diff --git a/webui/app.js b/webui/app.js index cb9bc72..0029bd6 100644 --- a/webui/app.js +++ b/webui/app.js @@ -10,19 +10,19 @@ const state = { authStates: {}, }; -const ACTIVE_ACCOUNT_STORAGE_KEY = "telegramScraper.activeAccount"; +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" }, + headers: { 'Content-Type': 'application/json' }, ...options, }); const data = await response.json(); if (!response.ok) { - throw new Error(data.error || "Request failed"); + throw new Error(data.error || 'Request failed'); } return data; } @@ -30,16 +30,16 @@ async function api(path, options = {}) { /* ── Format helpers ─────────────────────────────────── */ function formatDate(value) { - if (!value) return "-"; - return value.replace("T", " ").replace("+00:00", " UTC"); + if (!value) return '-'; + return value.replace('T', ' ').replace('+00:00', ' UTC'); } function relativeTime(value) { - if (!value) return "-"; + if (!value) return '-'; const date = new Date(value); - if (Number.isNaN(date.getTime())) return "-"; + 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 < 10) return 'now'; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; @@ -49,16 +49,16 @@ function relativeTime(value) { } function displayTime(value) { - if (!value) return "-"; + 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", + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', }); } @@ -71,30 +71,30 @@ function confirmAction(message) { return window.confirm(message); } -function showToast(message, type = "info") { - let root = document.getElementById("toast-root"); +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"; + root = document.createElement('div'); + root.id = 'toast-root'; + root.className = 'toast-root'; document.body.appendChild(root); } - const toast = document.createElement("div"); + const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; root.appendChild(toast); window.setTimeout(() => { - toast.classList.add("toast-hide"); + 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 blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], { + type: 'application/json', }); const url = URL.createObjectURL(blob); - const link = document.createElement("a"); + const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); @@ -106,10 +106,10 @@ function downloadJson(filename, payload) { function isAccountAuthorized(auth) { const status = auth?.auth_status || auth || {}; return ( - auth?.phase === "authorized" || - auth?.status === "authorized" || - status.status === "ready" || - status.status === "authorized" + auth?.phase === 'authorized' || + auth?.status === 'authorized' || + status.status === 'ready' || + status.status === 'authorized' ); } @@ -122,10 +122,10 @@ function saveActiveAccount(accountId) { function loadSavedActiveAccount(accounts) { let savedId = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY); if (!savedId) { - savedId = localStorage.getItem("activeAccount"); + savedId = localStorage.getItem('activeAccount'); if (savedId) { localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, savedId); - localStorage.removeItem("activeAccount"); + localStorage.removeItem('activeAccount'); } } if (savedId && accounts.some((account) => account.id === savedId)) { @@ -143,31 +143,31 @@ function loadSavedActiveAccount(accounts) { /* ── Account tab management ─────────────────────────── */ function renderAccountTabs(accounts) { - const container = document.getElementById("account-tabs"); - const template = document.getElementById("account-tab-template"); - container.innerHTML = ""; + 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"); + 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" : ""); + statusEl.textContent = authOk ? '●' : '○'; + statusEl.className = 'account-tab-status ' + (authOk ? 'ok' : ''); } if (acc.id === state.activeAccount) { - node.classList.add("active"); + node.classList.add('active'); } - node.addEventListener("click", () => switchAccount(acc.id)); + 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"); + const container = document.getElementById('account-panels'); + const template = document.getElementById('account-panel-template'); // Don't duplicate if (document.getElementById(`panel-${accountId}`)) return; @@ -177,14 +177,14 @@ function renderAccountPanel(accountId) { node.dataset.accountId = accountId; // Add/remove channel form - const addForm = node.querySelector(".add-channel-form"); - addForm.addEventListener("submit", async (event) => { + 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(); + 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", + method: 'POST', body: JSON.stringify({ channel_id: channelId, name }), }); addForm.reset(); @@ -192,53 +192,59 @@ function renderAccountPanel(accountId) { }); // Continuous form - const contForm = node.querySelector(".continuous-form"); - contForm.addEventListener("submit", async (event) => { + 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 + 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; + if (!confirmAction('Continuous scraping enabled with no selected channels. Save anyway?')) return; } await api(`/api/accounts/${accountId}/continuous`, { - method: "POST", + 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; }); + 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"); + const contentTabs = node.querySelectorAll('.content-tab'); contentTabs.forEach((tab) => { - tab.addEventListener("click", () => { + 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); + 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)); + node.querySelector('.refresh-jobs-btn').addEventListener('click', () => refreshAccount(accountId)); container.appendChild(node); } function closeAllJobStreams() { - for (const [jobId, stream] of jobStreams) { - stream.close(); + 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(); } @@ -249,13 +255,13 @@ function switchAccount(accountId) { saveActiveAccount(accountId); // Update tabs - document.querySelectorAll(".account-tab").forEach((tab) => { - tab.classList.toggle("active", tab.dataset.accountId === accountId); + 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); + document.querySelectorAll('.account-panel').forEach((panel) => { + panel.classList.toggle('hidden', panel.dataset.accountId !== accountId); }); // Ensure panel exists @@ -275,17 +281,17 @@ function switchAccount(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"); + 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)"; + 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)"; + nameEl.textContent = 'None'; + statusEl.textContent = 'Add an account in Settings'; + statusEl.style.color = 'var(--dim)'; } } @@ -294,56 +300,57 @@ function updateSidebarAccount() { 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 = ""; + 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.
'; + 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('.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 () => { + node.querySelector('.scrape-btn').addEventListener('click', async () => { await api(`/api/accounts/${accountId}/jobs/scrape`, { - method: "POST", + method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); - node.querySelector(".export-btn").addEventListener("click", async () => { + node.querySelector('.export-btn').addEventListener('click', async () => { await api(`/api/accounts/${accountId}/jobs/export-channel`, { - method: "POST", + method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); - node.querySelector(".export-view-btn").addEventListener("click", () => { + 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 () => { + node.querySelector('.media-btn').addEventListener('click', async () => { await api(`/api/accounts/${accountId}/jobs/rescrape-media`, { - method: "POST", + method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); }); - node.querySelector(".remove-btn").addEventListener("click", async () => { + 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", + method: 'POST', body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshAccount(accountId); @@ -353,15 +360,15 @@ function renderChannels(accountId, channels) { }); // Update channel count stat - panel.querySelector(".channel-count").textContent = String(channels.length); + 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 = ""; + 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.
'; @@ -371,12 +378,12 @@ function renderJobs(accountId, jobs) { 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."; + 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); }); @@ -386,32 +393,93 @@ 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."; + 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) || !window.EventSource) { + if (!['queued', 'running'].includes(status) || jobStreams.has(jobId)) { return; } - const stream = new EventSource(`/api/jobs/${encodeURIComponent(jobId)}/events`); - jobStreams.set(jobId, stream); - stream.onmessage = (event) => { - const job = JSON.parse(event.data); - updateRenderedJob(accountId, job); - if (["completed", "failed"].includes(job.status)) { - stream.close(); - jobStreams.delete(jobId); - refreshAccount(accountId); - } - }; - stream.onerror = () => { - stream.close(); - jobStreams.delete(jobId); - }; + + // 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) { @@ -420,31 +488,31 @@ function renderContinuous(accountId, data) { 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); + 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 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 = ""; + chList.innerHTML = ''; channels.forEach((ch) => { - const label = document.createElement("label"); - label.className = "checkbox-row"; - label.classList.toggle("disabled", runAll); + 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"; + 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"); + const text = document.createElement('span'); text.textContent = `${ch.name} (${ch.channel_id})`; label.append(checkbox, text); @@ -452,73 +520,77 @@ function renderContinuous(accountId, data) { }); // Meta - panel.querySelector(".continuous-status").textContent = status.running ? "running" : "stopped"; - panel.querySelector(".continuous-last-iteration").textContent = status.last_iteration_at + 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 || "-"; + : '-'; + panel.querySelector('.continuous-last-error').textContent = status.last_error || '-'; // Logs - const logsRoot = panel.querySelector(".continuous-logs"); + const logsRoot = panel.querySelector('.continuous-logs'); const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); - logsRoot.innerHTML = ""; + logsRoot.innerHTML = ''; if (!entries.length) { - logsRoot.innerHTML = '
-INFONo logs yet.
'; + 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; + 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 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 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 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; + const messageNode = document.createElement('span'); + messageNode.className = 'log-message'; + messageNode.textContent = entry.message; - row.append(timeNode, ageNode, levelNode, messageNode); - logsRoot.appendChild(row); - }); + 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"; + 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) { + if (typeof entry === 'object' && entry !== null) { return { timestamp: entry.timestamp || null, - level: classifyLog(entry.message || "", entry.level), - message: entry.message || "", + level: classifyLog(entry.message || '', entry.level), + message: entry.message || '', }; } - const line = String(entry || ""); + const line = String(entry || ''); const match = line.match(/^\[(\d{2}:\d{2}:\d{2})\]\s?(.*)$/); return { timestamp: null, - legacyTime: match?.[1] || "", + legacyTime: match?.[1] || '', level: classifyLog(match?.[2] || line), message: match?.[2] || line, }; @@ -529,17 +601,17 @@ function renderSummary(accountId, data) { 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"; + 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 = [ + 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(" | "); + health.active_job ? `active: ${health.active_job.status}` : 'idle', + ].join(' | '); } /* ── Account data loading ───────────────────────────── */ @@ -565,10 +637,10 @@ async function refreshAccount(accountId) { // Update tab status indicator const tab = document.querySelector(`.account-tab[data-account-id="${accountId}"]`); if (tab) { - const statusEl = tab.querySelector(".account-tab-status"); + const statusEl = tab.querySelector('.account-tab-status'); const authOk = isAccountAuthorized(authData); - statusEl.textContent = authOk ? "●" : "○"; - statusEl.className = "account-tab-status " + (authOk ? "ok" : ""); + statusEl.textContent = authOk ? '●' : '○'; + statusEl.className = 'account-tab-status ' + (authOk ? 'ok' : ''); } } catch (err) { console.error(`Failed to refresh account ${accountId}:`, err); @@ -578,14 +650,14 @@ async function refreshAccount(accountId) { async function loadAccounts() { let accounts = []; try { - const resp = await api("/api/accounts"); + const resp = await api('/api/accounts'); accounts = resp.accounts || []; } catch { // Legacy fallback — check /api/auth try { - const legacy = await api("/api/auth"); + const legacy = await api('/api/auth'); if (legacy.saved_credentials?.api_id) { - accounts = [{ id: "default", label: "Default", auth: legacy.auth_status || legacy }]; + accounts = [{ id: 'default', label: 'Default', auth: legacy.auth_status || legacy }]; } } catch { // No accounts at all @@ -595,11 +667,11 @@ async function loadAccounts() { state.accounts = accounts; if (accounts.length === 0) { - document.getElementById("no-accounts-msg").classList.remove("hidden"); + document.getElementById('no-accounts-msg').classList.remove('hidden'); return; } - document.getElementById("no-accounts-msg").classList.add("hidden"); + document.getElementById('no-accounts-msg').classList.add('hidden'); // Render tabs renderAccountTabs(accounts); @@ -620,48 +692,48 @@ async function loadAccounts() { /* ── Settings: Accounts list ────────────────────────── */ function renderSettingsAccounts() { - const container = document.getElementById("accounts-list"); - const template = document.getElementById("account-list-item-template"); - container.innerHTML = ""; + 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; + 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 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"; + 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", () => { + node.querySelector('.account-select-btn').addEventListener('click', () => { switchAccount(acc.id); - document.getElementById("settings-dialog").close(); + document.getElementById('settings-dialog').close(); }); - node.querySelector(".account-export-btn").addEventListener("click", async () => { + 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"); + showToast(`Exported ${acc.label || acc.id}.`, 'success'); } catch (err) { - showToast(`Failed to export account: ${err.message}`, "error"); + showToast(`Failed to export account: ${err.message}`, 'error'); } }); - node.querySelector(".account-remove-btn").addEventListener("click", async () => { + 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" }); + 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"); + showToast(`Failed to remove account: ${err.message}`, 'error'); } }); @@ -672,17 +744,17 @@ function renderSettingsAccounts() { /* ── Auth (operates on active account) ───────────────── */ function updateAuthSection(accountId) { - const label = document.getElementById("auth-account-label"); + const label = document.getElementById('auth-account-label'); const acc = state.accounts.find((a) => a.id === accountId); - label.textContent = acc ? (acc.label || acc.id) : "-"; + 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(); + 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", + method: 'POST', body: JSON.stringify({ api_id: apiId, api_hash: apiHash }), }); await loadAccounts(); @@ -690,45 +762,45 @@ async function saveCredentials(accountId) { async function startQrLogin(accountId) { const data = await api(`/api/accounts/${accountId}/auth/qr/start`, { - method: "POST", + method: 'POST', body: JSON.stringify({}), }); if (data.qr_image) { - document.getElementById("qr-image").src = data.qr_image; - document.getElementById("qr-wrap").classList.remove("hidden"); + 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(); + const phone = document.getElementById('phone-input').value.trim(); if (!phone) return; await api(`/api/accounts/${accountId}/auth/phone/request`, { - method: "POST", + method: 'POST', body: JSON.stringify({ phone }), }); await loadAccounts(); } async function submitPhoneCode(accountId) { - const code = document.getElementById("code-input").value.trim(); + const code = document.getElementById('code-input').value.trim(); if (!code) return; await api(`/api/accounts/${accountId}/auth/phone/submit`, { - method: "POST", + method: 'POST', body: JSON.stringify({ code }), }); - document.getElementById("code-input").value = ""; + document.getElementById('code-input').value = ''; await loadAccounts(); } async function submitPassword(accountId) { - const password = document.getElementById("password-input").value.trim(); + const password = document.getElementById('password-input').value.trim(); if (!password) return; await api(`/api/accounts/${accountId}/auth/password`, { - method: "POST", + method: 'POST', body: JSON.stringify({ password }), }); - document.getElementById("password-input").value = ""; + document.getElementById('password-input').value = ''; await loadAccounts(); } @@ -736,9 +808,9 @@ async function submitPassword(accountId) { async function scrapeAll() { if (!state.activeAccount) return; - if (!confirmAction("Queue scraping for all tracked channels?")) return; + if (!confirmAction('Queue scraping for all tracked channels?')) return; await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, { - method: "POST", + method: 'POST', body: JSON.stringify({}), }); await refreshAccount(state.activeAccount); @@ -746,9 +818,9 @@ async function scrapeAll() { async function exportAll() { if (!state.activeAccount) return; - if (!confirmAction("Queue export for all tracked channels?")) return; + if (!confirmAction('Queue export for all tracked channels?')) return; await api(`/api/accounts/${state.activeAccount}/jobs/export`, { - method: "POST", + method: 'POST', body: JSON.stringify({}), }); await refreshAccount(state.activeAccount); @@ -757,7 +829,7 @@ async function exportAll() { async function refreshDialogs() { if (!state.activeAccount) return; await api(`/api/accounts/${state.activeAccount}/jobs/refresh-dialogs`, { - method: "POST", + method: 'POST', body: JSON.stringify({}), }); await refreshAccount(state.activeAccount); @@ -766,7 +838,7 @@ async function refreshDialogs() { async function toggleMedia(value) { if (!state.activeAccount) return; await api(`/api/accounts/${state.activeAccount}/settings/media`, { - method: "POST", + method: 'POST', body: JSON.stringify({ value }), }); await refreshAccount(state.activeAccount); @@ -776,19 +848,34 @@ async function toggleMedia(value) { 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('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('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"); } + 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", () => { + 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); @@ -796,22 +883,22 @@ async function main() { renderSettingsAccounts(); settingsDialog.showModal(); }); - document.getElementById("close-settings-btn").addEventListener("click", () => { + document.getElementById('close-settings-btn').addEventListener('click', () => { settingsDialog.close(); }); // ── Add account form ── - document.getElementById("add-account-form").addEventListener("submit", async (event) => { + 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(); + 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", + await api('/api/accounts', { + method: 'POST', body: JSON.stringify({ account_id: accountId, label: label || accountId, @@ -825,91 +912,96 @@ async function main() { switchAccount(accountId); } } catch (err) { - showToast("Failed to add account: " + err.message, "error"); + showToast('Failed to add account: ' + err.message, 'error'); } }); - document.getElementById("import-account-file").addEventListener("change", async (event) => { + 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", + 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 = ""; + event.currentTarget.value = ''; await loadAccounts(); switchAccount(accountId); - showToast(`Imported ${accountId}.`, "success"); + showToast(`Imported ${accountId}.`, 'success'); } catch (err) { - showToast(`Failed to import account: ${err.message}`, "error"); + showToast(`Failed to import account: ${err.message}`, 'error'); } }); // ── Credentials form ── - document.getElementById("credentials-form").addEventListener("submit", async (event) => { + 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"); + showToast('Failed to save credentials: ' + err.message, 'error'); } }); // ── QR login ── - document.getElementById("start-qr-btn").addEventListener("click", async (event) => { + 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"); + showToast('QR login error: ' + err.message, 'error'); } finally { setBusy(event.currentTarget, false); } }); // ── Phone ── - document.getElementById("phone-form").addEventListener("submit", async (event) => { + 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"); + showToast('Phone code request error: ' + err.message, 'error'); } }); // ── Code ── - document.getElementById("code-form").addEventListener("submit", async (event) => { + 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"); + showToast('Code submit error: ' + err.message, 'error'); } }); // ── Password ── - document.getElementById("password-form").addEventListener("submit", async (event) => { + 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"); + showToast('Password error: ' + err.message, 'error'); } }); // ── Media toggle ── - document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => { + 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"); } + try { + await toggleMedia(checked); + showToast('Media setting updated.', 'success'); + } catch (err) { + showToast('Media toggle error: ' + err.message, 'error'); + } }); // ── Load accounts ── @@ -925,5 +1017,5 @@ async function main() { main().catch((error) => { console.error(error); - showToast(error.message, "error"); + showToast(error.message, 'error'); }); diff --git a/webui/index.html b/webui/index.html index dcc7bb5..e365a4d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,4 +1,4 @@ - + @@ -51,7 +51,9 @@

No Accounts Configured

-

Click "Add Account" in Settings to get started, or use the legacy single-account mode.

+

+ Click "Add Account" in Settings to get started, or use the legacy single-account mode. +

diff --git a/webui/style.css b/webui/style.css index 1450ffc..fa1fc05 100644 --- a/webui/style.css +++ b/webui/style.css @@ -17,8 +17,8 @@ --info: #8ec7ff; --radius: 18px; --shadow: none; - --font-ui: "Segoe UI", "Helvetica Neue", Arial, sans-serif; - --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; + --font-ui: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; } * { @@ -129,7 +129,10 @@ input, min-height: 42px; padding: 9px 13px; cursor: pointer; - transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + transform 0.18s ease; } .nav-link.active, @@ -301,7 +304,7 @@ input:focus { } .switch-slider::before { - content: ""; + content: ''; position: absolute; top: 4px; left: 4px; @@ -309,7 +312,9 @@ input:focus { height: 16px; background: var(--dim); border-radius: 50%; - transition: transform 0.16s ease, background-color 0.16s ease; + transition: + transform 0.16s ease, + background-color 0.16s ease; } .switch input:checked + .switch-slider { @@ -628,9 +633,12 @@ tbody tr:hover { overflow: hidden; background: radial-gradient(circle at 20px 20px, rgba(255, 255, 255, 0.025) 1px, transparent 1.5px), - radial-gradient(circle at 58px 54px, rgba(255, 255, 255, 0.018) 1px, transparent 1.5px), - #000; - background-size: auto, 84px 84px, 84px 84px, auto; + radial-gradient(circle at 58px 54px, rgba(255, 255, 255, 0.018) 1px, transparent 1.5px), #000; + background-size: + auto, + 84px 84px, + 84px 84px, + auto; } .viewer-header { @@ -659,7 +667,7 @@ tbody tr:hover { flex-wrap: wrap; } -.viewer-tools input[type="search"] { +.viewer-tools input[type='search'] { min-width: min(260px, 40vw); } @@ -705,7 +713,7 @@ tbody tr:hover { .chat-date-sep::before, .chat-date-sep::after { - content: ""; + content: ''; flex: 1; height: 1px; background: #242424; @@ -750,7 +758,7 @@ tbody tr:hover { } .chat-bubble::before { - content: ""; + content: ''; position: absolute; top: 8px; border: 6px solid transparent; @@ -779,8 +787,13 @@ tbody tr:hover { } @keyframes chat-highlight { - 0%, 100% { box-shadow: 0 0 0 0 rgba(139, 180, 248, 0); } - 50% { box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4); } + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(139, 180, 248, 0); + } + 50% { + box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4); + } } /* ---------- Sender ---------- */ @@ -912,7 +925,9 @@ tbody tr:hover { background: #111; color: var(--text-color); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.36); - transition: opacity 0.2s ease, transform 0.2s ease; + transition: + opacity 0.2s ease, + transform 0.2s ease; } .toast-success { @@ -1151,7 +1166,7 @@ tbody tr:hover { } .viewer-sidebar.open::before { - content: ""; + content: ''; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); diff --git a/webui/viewer.html b/webui/viewer.html index 0a5147f..3af7642 100644 --- a/webui/viewer.html +++ b/webui/viewer.html @@ -1,4 +1,4 @@ - + @@ -13,7 +13,10 @@
Export Viewer

Messages

-

Account:

+

+ Account: + +

Dashboard diff --git a/webui/viewer.js b/webui/viewer.js index 0a504f6..903fade 100644 --- a/webui/viewer.js +++ b/webui/viewer.js @@ -1,4 +1,4 @@ -if ("scrollRestoration" in history) history.scrollRestoration = "manual"; +if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; const viewerState = { accountId: null, @@ -8,7 +8,7 @@ const viewerState = { oldestMessageId: null, newestMessageId: null, userId: null, - search: "", + search: '', loading: false, autoRefreshTimer: null, sentinelObserver: null, @@ -18,135 +18,139 @@ async function api(path) { const response = await fetch(path); const data = await response.json(); if (!response.ok) { - throw new Error(data.error || "Request failed"); + throw new Error(data.error || 'Request failed'); } return data; } -function showToast(message, type = "info") { - let root = document.getElementById("toast-root"); +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"; + root = document.createElement('div'); + root.id = 'toast-root'; + root.className = 'toast-root'; document.body.appendChild(root); } - const toast = document.createElement("div"); + const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; root.appendChild(toast); window.setTimeout(() => { - toast.classList.add("toast-hide"); + toast.classList.add('toast-hide'); window.setTimeout(() => toast.remove(), 250); }, 3600); } function formatDateHeader(dateStr) { - if (!dateStr) return ""; - const d = new Date(dateStr.replace(" ", "T")); - if (isNaN(d.getTime())) return ""; + if (!dateStr) return ''; + const d = new Date(dateStr.replace(' ', 'T')); + if (isNaN(d.getTime())) return ''; const now = new Date(); const diff = now - d; const oneDay = 86400000; - if (diff < oneDay && d.getDate() === now.getDate()) return "Today"; - if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return "Yesterday"; - return d.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + if (diff < oneDay && d.getDate() === now.getDate()) return 'Today'; + if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return 'Yesterday'; + return d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, }); } function formatTime(dateStr) { - if (!dateStr) return ""; - const d = new Date(dateStr.replace(" ", "T")); - if (isNaN(d.getTime())) return ""; - return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + if (!dateStr) return ''; + const d = new Date(dateStr.replace(' ', 'T')); + if (isNaN(d.getTime())) return ''; + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } function chatListTime(dateStr) { - if (!dateStr) return ""; - const d = new Date(dateStr.replace(" ", "T")); - if (isNaN(d.getTime())) return ""; + if (!dateStr) return ''; + const d = new Date(dateStr.replace(' ', 'T')); + if (isNaN(d.getTime())) return ''; const now = new Date(); if (d.toDateString() === now.toDateString()) { - return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function initials(value) { - const source = String(value || "?").replace(/^@/, "").trim(); + const source = String(value || '?') + .replace(/^@/, '') + .trim(); const words = source.split(/\s+/).filter(Boolean); if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase(); return source.slice(0, 2).toUpperCase(); } function dateKey(dateStr) { - if (!dateStr) return ""; + if (!dateStr) return ''; return dateStr.slice(0, 10); } function textNodeWithBreaks(text) { const fragment = document.createDocumentFragment(); if (!text) return fragment; - const parts = text.split("\n"); + const parts = text.split('\n'); parts.forEach((part, index) => { - if (index > 0) fragment.appendChild(document.createElement("br")); + if (index > 0) fragment.appendChild(document.createElement('br')); fragment.appendChild(document.createTextNode(part)); }); return fragment; } function scrollToBottom() { - const list = document.getElementById("messages-list"); + const list = document.getElementById('messages-list'); if (!list) return; - const go = () => { list.scrollTop = list.scrollHeight; }; + const go = () => { + list.scrollTop = list.scrollHeight; + }; go(); requestAnimationFrame(go); setTimeout(go, 150); } function makeScrollSentinel() { - const sentinel = document.createElement("div"); - sentinel.id = "scroll-sentinel"; - sentinel.className = "scroll-sentinel"; + const sentinel = document.createElement('div'); + sentinel.id = 'scroll-sentinel'; + sentinel.className = 'scroll-sentinel'; return sentinel; } -function resetMessageView(title = "Select a channel", subtitle = "Reading messages from the local SQLite database.") { - const root = document.getElementById("messages-list"); +function resetMessageView(title = 'Select a channel', subtitle = 'Reading messages from the local SQLite database.') { + const root = document.getElementById('messages-list'); if (root) { - root.innerHTML = ""; + root.innerHTML = ''; root.appendChild(makeScrollSentinel()); } - document.getElementById("viewer-title").textContent = title; - document.getElementById("viewer-subtitle").textContent = subtitle; + document.getElementById('viewer-title').textContent = title; + document.getElementById('viewer-subtitle').textContent = subtitle; viewerState.oldestMessageId = null; viewerState.newestMessageId = null; } function renderChannelList() { - const root = document.getElementById("viewer-channel-list"); + const root = document.getElementById('viewer-channel-list'); if (!root) return; - root.innerHTML = ""; - const template = document.getElementById("viewer-channel-template"); + root.innerHTML = ''; + const template = document.getElementById('viewer-channel-template'); if (!template) return; viewerState.channels.forEach((channel) => { const node = template.content.firstElementChild.cloneNode(true); const preview = channel.last_message_preview || - (channel.has_database ? "No text in the last saved message" : "No local database yet"); - node.querySelector(".viewer-channel-avatar").textContent = initials(channel.name || channel.channel_id); - node.querySelector(".viewer-channel-name").textContent = channel.name; - node.querySelector(".viewer-channel-time").textContent = chatListTime(channel.last_date); - node.querySelector(".viewer-channel-preview").textContent = preview; - node.querySelector(".viewer-channel-count").textContent = String(channel.message_count || 0); + (channel.has_database ? 'No text in the last saved message' : 'No local database yet'); + node.querySelector('.viewer-channel-avatar').textContent = initials(channel.name || channel.channel_id); + node.querySelector('.viewer-channel-name').textContent = channel.name; + node.querySelector('.viewer-channel-time').textContent = chatListTime(channel.last_date); + node.querySelector('.viewer-channel-preview').textContent = preview; + node.querySelector('.viewer-channel-count').textContent = String(channel.message_count || 0); if (channel.channel_id === viewerState.channelId) { - node.classList.add("active"); + node.classList.add('active'); } - node.addEventListener("click", () => { - document.querySelector(".viewer-sidebar")?.classList.remove("open"); + node.addEventListener('click', () => { + document.querySelector('.viewer-sidebar')?.classList.remove('open'); viewerState.oldestMessageId = null; viewerState.newestMessageId = null; loadChannel(channel.channel_id); @@ -155,10 +159,8 @@ function renderChannelList() { }); } -function messageEndpoint(channelId, before = "") { - const search = viewerState.search - ? `&search=${encodeURIComponent(viewerState.search)}` - : ""; +function messageEndpoint(channelId, before = '') { + const search = viewerState.search ? `&search=${encodeURIComponent(viewerState.search)}` : ''; if (viewerState.accountId) { return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`; } @@ -169,22 +171,22 @@ function channelsEndpoint() { if (viewerState.accountId) { return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`; } - return "/api/channels"; + return '/api/channels'; } function renderAccountSelector() { - const sel = document.getElementById("viewer-account-select"); + const sel = document.getElementById('viewer-account-select'); if (!sel) return; - sel.innerHTML = ""; + sel.innerHTML = ''; const legacy = viewerState.accounts.length === 0; if (legacy) { - const opt = document.createElement("option"); - opt.value = ""; - opt.textContent = "Legacy"; + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'Legacy'; sel.appendChild(opt); } else { viewerState.accounts.forEach((acc) => { - const opt = document.createElement("option"); + const opt = document.createElement('option'); opt.value = acc.id; opt.textContent = acc.label || acc.id; if (acc.id === viewerState.accountId) opt.selected = true; @@ -195,12 +197,12 @@ function renderAccountSelector() { async function switchViewerAccount(accountId) { viewerState.accountId = accountId || null; - viewerState.search = ""; - const searchInput = document.getElementById("viewer-search"); - if (searchInput) searchInput.value = ""; + viewerState.search = ''; + const searchInput = document.getElementById('viewer-search'); + if (searchInput) searchInput.value = ''; viewerState.accounts.forEach((acc) => { if (acc.id === accountId) { - const opts = document.getElementById("viewer-account-select")?.options; + const opts = document.getElementById('viewer-account-select')?.options; if (opts) { for (let i = 0; i < opts.length; i++) { opts[i].selected = opts[i].value === accountId; @@ -211,10 +213,10 @@ async function switchViewerAccount(accountId) { try { const authData = viewerState.accountId ? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`) - : await api("/api/auth"); + : await api('/api/auth'); viewerState.userId = authData.user_id || null; } catch (e) { - console.warn("Could not fetch user ID:", e); + console.warn('Could not fetch user ID:', e); } const channels = await api(channelsEndpoint()); viewerState.channels = channels; @@ -226,99 +228,93 @@ async function switchViewerAccount(accountId) { await loadChannel(viewerState.channelId); } else { resetMessageView( - "No channels", - viewerState.accountId - ? "This account has no tracked channels yet." - : "No local legacy channels found." + 'No channels', + viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.', ); } } async function loadViewerAccount(params) { - const requested = params.get("account"); + const requested = params.get('account'); try { - const payload = await api("/api/accounts"); + const payload = await api('/api/accounts'); viewerState.accounts = payload.accounts || []; } catch { viewerState.accounts = []; } const requestedExists = requested && viewerState.accounts.some((acc) => acc.id === requested); - viewerState.accountId = requestedExists - ? requested - : viewerState.accounts[0]?.id || null; + viewerState.accountId = requestedExists ? requested : viewerState.accounts[0]?.id || null; renderAccountSelector(); - const sel = document.getElementById("viewer-account-select"); + const sel = document.getElementById('viewer-account-select'); if (sel) { - sel.addEventListener("change", () => switchViewerAccount(sel.value)); + sel.addEventListener('change', () => switchViewerAccount(sel.value)); } try { const authData = viewerState.accountId ? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`) - : await api("/api/auth"); + : await api('/api/auth'); viewerState.userId = authData.user_id || null; } catch (e) { - console.warn("Could not fetch user ID:", e); + console.warn('Could not fetch user ID:', e); } } function buildMessageNode(message, root) { - const template = document.getElementById("message-template"); + const template = document.getElementById('message-template'); const wrap = template.content.firstElementChild.cloneNode(true); - const bubble = wrap.querySelector(".chat-bubble"); + const bubble = wrap.querySelector('.chat-bubble'); bubble.dataset.messageId = String(message.message_id); const isOwn = viewerState.userId && message.sender_id === viewerState.userId; if (isOwn) { - wrap.classList.add("chat-bubble-wrap--own"); + wrap.classList.add('chat-bubble-wrap--own'); } - const sender = bubble.querySelector(".chat-sender"); - sender.textContent = message.sender_name || ""; + const sender = bubble.querySelector('.chat-sender'); + sender.textContent = message.sender_name || ''; - const replyEl = bubble.querySelector(".chat-reply"); + const replyEl = bubble.querySelector('.chat-reply'); const replyMsg = message.reply_to_message; if (message.reply_to) { - replyEl.classList.remove("hidden"); - bubble.querySelector(".chat-reply-author").textContent = - replyMsg?.sender_name || `#${message.reply_to}`; - bubble.querySelector(".chat-reply-text").textContent = - replyMsg?.text || "(message not available)"; - replyEl.addEventListener("click", () => { + replyEl.classList.remove('hidden'); + bubble.querySelector('.chat-reply-author').textContent = replyMsg?.sender_name || `#${message.reply_to}`; + bubble.querySelector('.chat-reply-text').textContent = replyMsg?.text || '(message not available)'; + replyEl.addEventListener('click', () => { const target = root.querySelector(`[data-message-id="${message.reply_to}"]`); if (target) { - target.scrollIntoView({ behavior: "smooth", block: "center" }); - target.classList.add("chat-bubble--highlight"); - setTimeout(() => target.classList.remove("chat-bubble--highlight"), 2000); + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.classList.add('chat-bubble--highlight'); + setTimeout(() => target.classList.remove('chat-bubble--highlight'), 2000); } }); } else { - replyEl.classList.add("hidden"); + replyEl.classList.add('hidden'); } - bubble.querySelector(".chat-text").appendChild(textNodeWithBreaks(message.text || "")); + bubble.querySelector('.chat-text').appendChild(textNodeWithBreaks(message.text || '')); - const media = bubble.querySelector(".chat-media"); - if (message.media_url && message.media_kind === "image") { - const img = document.createElement("img"); + const media = bubble.querySelector('.chat-media'); + if (message.media_url && message.media_kind === 'image') { + const img = document.createElement('img'); img.src = message.media_url; - img.loading = "lazy"; + img.loading = 'lazy'; media.appendChild(img); - } else if (message.media_url && message.media_kind === "video") { - const video = document.createElement("video"); + } else if (message.media_url && message.media_kind === 'video') { + const video = document.createElement('video'); video.src = message.media_url; video.controls = true; - video.preload = "metadata"; + video.preload = 'metadata'; media.appendChild(video); } else if (message.media_url) { - const link = document.createElement("a"); + const link = document.createElement('a'); link.href = message.media_url; - link.target = "_blank"; - link.rel = "noreferrer"; - link.textContent = "Open file"; + link.target = '_blank'; + link.rel = 'noreferrer'; + link.textContent = 'Open file'; media.appendChild(link); } @@ -327,41 +323,40 @@ function buildMessageNode(message, root) { if (message.views) footerParts.push(`views: ${message.views}`); if (message.forwards) footerParts.push(`forwards: ${message.forwards}`); if (message.reactions) footerParts.push(`reactions: ${message.reactions}`); - bubble.querySelector(".chat-footer").textContent = footerParts.join(" | "); + bubble.querySelector('.chat-footer').textContent = footerParts.join(' | '); return { wrap, dateKey: dateKey(message.date) }; } function makeDateSep(text) { - const el = document.createElement("div"); - el.className = "chat-date-sep"; + const el = document.createElement('div'); + el.className = 'chat-date-sep'; el.textContent = text; return el; } function renderMessages(payload, append = false) { - const root = document.getElementById("messages-list"); + const root = document.getElementById('messages-list'); if (!root) return; - let sentinel = document.getElementById("scroll-sentinel"); + let sentinel = document.getElementById('scroll-sentinel'); if (!sentinel) sentinel = makeScrollSentinel(); const channel = payload.channel; - document.getElementById("viewer-title").textContent = - channel?.name || payload.channel_id; - document.getElementById("viewer-subtitle").textContent = channel - ? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ""}, ${channel.message_count} total, latest: ${channel.last_date || "-"}` - : "No local database found for this channel yet."; + document.getElementById('viewer-title').textContent = channel?.name || payload.channel_id; + document.getElementById('viewer-subtitle').textContent = channel + ? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ''}, ${channel.message_count} total, latest: ${channel.last_date || '-'}` + : 'No local database found for this channel yet.'; if (!append) { - root.innerHTML = ""; + root.innerHTML = ''; root.appendChild(sentinel); } let lastKey = null; if (append) { - const firstExisting = root.querySelector("[data-message-id]"); + const firstExisting = root.querySelector('[data-message-id]'); if (firstExisting) { - const candidate = firstExisting.closest(".chat-bubble-wrap"); + const candidate = firstExisting.closest('.chat-bubble-wrap'); if (candidate && candidate._dateKey) lastKey = candidate._dateKey; } } @@ -395,16 +390,15 @@ function renderMessages(payload, append = false) { if (payload.messages.length) { viewerState.oldestMessageId = payload.messages[0].message_id; - viewerState.newestMessageId = - payload.messages[payload.messages.length - 1].message_id; + viewerState.newestMessageId = payload.messages[payload.messages.length - 1].message_id; } else if (!append) { viewerState.oldestMessageId = null; viewerState.newestMessageId = null; - const empty = document.createElement("div"); - empty.className = "viewer-empty-state"; + const empty = document.createElement('div'); + empty.className = 'viewer-empty-state'; empty.textContent = viewerState.search ? `No messages found for "${viewerState.search}".` - : "No saved messages in this channel yet."; + : 'No saved messages in this channel yet.'; root.appendChild(empty); } @@ -419,14 +413,11 @@ async function loadChannel(channelId, append = false) { viewerState.channelId = channelId; renderChannelList(); - const before = - append && viewerState.oldestMessageId - ? `&before=${viewerState.oldestMessageId}` - : ""; + const before = append && viewerState.oldestMessageId ? `&before=${viewerState.oldestMessageId}` : ''; try { const payload = await api(messageEndpoint(channelId, before)); if (append) { - const list = document.getElementById("messages-list"); + const list = document.getElementById('messages-list'); const prevScrollHeight = list.scrollHeight; renderMessages(payload, true); requestAnimationFrame(() => { @@ -436,7 +427,7 @@ async function loadChannel(channelId, append = false) { renderMessages(payload, false); } } catch (err) { - console.error("Failed to load messages:", err); + console.error('Failed to load messages:', err); } finally { viewerState.loading = false; } @@ -450,13 +441,13 @@ async function refreshCurrentChannel() { } function setupViewerTools() { - const searchInput = document.getElementById("viewer-search"); - const refreshBtn = document.getElementById("viewer-refresh-btn"); - const autoRefresh = document.getElementById("viewer-auto-refresh"); + const searchInput = document.getElementById('viewer-search'); + const refreshBtn = document.getElementById('viewer-refresh-btn'); + const autoRefresh = document.getElementById('viewer-auto-refresh'); let searchTimer = null; if (searchInput) { - searchInput.addEventListener("input", () => { + searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { viewerState.search = searchInput.value.trim(); @@ -466,7 +457,7 @@ function setupViewerTools() { } if (refreshBtn) { - refreshBtn.addEventListener("click", () => refreshCurrentChannel()); + refreshBtn.addEventListener('click', () => refreshCurrentChannel()); } viewerState.autoRefreshTimer = window.setInterval(() => { @@ -477,7 +468,7 @@ function setupViewerTools() { } function setupInfiniteScroll() { - const sentinel = document.getElementById("scroll-sentinel"); + const sentinel = document.getElementById('scroll-sentinel'); if (!sentinel) return; if (viewerState.sentinelObserver) { viewerState.sentinelObserver.disconnect(); @@ -485,17 +476,12 @@ function setupInfiniteScroll() { viewerState.sentinelObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { - if ( - entry.isIntersecting && - viewerState.channelId && - viewerState.oldestMessageId && - !viewerState.loading - ) { + if (entry.isIntersecting && viewerState.channelId && viewerState.oldestMessageId && !viewerState.loading) { loadChannel(viewerState.channelId, true); } } }, - { root: document.getElementById("messages-list"), threshold: 0.1 } + { root: document.getElementById('messages-list'), threshold: 0.1 }, ); viewerState.sentinelObserver.observe(sentinel); } @@ -504,38 +490,33 @@ async function main() { const params = new URLSearchParams(window.location.search); await loadViewerAccount(params); viewerState.channels = await api(channelsEndpoint()); - const requestedChannel = params.get("channel"); + const requestedChannel = params.get('channel'); const requestedChannelExists = - requestedChannel && - viewerState.channels.some((channel) => channel.channel_id === requestedChannel); - viewerState.channelId = requestedChannelExists - ? requestedChannel - : viewerState.channels[0]?.channel_id || null; + requestedChannel && viewerState.channels.some((channel) => channel.channel_id === requestedChannel); + viewerState.channelId = requestedChannelExists ? requestedChannel : viewerState.channels[0]?.channel_id || null; renderChannelList(); if (viewerState.channelId) { await loadChannel(viewerState.channelId); } else { resetMessageView( - "No channels", - viewerState.accountId - ? "This account has no tracked channels yet." - : "No local legacy channels found." + 'No channels', + viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.', ); } setupInfiniteScroll(); setupViewerTools(); - const toggle = document.getElementById("sidebar-toggle"); - const sidebar = document.querySelector(".viewer-sidebar"); + const toggle = document.getElementById('sidebar-toggle'); + const sidebar = document.querySelector('.viewer-sidebar'); if (toggle && sidebar) { - toggle.addEventListener("click", () => { - sidebar.classList.toggle("open"); + toggle.addEventListener('click', () => { + sidebar.classList.toggle('open'); }); - sidebar.addEventListener("click", (e) => { + sidebar.addEventListener('click', (e) => { if (e.target === sidebar) { - sidebar.classList.remove("open"); + sidebar.classList.remove('open'); } }); } @@ -543,5 +524,5 @@ async function main() { main().catch((error) => { console.error(error); - showToast(error.message, "error"); + showToast(error.message, 'error'); });