Files
telegram-scraper/webui/app.js
T
forust 70ac1ce73e
ci / lint-prettier (push) Successful in 11s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 8s
ci / validate (push) Successful in 7s
ci / publish (push) Successful in 30s
feat(webui): add dashboard stat details
2026-07-26 19:53:19 +02:00

1047 lines
36 KiB
JavaScript

/* ── 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 =
'<td colspan="5"><div class="empty-state">No tracked channels yet. Add an ID or @username to start scraping this account.</div></td>';
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);
panel.querySelector('.channel-active-count').textContent = String(channels.length);
panel.querySelector('.channel-inactive-count').textContent = '0';
}
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 = '<div class="empty-state">No jobs yet. Start a scrape or export to see progress here.</div>';
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-next-run').textContent = nextContinuousRunLabel(config, status);
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 =
'<div class="log-line"><span class="log-time">-</span><span class="log-level">INFO</span><span class="log-message">No logs yet.</span></div>';
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 nextContinuousRunLabel(config, status) {
if (!config.enabled) return '-';
if (status.running) return 'running now';
const baseValue = status.last_iteration_at || status.last_finished_at || status.last_started_at;
if (!baseValue) return 'pending';
const baseDate = new Date(baseValue);
if (Number.isNaN(baseDate.getTime())) return 'pending';
const intervalMs = Math.max(1, Number(config.interval_minutes || 1)) * 60 * 1000;
const nextDate = new Date(baseDate.getTime() + intervalMs);
if (nextDate.getTime() <= Date.now()) return 'due now';
return displayTime(nextDate.toISOString());
}
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);
panel.querySelector('.forwarding-active-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(' | ');
const mediaCount = Number(health.media_count || 0);
panel.querySelector('.media-session-count').textContent = String(mediaCount);
panel.querySelector('.media-total-count').textContent = String(mediaCount);
panel.querySelector('.health-session-value').textContent = health.session_ready ? 'Valid' : 'Check';
panel.querySelector('.health-rate-value').textContent = health.active_job ? 'Busy' : 'OK';
}
/* ── 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');
const openSettingsBtn = document.getElementById('open-settings-btn');
if (!openSettingsBtn.matches('a[href]')) {
openSettingsBtn.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');
});