/* ── State ───────────────────────────────────────────── */ const state = { accounts: [], activeAccount: null, dashboards: {}, continuous: {}, channels: {}, jobs: {}, authStates: {}, }; const ACTIVE_ACCOUNT_STORAGE_KEY = 'telegramScraper.activeAccount'; const jobStreams = new Map(); /* ── API helper ─────────────────────────────────────── */ async function api(path, options = {}) { const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options, }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Request failed'); } return data; } /* ── Format helpers ─────────────────────────────────── */ function formatDate(value) { if (!value) return '-'; return value.replace('T', ' ').replace('+00:00', ' UTC'); } function relativeTime(value) { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '-'; const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); if (seconds < 10) return 'now'; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; } function displayTime(value) { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return formatDate(value); return date.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function setBusy(button, busy) { if (!button) return; button.disabled = busy; } function confirmAction(message) { return window.confirm(message); } function showToast(message, type = 'info') { let root = document.getElementById('toast-root'); if (!root) { root = document.createElement('div'); root.id = 'toast-root'; root.className = 'toast-root'; document.body.appendChild(root); } const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; root.appendChild(toast); window.setTimeout(() => { toast.classList.add('toast-hide'); window.setTimeout(() => toast.remove(), 250); }, 3600); } function downloadJson(filename, payload) { const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], { type: 'application/json', }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function isAccountAuthorized(auth) { const status = auth?.auth_status || auth || {}; return ( auth?.phase === 'authorized' || auth?.status === 'authorized' || status.status === 'ready' || status.status === 'authorized' ); } function saveActiveAccount(accountId) { if (accountId) { localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, accountId); } } function loadSavedActiveAccount(accounts) { let savedId = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY); if (!savedId) { savedId = localStorage.getItem('activeAccount'); if (savedId) { localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, savedId); localStorage.removeItem('activeAccount'); } } if (savedId && accounts.some((account) => account.id === savedId)) { return savedId; } if (savedId) { localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY); } if (state.activeAccount && accounts.some((account) => account.id === state.activeAccount)) { return state.activeAccount; } return accounts[0]?.id || null; } /* ── Account tab management ─────────────────────────── */ function renderAccountTabs(accounts) { const container = document.getElementById('account-tabs'); const template = document.getElementById('account-tab-template'); container.innerHTML = ''; accounts.forEach((acc) => { const node = template.content.firstElementChild.cloneNode(true); node.dataset.accountId = acc.id; node.querySelector('.account-tab-name').textContent = acc.label || acc.id; const statusEl = node.querySelector('.account-tab-status'); if (acc.auth) { const authOk = isAccountAuthorized(acc.auth); statusEl.textContent = authOk ? '●' : '○'; statusEl.className = 'account-tab-status ' + (authOk ? 'ok' : ''); } if (acc.id === state.activeAccount) { node.classList.add('active'); } node.addEventListener('click', () => switchAccount(acc.id)); container.appendChild(node); }); } function renderAccountPanel(accountId) { const container = document.getElementById('account-panels'); const template = document.getElementById('account-panel-template'); // Don't duplicate if (document.getElementById(`panel-${accountId}`)) return; const node = template.content.firstElementChild.cloneNode(true); node.id = `panel-${accountId}`; node.dataset.accountId = accountId; // Add/remove channel form const addForm = node.querySelector('.add-channel-form'); addForm.addEventListener('submit', async (event) => { event.preventDefault(); const channelId = addForm.querySelector('.add-channel-id').value.trim(); const name = addForm.querySelector('.add-channel-name').value.trim(); if (!channelId) return; await api(`/api/accounts/${accountId}/channels/add`, { method: 'POST', body: JSON.stringify({ channel_id: channelId, name }), }); addForm.reset(); await refreshAccount(accountId); }); // Continuous form const contForm = node.querySelector('.continuous-form'); contForm.addEventListener('submit', async (event) => { event.preventDefault(); const enabled = contForm.querySelector('.continuous-enabled').checked; const intervalMinutes = contForm.querySelector('.continuous-interval').value; const runAllTracked = contForm.querySelector('.continuous-all').checked; const channels = Array.from(contForm.querySelectorAll('.continuous-channel-checkbox:checked')).map( (item) => item.value, ); if (!runAllTracked && channels.length === 0 && enabled) { if (!confirmAction('Continuous scraping enabled with no selected channels. Save anyway?')) return; } await api(`/api/accounts/${accountId}/continuous`, { method: 'POST', body: JSON.stringify({ enabled, interval_minutes: intervalMinutes, run_all_tracked: runAllTracked, channels }), }); await refreshAccount(accountId); }); // "All tracked" checkbox toggles individual checkboxes const contAll = contForm.querySelector('.continuous-all'); contAll.addEventListener('change', () => { const checkboxes = contForm.querySelectorAll('.continuous-channel-checkbox'); checkboxes.forEach((cb) => { cb.disabled = contAll.checked; }); }); // Content tabs within account panel const contentTabs = node.querySelectorAll('.content-tab'); contentTabs.forEach((tab) => { tab.addEventListener('click', () => { const targetId = tab.dataset.tabTarget; contentTabs.forEach((t) => t.classList.toggle('active', t === tab)); node.querySelectorAll('.tab-panel').forEach((p) => { p.classList.toggle('active', p.dataset.panel === targetId); }); }); }); // Refresh jobs node.querySelector('.refresh-jobs-btn').addEventListener('click', () => refreshAccount(accountId)); container.appendChild(node); } function closeAllJobStreams() { for (const [jobId, handle] of jobStreams) { if (handle instanceof EventSource || typeof handle?.close === 'function') { handle.close(); } else if (typeof handle === 'number') { clearInterval(handle); } } jobStreams.clear(); } function switchAccount(accountId) { closeAllJobStreams(); state.activeAccount = accountId; saveActiveAccount(accountId); // Update tabs document.querySelectorAll('.account-tab').forEach((tab) => { tab.classList.toggle('active', tab.dataset.accountId === accountId); }); // Show/hide panels document.querySelectorAll('.account-panel').forEach((panel) => { panel.classList.toggle('hidden', panel.dataset.accountId !== accountId); }); // Ensure panel exists if (!document.getElementById(`panel-${accountId}`)) { renderAccountPanel(accountId); } // Update sidebar updateSidebarAccount(); // Refresh data refreshAccount(accountId); // Update settings auth section updateAuthSection(accountId); } function updateSidebarAccount() { const acc = state.accounts.find((a) => a.id === state.activeAccount); const nameEl = document.getElementById('active-account-name'); const statusEl = document.getElementById('active-account-status'); if (acc) { nameEl.textContent = acc.label || acc.id; const authOk = isAccountAuthorized(acc.auth); statusEl.textContent = authOk ? 'Authorized session' : 'Needs login'; statusEl.style.color = authOk ? 'var(--ok)' : 'var(--danger)'; } else { nameEl.textContent = 'None'; statusEl.textContent = 'Add an account in Settings'; statusEl.style.color = 'var(--dim)'; } } /* ── Dashboard rendering ────────────────────────────── */ function renderChannels(accountId, channels) { const panel = document.getElementById(`panel-${accountId}`); if (!panel) return; const tbody = panel.querySelector('.channels-table'); const template = document.getElementById('channel-row-template'); tbody.innerHTML = ''; if (!channels.length) { const row = document.createElement('tr'); row.innerHTML = '
No tracked channels yet. Add an ID or @username to start scraping this account.
'; tbody.appendChild(row); } channels.forEach((channel) => { const node = template.content.firstElementChild.cloneNode(true); node.querySelector('.channel-name').textContent = channel.name; node.querySelector('.channel-id').textContent = channel.channel_id; node.querySelector('.message-count').textContent = String(channel.message_count); node.querySelector('.media-count').textContent = String(channel.media_count); node.querySelector('.last-date').textContent = channel.last_date || '-'; node.querySelector('.scrape-btn').addEventListener('click', async () => { await api(`/api/accounts/${accountId}/jobs/scrape`, { method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); node.querySelector('.export-btn').addEventListener('click', async () => { await api(`/api/accounts/${accountId}/jobs/export-channel`, { method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); node.querySelector('.export-view-btn').addEventListener('click', () => { window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}&account=${encodeURIComponent(accountId)}`; }); node.querySelector('.media-btn').addEventListener('click', async () => { await api(`/api/accounts/${accountId}/jobs/rescrape-media`, { method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); node.querySelector('.remove-btn').addEventListener('click', async () => { if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return; await api(`/api/accounts/${accountId}/channels/remove`, { method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); tbody.appendChild(node); }); // Update channel count stat panel.querySelector('.channel-count').textContent = String(channels.length); } function renderJobs(accountId, jobs) { const panel = document.getElementById(`panel-${accountId}`); if (!panel) return; const root = panel.querySelector('.jobs-list'); const template = document.getElementById('job-template'); root.innerHTML = ''; if (!jobs.length) { root.innerHTML = '
No jobs yet. Start a scrape or export to see progress here.
'; return; } jobs.forEach((job) => { const node = template.content.firstElementChild.cloneNode(true); node.dataset.jobId = job.job_id; node.querySelector('.job-title').textContent = job.title; node.querySelector('.job-status').textContent = job.status; node.querySelector('.job-time').textContent = [displayTime(job.started_at), displayTime(job.finished_at)] .filter((item) => item !== '-') .join(' -> '); node.querySelector('.job-logs').textContent = job.logs || job.error || 'No logs.'; root.appendChild(node); subscribeJobStream(accountId, job.job_id, job.status); }); } function updateRenderedJob(accountId, job) { const panel = document.getElementById(`panel-${accountId}`); const node = panel?.querySelector(`[data-job-id="${job.job_id}"]`); if (!node) return; node.querySelector('.job-status').textContent = job.status; node.querySelector('.job-time').textContent = [displayTime(job.started_at), displayTime(job.finished_at)] .filter((item) => item !== '-') .join(' -> '); node.querySelector('.job-logs').textContent = job.logs || job.error || 'No logs.'; } function pollJobFallback(accountId, jobId) { let pollAttempts = 0; const maxPollAttempts = 60; const pollInterval = 2000; const pollTimer = window.setInterval(async () => { pollAttempts++; try { const job = await api(`/api/jobs/${encodeURIComponent(jobId)}`); updateRenderedJob(accountId, job); if (['completed', 'failed'].includes(job.status)) { clearInterval(pollTimer); jobStreams.delete(jobId); refreshAccount(accountId); return; } } catch { // ignore poll errors } if (pollAttempts >= maxPollAttempts) { clearInterval(pollTimer); jobStreams.delete(jobId); } }, pollInterval); jobStreams.set(jobId, pollTimer); } function subscribeJobStream(accountId, jobId, status) { if (!['queued', 'running'].includes(status) || jobStreams.has(jobId)) { return; } // If EventSource not available, fall back to polling if (!window.EventSource) { pollJobFallback(accountId, jobId); return; } let retryCount = 0; const maxRetries = 5; let stream = null; function connect() { if (stream) stream.close(); if (jobStreams.get(jobId) === 'dead') return; const newStream = new EventSource(`/api/jobs/${encodeURIComponent(jobId)}/events`); stream = newStream; jobStreams.set(jobId, newStream); newStream.onmessage = (event) => { retryCount = 0; // reset backoff on successful message const job = JSON.parse(event.data); updateRenderedJob(accountId, job); if (['completed', 'failed'].includes(job.status)) { newStream.close(); jobStreams.delete(jobId); refreshAccount(accountId); } }; newStream.onerror = () => { newStream.close(); if (jobStreams.get(jobId) === 'dead') return; retryCount++; if (retryCount >= maxRetries) { // Mark as dead and switch to polling jobStreams.delete(jobId); showToast('Live job updates degraded — switching to polling.', 'warn'); pollJobFallback(accountId, jobId); return; } // Exponential backoff: 1s, 2s, 4s, 8s, 16s const delay = Math.min(1000 * Math.pow(2, retryCount - 1), 16000); setTimeout(connect, delay); }; } connect(); } function renderContinuous(accountId, data) { const panel = document.getElementById(`panel-${accountId}`); if (!panel) return; const config = data.config || {}; const status = data.status || {}; const contForm = panel.querySelector('.continuous-form'); contForm.querySelector('.continuous-enabled').checked = Boolean(config.enabled); contForm.querySelector('.continuous-interval').value = config.interval_minutes || 1; contForm.querySelector('.continuous-all').checked = Boolean(config.run_all_tracked); // Channel picker const chList = panel.querySelector('.continuous-channel-list'); const channels = state.channels[accountId] || []; const runAll = Boolean(config.run_all_tracked); const selected = new Set(config.channels || []); chList.innerHTML = ''; channels.forEach((ch) => { const label = document.createElement('label'); label.className = 'checkbox-row'; label.classList.toggle('disabled', runAll); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'continuous-channel-checkbox'; checkbox.value = ch.channel_id; checkbox.checked = runAll || selected.has(ch.channel_id); checkbox.disabled = runAll; const text = document.createElement('span'); text.textContent = `${ch.name} (${ch.channel_id})`; label.append(checkbox, text); chList.appendChild(label); }); // Meta panel.querySelector('.continuous-status').textContent = status.running ? 'running' : 'stopped'; panel.querySelector('.continuous-last-iteration').textContent = status.last_iteration_at ? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})` : '-'; panel.querySelector('.continuous-last-error').textContent = status.last_error || '-'; // Logs const logsRoot = panel.querySelector('.continuous-logs'); const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); logsRoot.innerHTML = ''; if (!entries.length) { logsRoot.innerHTML = '
-INFONo logs yet.
'; return; } entries .slice() .reverse() .forEach((entry) => { const row = document.createElement('div'); row.className = `log-line ${entry.level}`; const time = entry.timestamp ? displayTime(entry.timestamp).split(', ').pop() : entry.legacyTime || '-'; const absolute = entry.timestamp ? displayTime(entry.timestamp) : entry.legacyTime || '-'; row.title = absolute; const timeNode = document.createElement('span'); timeNode.className = 'log-time'; timeNode.textContent = time; const ageNode = document.createElement('span'); ageNode.className = 'log-age'; ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : '-'; const levelNode = document.createElement('span'); levelNode.className = 'log-level'; levelNode.textContent = entry.level.toUpperCase(); const messageNode = document.createElement('span'); messageNode.className = 'log-message'; messageNode.textContent = entry.message; row.append(timeNode, ageNode, levelNode, messageNode); logsRoot.appendChild(row); }); } function classifyLog(message, level) { if (level) return level; const normalized = message.toLowerCase(); if (normalized.includes('failed') || normalized.includes('error') || normalized.includes('traceback')) return 'error'; if (normalized.includes('sleep') || normalized.includes('no channels')) return 'warn'; if (normalized.includes('finished') || normalized.includes('completed')) return 'success'; if (normalized.includes('starting') || normalized.includes('started')) return 'info'; return 'debug'; } function normalizeLogEntry(entry) { if (typeof entry === 'object' && entry !== null) { return { timestamp: entry.timestamp || null, level: classifyLog(entry.message || '', entry.level), message: entry.message || '', }; } const line = String(entry || ''); const match = line.match(/^\[(\d{2}:\d{2}:\d{2})\]\s?(.*)$/); return { timestamp: null, legacyTime: match?.[1] || '', level: classifyLog(match?.[2] || line), message: match?.[2] || line, }; } function renderSummary(accountId, data) { const panel = document.getElementById(`panel-${accountId}`); if (!panel) return; const d = data.dashboard || data.state || {}; const health = data.health || {}; panel.querySelector('.forwarding-count').textContent = String((d.forwarding_rules || []).length); const toggle = panel.querySelector('.scrape-media-label'); toggle.textContent = d.scrape_media ? 'ON' : 'OFF'; const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists; panel.querySelector('.account-health-label').textContent = healthOk ? 'Ready' : 'Check'; panel.querySelector('.account-health-label').style.color = healthOk ? 'var(--ok)' : 'var(--warn)'; panel.querySelector('.account-health-detail').textContent = [ `${health.message_count || 0} messages`, `${health.media_count || 0} media`, health.active_job ? `active: ${health.active_job.status}` : 'idle', ].join(' | '); } /* ── Account data loading ───────────────────────────── */ async function refreshAccount(accountId) { try { const [dashboard, authData, continuousData] = await Promise.all([ api(`/api/accounts/${accountId}`), api(`/api/accounts/${accountId}/auth`).catch(() => ({})), api(`/api/accounts/${accountId}/continuous`).catch(() => ({})), ]); state.dashboards[accountId] = dashboard; state.continuous[accountId] = continuousData; state.authStates[accountId] = authData; state.channels[accountId] = dashboard.channels || []; renderSummary(accountId, dashboard); renderChannels(accountId, dashboard.channels || []); renderJobs(accountId, dashboard.jobs || []); renderContinuous(accountId, continuousData); // Update tab status indicator const tab = document.querySelector(`.account-tab[data-account-id="${accountId}"]`); if (tab) { const statusEl = tab.querySelector('.account-tab-status'); const authOk = isAccountAuthorized(authData); statusEl.textContent = authOk ? '●' : '○'; statusEl.className = 'account-tab-status ' + (authOk ? 'ok' : ''); } } catch (err) { console.error(`Failed to refresh account ${accountId}:`, err); } } async function loadAccounts() { let accounts = []; try { const resp = await api('/api/accounts'); accounts = resp.accounts || []; } catch { // Legacy fallback — check /api/auth try { const legacy = await api('/api/auth'); if (legacy.saved_credentials?.api_id) { accounts = [{ id: 'default', label: 'Default', auth: legacy.auth_status || legacy }]; } } catch { // No accounts at all } } state.accounts = accounts; if (accounts.length === 0) { document.getElementById('no-accounts-msg').classList.remove('hidden'); return; } document.getElementById('no-accounts-msg').classList.add('hidden'); // Render tabs renderAccountTabs(accounts); // Render panels for each account accounts.forEach((acc) => renderAccountPanel(acc.id)); // Determine active account (persisted across reloads) const activeId = loadSavedActiveAccount(accounts); if (activeId) { switchAccount(activeId); } // Update settings renderSettingsAccounts(); } /* ── Settings: Accounts list ────────────────────────── */ function renderSettingsAccounts() { const container = document.getElementById('accounts-list'); const template = document.getElementById('account-list-item-template'); container.innerHTML = ''; state.accounts.forEach((acc) => { const node = template.content.firstElementChild.cloneNode(true); node.querySelector('.account-list-label').textContent = acc.label || acc.id; node.querySelector('.account-list-id').textContent = acc.id; const authStatus = node.querySelector('.account-list-auth-status'); const authOk = isAccountAuthorized(acc.auth); authStatus.textContent = authOk ? 'Authorized' : 'Needs auth'; authStatus.style.color = authOk ? 'var(--ok)' : 'var(--danger)'; authStatus.style.fontSize = '0.78rem'; node.querySelector('.account-select-btn').addEventListener('click', () => { switchAccount(acc.id); document.getElementById('settings-dialog').close(); }); node.querySelector('.account-export-btn').addEventListener('click', async () => { try { const payload = await api(`/api/accounts/${acc.id}/export`); downloadJson(`telegram-scraper-account-${acc.id}.json`, payload); showToast(`Exported ${acc.label || acc.id}.`, 'success'); } catch (err) { showToast(`Failed to export account: ${err.message}`, 'error'); } }); node.querySelector('.account-remove-btn').addEventListener('click', async () => { if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return; try { const removingActive = state.activeAccount === acc.id; await api(`/api/accounts/${acc.id}`, { method: 'DELETE' }); if (removingActive) { state.activeAccount = null; localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY); } await loadAccounts(); } catch (err) { showToast(`Failed to remove account: ${err.message}`, 'error'); } }); container.appendChild(node); }); } /* ── Auth (operates on active account) ───────────────── */ function updateAuthSection(accountId) { const label = document.getElementById('auth-account-label'); const acc = state.accounts.find((a) => a.id === accountId); label.textContent = acc ? acc.label || acc.id : '-'; } async function saveCredentials(accountId) { const apiId = document.getElementById('api-id-input').value.trim(); const apiHash = document.getElementById('api-hash-input').value.trim(); if (!apiId || !apiHash) return; await api(`/api/accounts/${accountId}/auth/credentials`, { method: 'POST', body: JSON.stringify({ api_id: apiId, api_hash: apiHash }), }); await loadAccounts(); } async function startQrLogin(accountId) { const data = await api(`/api/accounts/${accountId}/auth/qr/start`, { method: 'POST', body: JSON.stringify({}), }); if (data.qr_image) { document.getElementById('qr-image').src = data.qr_image; document.getElementById('qr-wrap').classList.remove('hidden'); } await loadAccounts(); } async function requestPhoneCode(accountId) { const phone = document.getElementById('phone-input').value.trim(); if (!phone) return; await api(`/api/accounts/${accountId}/auth/phone/request`, { method: 'POST', body: JSON.stringify({ phone }), }); await loadAccounts(); } async function submitPhoneCode(accountId) { const code = document.getElementById('code-input').value.trim(); if (!code) return; await api(`/api/accounts/${accountId}/auth/phone/submit`, { method: 'POST', body: JSON.stringify({ code }), }); document.getElementById('code-input').value = ''; await loadAccounts(); } async function submitPassword(accountId) { const password = document.getElementById('password-input').value.trim(); if (!password) return; await api(`/api/accounts/${accountId}/auth/password`, { method: 'POST', body: JSON.stringify({ password }), }); document.getElementById('password-input').value = ''; await loadAccounts(); } /* ── Scrape / Export all (for active account) ───────── */ async function scrapeAll() { if (!state.activeAccount) return; if (!confirmAction('Queue scraping for all tracked channels?')) return; await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, { method: 'POST', body: JSON.stringify({}), }); await refreshAccount(state.activeAccount); } async function exportAll() { if (!state.activeAccount) return; if (!confirmAction('Queue export for all tracked channels?')) return; await api(`/api/accounts/${state.activeAccount}/jobs/export`, { method: 'POST', body: JSON.stringify({}), }); await refreshAccount(state.activeAccount); } async function refreshDialogs() { if (!state.activeAccount) return; await api(`/api/accounts/${state.activeAccount}/jobs/refresh-dialogs`, { method: 'POST', body: JSON.stringify({}), }); await refreshAccount(state.activeAccount); } async function toggleMedia(value) { if (!state.activeAccount) return; await api(`/api/accounts/${state.activeAccount}/settings/media`, { method: 'POST', body: JSON.stringify({ value }), }); await refreshAccount(state.activeAccount); } /* ── Main ───────────────────────────────────────────── */ async function main() { // ── Sidebar action buttons ── document.getElementById('scrape-all-btn').addEventListener('click', async () => { try { await scrapeAll(); showToast('Scrape job queued.', 'success'); } catch (err) { showToast('Scrape error: ' + err.message, 'error'); } }); document.getElementById('export-all-btn').addEventListener('click', async () => { try { await exportAll(); showToast('Export job queued.', 'success'); } catch (err) { showToast('Export error: ' + err.message, 'error'); } }); document.getElementById('refresh-dialogs-btn').addEventListener('click', async () => { try { await refreshDialogs(); showToast('Dialog refresh queued.', 'success'); } catch (err) { showToast('Dialog refresh error: ' + err.message, 'error'); } }); // ── Settings dialog ── const settingsDialog = document.getElementById('settings-dialog'); document.getElementById('open-settings-btn').addEventListener('click', () => { // Update auth section for active account if (state.activeAccount) { updateAuthSection(state.activeAccount); } renderSettingsAccounts(); settingsDialog.showModal(); }); document.getElementById('close-settings-btn').addEventListener('click', () => { settingsDialog.close(); }); // ── Add account form ── document.getElementById('add-account-form').addEventListener('submit', async (event) => { event.preventDefault(); const accountId = document.getElementById('add-account-id').value.trim(); const label = document.getElementById('add-account-label').value.trim(); const apiId = document.getElementById('add-account-api-id').value.trim(); const apiHash = document.getElementById('add-account-api-hash').value.trim(); if (!accountId) return; try { await api('/api/accounts', { method: 'POST', body: JSON.stringify({ account_id: accountId, label: label || accountId, api_id: apiId ? parseInt(apiId) : undefined, api_hash: apiHash || undefined, }), }); event.target.reset(); await loadAccounts(); if (!state.activeAccount) { switchAccount(accountId); } } catch (err) { showToast('Failed to add account: ' + err.message, 'error'); } }); document.getElementById('import-account-file').addEventListener('change', async (event) => { const file = event.currentTarget.files?.[0]; if (!file) return; try { const payload = JSON.parse(await file.text()); const accountId = payload.account_id || payload.id; if (!accountId) throw new Error('account_id is missing in import file'); await api('/api/accounts/import', { method: 'POST', body: JSON.stringify(payload), }); event.currentTarget.value = ''; await loadAccounts(); switchAccount(accountId); showToast(`Imported ${accountId}.`, 'success'); } catch (err) { showToast(`Failed to import account: ${err.message}`, 'error'); } }); // ── Credentials form ── document.getElementById('credentials-form').addEventListener('submit', async (event) => { event.preventDefault(); if (!state.activeAccount) return; try { await saveCredentials(state.activeAccount); } catch (err) { showToast('Failed to save credentials: ' + err.message, 'error'); } }); // ── QR login ── document.getElementById('start-qr-btn').addEventListener('click', async (event) => { setBusy(event.currentTarget, true); try { if (!state.activeAccount) return; await startQrLogin(state.activeAccount); } catch (err) { showToast('QR login error: ' + err.message, 'error'); } finally { setBusy(event.currentTarget, false); } }); // ── Phone ── document.getElementById('phone-form').addEventListener('submit', async (event) => { event.preventDefault(); if (!state.activeAccount) return; try { await requestPhoneCode(state.activeAccount); } catch (err) { showToast('Phone code request error: ' + err.message, 'error'); } }); // ── Code ── document.getElementById('code-form').addEventListener('submit', async (event) => { event.preventDefault(); if (!state.activeAccount) return; try { await submitPhoneCode(state.activeAccount); } catch (err) { showToast('Code submit error: ' + err.message, 'error'); } }); // ── Password ── document.getElementById('password-form').addEventListener('submit', async (event) => { event.preventDefault(); if (!state.activeAccount) return; try { await submitPassword(state.activeAccount); } catch (err) { showToast('Password error: ' + err.message, 'error'); } }); // ── Media toggle ── document.getElementById('scrape-media-toggle').addEventListener('change', async (event) => { const checked = event.currentTarget.checked; try { await toggleMedia(checked); showToast('Media setting updated.', 'success'); } catch (err) { showToast('Media toggle error: ' + err.message, 'error'); } }); // ── Load accounts ── await loadAccounts(); // ── Periodic refresh ── window.setInterval(() => { if (state.activeAccount) { refreshAccount(state.activeAccount); } }, 5000); } main().catch((error) => { console.error(error); showToast(error.message, 'error'); });