Очистка удаленных

This commit is contained in:
Халимов Рустам
2026-03-17 15:55:41 +03:00
parent 67bf8319aa
commit 408cb446c3
13 changed files with 494 additions and 43 deletions
+4 -3
View File
@@ -362,10 +362,11 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0);
useEffect(() => {
if (loadedTabs.size === tabsConfig.length && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
const expected = chatId ? 5 : 1;
if (loadedTabs.size === expected && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
setActiveTab(availableTabs[0].key as any);
}
}, [loadedTabs, activeTab, tabsConfig.length]);
}, [loadedTabs, activeTab, chatId, availableTabs]);
return (
<>
@@ -903,7 +904,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
)}
</div>
</div>
) : loadedTabs.size === tabsConfig.length ? (
) : loadedTabs.size === (chatId ? 5 : 1) ? (
<div className="m-4 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
<ImageIcon size={32} className="text-zinc-600 mb-3" />
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
+16 -2
View File
@@ -1,9 +1,22 @@
// Notification sound using Web Audio API — generates a pleasant chime
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext {
function getAudioContext(): AudioContext | null {
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
if (!(navigator as any).userActivation.hasBeenActive) {
return null;
}
}
if (!audioContext) {
audioContext = new AudioContext();
try {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
if (AudioCtx) {
audioContext = new AudioCtx();
}
} catch {
return null;
}
}
return audioContext;
}
@@ -11,6 +24,7 @@ function getAudioContext(): AudioContext {
export function playNotificationSound() {
try {
const ctx = getAudioContext();
if (!ctx) return;
if (ctx.state === 'suspended') {
ctx.resume();
}
+16 -5
View File
@@ -100,13 +100,24 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
const cached = waveformCache.get(url);
if (cached) return cached;
let audioCtx: AudioContext | undefined;
let audioCtx: any;
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
await audioCtx.close();
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
if (OfflineCtx) {
audioCtx = new OfflineCtx(1, 1, 44100);
} else {
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
// We only need the buffer, so decode it
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
// If it was a regular AudioContext, close it.
if (audioCtx.close) {
await audioCtx.close();
}
audioCtx = undefined;
const channelData = audioBuffer.getChannelData(0);
@@ -132,7 +143,7 @@ export async function extractWaveform(url: string, bars: number = 28): Promise<n
return normalized;
} catch {
// Close leaked AudioContext if any
if (audioCtx) audioCtx.close().catch(() => {});
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
// On error, return uniform bars
return Array(bars).fill(0.5);
}
+212 -13
View File
@@ -108,8 +108,28 @@ const translations = {
copied: 'Copied!',
createUserSuccess: 'User created successfully',
passwordResetSuccess: 'Password reset successfully',
resetPasswordDesc: 'Generate a new strong password for this user and invalidate the old one.',
usernamePlaceholder: 'Ex: johndoe',
displayNamePlaceholder: 'Ex: John Doe',
passwordPlaceholder: 'Strong password...',
generate: 'Generate',
cancel: 'Cancel',
displayName: 'Display Name',
createUserError: 'Error creating user',
createUser: 'Create User',
dataCleanup: 'Data Cleanup',
dataCleanupDesc: 'Calculate and remove unreachable messages (e.g. from deleted groups) and media to free up disk space.',
calcCleanupBtn: 'Calculate GC',
runCleanupBtn: 'Run Clean',
cleanMessages: 'Orphaned Messages',
cleanMedia: 'Orphaned Media Size',
cleanSuccess: 'Cleanup completed successfully!',
testConnection: 'Test Connection',
turnSuccess: 'TURN Connection Successful!',
turnFailed: 'TURN Test Failed: ',
klipySuccess: 'Klipy connection successful!',
klipyFailed: 'Klipy Test Failed: ',
apiRequired: 'API Key is required',
},
ru: {
loginTitle: 'Вход администратора',
@@ -173,8 +193,28 @@ const translations = {
copied: 'Скопировано!',
createUserSuccess: 'Пользователь успешно создан',
passwordResetSuccess: 'Пароль успешно сброшен',
resetPasswordDesc: 'Сгенерировать новый надежный пароль для этого пользователя и аннулировать старый.',
usernamePlaceholder: 'Например: johndoe',
displayNamePlaceholder: 'Например: Иван Иванов',
passwordPlaceholder: 'Надежный пароль...',
generate: 'Сгенерировать',
cancel: 'Отмена',
displayName: 'Имя профиля',
createUserError: 'Ошибка при создании',
createUser: 'Создать',
dataCleanup: 'Очистка данных',
dataCleanupDesc: 'Вычисление и удаление недостижимых сообщений (например, из удаленных групп) и файлов для освобождения места.',
calcCleanupBtn: 'Оценить объем',
runCleanupBtn: 'Очистить данные',
cleanMessages: 'Сообщений к удалению',
cleanMedia: 'Медиа к удалению',
cleanSuccess: 'Очистка успешно завершена!',
testConnection: 'Проверить соединение',
turnSuccess: 'Успешное подключение к TURN!',
turnFailed: 'Ошибка TURN: ',
klipySuccess: 'Успешное подключение к Klipy!',
klipyFailed: 'Ошибка Klipy: ',
apiRequired: 'API ключ обязателен',
}
};
@@ -199,18 +239,26 @@ export default function AdminPage() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
interface CleanStats {
orphanedMessagesCount: number;
orphanedMediaBytes: number;
}
const [stats, setStats] = useState<Stats | null>(null);
const [config, setConfig] = useState<Conf | null>(null);
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
const [authHeader, setAuthHeader] = useState('');
const [creds, setCreds] = useState({ user: '', pass: '' });
const [authenticated, setAuthenticated] = useState(false);
// Users Tab
const [users, setUsers] = useState<AppUser[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<AppUser | null>(null);
const [isTestingTurn, setIsTestingTurn] = useState<boolean>(false);
const [isTestingKlipy, setIsTestingKlipy] = useState<boolean>(false);
const [domainInput, setDomainInput] = useState('');
// Add User State
@@ -278,6 +326,95 @@ export default function AdminPage() {
} catch {}
};
const handleTestTurn = async () => {
if (!config || !config.turnHost) return;
setIsTestingTurn(true);
let resolved = false;
try {
const pc = new RTCPeerConnection({
iceServers: [{
urls: `turn:${config.turnHost}:${config.turnPort}`,
username: config.turnUser,
credential: config.turnSecret
}]
});
pc.addTransceiver('audio');
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error("Timeout gathering relay candidates"));
}
}, 10000);
pc.onicecandidate = (e) => {
if (e.candidate && (e.candidate.type === 'relay' || e.candidate.type === 'srflx')) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve();
}
}
if (e.candidate === null) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error("Failed to gather candidates. Check credentials or network."));
}
}
};
});
showToast(t.turnSuccess, 'success');
pc.close();
} catch (err: any) {
showToast(t.turnFailed + err.message, 'error');
} finally {
setIsTestingTurn(false);
}
};
const handleTestKlipy = async () => {
if (!config || !config.klipyApiKey) {
showToast(t.apiRequired, 'error');
return;
}
setIsTestingKlipy(true);
try {
const customerId = config.klipyCustomerId || "anonymous";
let url = `https://api.klipy.co/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
let res = await fetch(url);
if (!res.ok && res.status === 404) {
url = `https://api.klipy.com/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
res = await fetch(url);
}
if (res.ok) {
const data = await res.json().catch(() => null);
if (data && !data.error && (Array.isArray(data) || data.gifs || data.data)) {
showToast(t.klipySuccess, 'success');
} else {
showToast(t.klipyFailed + (data?.error || "Invalid response format"), 'error');
}
} else {
const err = await res.json().catch(() => ({}));
showToast(t.klipyFailed + (err?.error || err?.message || res.statusText), 'error');
}
} catch (err: any) {
showToast(t.klipyFailed + err.message, 'error');
} finally {
setIsTestingKlipy(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
@@ -330,6 +467,24 @@ export default function AdminPage() {
}
};
const handleCalcCleanup = async () => {
try {
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } });
if (res.ok) setCleanStats(await res.json());
} catch {}
};
const handleRunCleanup = async () => {
try {
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } });
if (res.ok) {
showToast(t.cleanSuccess, 'success');
setCleanStats(null);
fetchDashboard(authHeader);
}
} catch {}
};
const fetchUserDetails = async (id: string) => {
try {
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
@@ -416,7 +571,7 @@ export default function AdminPage() {
const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0;
return (
<div className="min-h-screen bg-black text-white font-sans flex overflow-hidden">
<div className="h-[100dvh] w-full bg-black text-white font-sans flex overflow-hidden">
<aside className="w-64 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0 overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-accent flex items-center gap-2">
@@ -482,6 +637,38 @@ export default function AdminPage() {
</div>
</div>
</div>
{/* DATA CLEANUP WIDGET */}
<div className="bg-surface border border-white/10 rounded-2xl p-6 lg:col-span-2 relative overflow-hidden">
<h3 className="text-gray-400 font-medium mb-2 flex items-center gap-2 relative z-10">
<Trash2 className="w-5 h-5"/> {t.dataCleanup}
</h3>
<div className="text-sm text-gray-500 mb-6 relative z-10 max-w-lg">{t.dataCleanupDesc}</div>
{!cleanStats ? (
<button onClick={handleCalcCleanup} className="bg-accent/10 hover:bg-accent/20 text-accent font-semibold px-4 py-2 rounded-xl transition-colors shrink-0">
{t.calcCleanupBtn}
</button>
) : (
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
<span className="text-gray-400">{t.cleanMessages}:</span>
<span className="text-white font-mono">{cleanStats.orphanedMessagesCount}</span>
</div>
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
<span className="text-gray-400">{t.cleanMedia}:</span>
<span className="text-white font-mono">{formatBytes(cleanStats.orphanedMediaBytes)}</span>
</div>
<div className="flex justify-start gap-4 mt-2">
<button onClick={handleRunCleanup} className="bg-red-500 hover:bg-red-600 text-white font-semibold px-6 py-2 rounded-xl transition-colors">
{t.runCleanupBtn}
</button>
<button onClick={() => setCleanStats(null)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
@@ -544,6 +731,12 @@ export default function AdminPage() {
{t.turnSecret}
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
</label>
<div className="flex justify-start mt-2">
<button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
{isTestingTurn ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
{t.testConnection}
</button>
</div>
</motion.div>
)}
</div>
@@ -566,11 +759,17 @@ export default function AdminPage() {
Customer ID (for tracking/analytics)
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyCustomerId || ''} onChange={e => setConfig({...config, klipyCustomerId: e.target.value})} />
</label>
<div className="flex justify-start mt-2">
<button onClick={handleTestKlipy} disabled={isTestingKlipy} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
{isTestingKlipy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
{t.testConnection}
</button>
</div>
</motion.div>
)}
</div>
<div className="sticky bottom-8 self-end">
<div className="mt-8 flex justify-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
@@ -635,7 +834,7 @@ export default function AdminPage() {
)}
</div>
<div className="sticky bottom-8 self-end">
<div className="mt-8 flex justify-end">
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
{t.saveChanges}
</button>
@@ -761,7 +960,7 @@ export default function AdminPage() {
<div className="bg-red-400/5 border border-red-400/20 p-5 rounded-xl flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex-1">
<h4 className="font-medium text-white">{t.resetPassword}</h4>
<p className="text-sm text-gray-400 mt-1">Generate a new strong password for this user and invalidate the old one.</p>
<p className="text-sm text-gray-400 mt-1">{t.resetPasswordDesc}</p>
</div>
<button onClick={() => handleResetPassword(selectedUser.id)} className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-6 py-2 rounded-xl transition-colors font-semibold whitespace-nowrap">
{t.resetPassword}
@@ -797,45 +996,45 @@ export default function AdminPage() {
<h2 className="text-2xl font-bold text-white mb-2">{t.addUser}</h2>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Username
{t.username}
<input
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={newUser.username}
onChange={e => setNewUser({...newUser, username: e.target.value.toLowerCase()})}
placeholder="Ex: john_doe"
placeholder={t.usernamePlaceholder}
/>
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Display Name
{t.displayName}
<input
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
value={newUser.displayName}
onChange={e => setNewUser({...newUser, displayName: e.target.value})}
placeholder="Ex: John Doe"
placeholder={t.displayNamePlaceholder}
/>
</label>
<label className="flex flex-col gap-1 text-sm text-gray-400">
Password
{t.password}
<div className="flex gap-2">
<input
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent flex-1 font-mono"
value={newUser.password}
onChange={e => setNewUser({...newUser, password: e.target.value})}
placeholder="Strong password..."
placeholder={t.passwordPlaceholder}
/>
<button
onClick={() => setNewUser({...newUser, password: generatePassword()})}
className="bg-white/10 hover:bg-white/20 px-4 rounded-xl transition-colors shrink-0"
>
Generate
{t.generate}
</button>
</div>
</label>
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-white/10">
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">Cancel</button>
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.displayName || !newUser.password} className="bg-accent text-black font-semibold px-6 py-2 rounded-xl hover:bg-accentLight transition-colors disabled:opacity-50">
{t.createUser}
</button>