const state = { dashboard: null, auth: null, continuous: null, }; 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; } 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 renderAuth(auth) { document.getElementById("auth-status").textContent = auth.status; document.getElementById("auth-details").textContent = auth.details || ""; } function toggleHidden(id, hidden) { document.getElementById(id).classList.toggle("hidden", hidden); } function renderAuthPanel(authData) { state.auth = authData; renderAuth(authData.auth_status); const apiId = authData.saved_credentials?.api_id ?? ""; document.getElementById("api-id-input").value = apiId || ""; document.getElementById("api-hash-input").placeholder = authData.saved_credentials?.api_hash_present ? "API Hash saved" : "API Hash"; const showQr = Boolean(authData.qr_image); toggleHidden("qr-wrap", !showQr); if (showQr) { document.getElementById("qr-image").src = authData.qr_image; } toggleHidden("code-form", authData.phase !== "code_required"); toggleHidden("password-form", authData.phase !== "password_required"); } function renderSummary(data) { document.getElementById("channel-count").textContent = String(data.state.channel_count); document.getElementById("forwarding-count").textContent = String(data.state.forwarding_rules.length); const toggle = document.getElementById("scrape-media-toggle"); toggle.checked = Boolean(data.state.scrape_media); document.getElementById("scrape-media-label").textContent = toggle.checked ? "ON" : "OFF"; } function renderChannels(channels) { const tbody = document.getElementById("channels-table"); tbody.innerHTML = ""; const template = document.getElementById("channel-row-template"); 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/jobs/scrape", { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshDashboard(); }); node.querySelector(".export-btn").addEventListener("click", async () => { await api("/api/jobs/export-channel", { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshDashboard(); }); node.querySelector(".export-view-btn").addEventListener("click", () => { window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}`; }); node.querySelector(".media-btn").addEventListener("click", async () => { await api("/api/jobs/rescrape-media", { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshDashboard(); }); node.querySelector(".remove-btn").addEventListener("click", async () => { if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return; await api("/api/channels/remove", { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); await refreshDashboard(); }); tbody.appendChild(node); }); } function renderJobs(jobs) { const root = document.getElementById("jobs-list"); root.innerHTML = ""; const template = document.getElementById("job-template"); if (!jobs.length) { root.innerHTML = '
No jobs yet.
'; return; } jobs.forEach((job) => { const node = template.content.firstElementChild.cloneNode(true); 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); }); } function renderContinuousChannelPicker(channels, config) { const root = document.getElementById("continuous-channel-list"); const runAll = Boolean(config.run_all_tracked); const selected = new Set(config.channels || []); root.innerHTML = ""; if (!channels.length) { root.innerHTML = '
No tracked channels.
'; return; } channels.forEach((channel) => { 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 = channel.channel_id; checkbox.checked = runAll || selected.has(channel.channel_id); checkbox.disabled = runAll; const text = document.createElement("span"); text.textContent = `${channel.name} (${channel.channel_id})`; label.append(checkbox, text); root.appendChild(label); }); } 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 renderContinuousLogs(status) { const root = document.getElementById("continuous-logs"); const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); root.innerHTML = ""; if (!entries.length) { const empty = document.createElement("div"); empty.className = "log-line"; empty.innerHTML = '--INFONo logs yet.'; root.appendChild(empty); 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); root.appendChild(row); }); } function renderContinuous(data) { state.continuous = data; const config = data.config || {}; const status = data.status || {}; document.getElementById("continuous-enabled").checked = Boolean(config.enabled); document.getElementById("continuous-interval").value = config.interval_minutes || 1; document.getElementById("continuous-all").checked = Boolean(config.run_all_tracked); renderContinuousChannelPicker(state.dashboard?.channels || [], config); document.getElementById("continuous-status").textContent = status.running ? "running" : "stopped"; document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at ? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})` : "-"; document.getElementById("continuous-last-error").textContent = status.last_error || "-"; renderContinuousLogs(status); } function setupTabs() { const tabs = document.querySelectorAll(".content-tab"); const panels = document.querySelectorAll(".tab-panel"); tabs.forEach((tab) => { tab.addEventListener("click", () => { const targetId = tab.dataset.tabTarget; tabs.forEach((item) => item.classList.toggle("active", item === tab)); panels.forEach((panel) => panel.classList.toggle("active", panel.id === targetId)); }); }); } async function refreshDashboard() { const data = await api("/api/dashboard"); const authData = await api("/api/auth"); const continuousData = await api("/api/continuous"); state.dashboard = data; renderAuthPanel(authData); renderSummary(data); renderChannels(data.channels); renderJobs(data.jobs); renderContinuous(continuousData); } async function main() { setupTabs(); const settingsDialog = document.getElementById("settings-dialog"); document.getElementById("open-settings-btn").addEventListener("click", () => { settingsDialog.showModal(); }); document.getElementById("close-settings-btn").addEventListener("click", () => { settingsDialog.close(); }); document.getElementById("scrape-all-btn").addEventListener("click", async (event) => { if (!confirmAction("Queue scraping for all tracked channels?")) return; setBusy(event.currentTarget, true); try { await api("/api/jobs/scrape", { method: "POST", body: JSON.stringify({}) }); await refreshDashboard(); } finally { setBusy(event.currentTarget, false); } }); document.getElementById("export-all-btn").addEventListener("click", async (event) => { if (!confirmAction("Queue export for all tracked channels?")) return; setBusy(event.currentTarget, true); try { await api("/api/jobs/export", { method: "POST", body: JSON.stringify({}) }); await refreshDashboard(); } finally { setBusy(event.currentTarget, false); } }); document.getElementById("refresh-dialogs-btn").addEventListener("click", async (event) => { setBusy(event.currentTarget, true); try { await api("/api/jobs/refresh-dialogs", { method: "POST", body: JSON.stringify({}) }); await refreshDashboard(); } finally { setBusy(event.currentTarget, false); } }); document.getElementById("refresh-jobs-btn").addEventListener("click", refreshDashboard); document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => { const checked = event.currentTarget.checked; document.getElementById("scrape-media-label").textContent = checked ? "ON" : "OFF"; await api("/api/settings/media", { method: "POST", body: JSON.stringify({ value: checked }), }); await refreshDashboard(); }); document.getElementById("add-channel-form").addEventListener("submit", async (event) => { event.preventDefault(); const channelId = document.getElementById("add-channel-id").value.trim(); const name = document.getElementById("add-channel-name").value.trim(); if (!channelId) return; await api("/api/channels/add", { method: "POST", body: JSON.stringify({ channel_id: channelId, name }), }); event.currentTarget.reset(); await refreshDashboard(); }); document.getElementById("credentials-form").addEventListener("submit", async (event) => { event.preventDefault(); const apiId = document.getElementById("api-id-input").value.trim(); const apiHash = document.getElementById("api-hash-input").value.trim(); await api("/api/auth/credentials", { method: "POST", body: JSON.stringify({ api_id: apiId, api_hash: apiHash }), }); await refreshDashboard(); }); document.getElementById("start-qr-btn").addEventListener("click", async (event) => { setBusy(event.currentTarget, true); try { await api("/api/auth/qr/start", { method: "POST", body: JSON.stringify({}), }); await refreshDashboard(); } finally { setBusy(event.currentTarget, false); } }); document.getElementById("phone-form").addEventListener("submit", async (event) => { event.preventDefault(); const phone = document.getElementById("phone-input").value.trim(); await api("/api/auth/phone/request", { method: "POST", body: JSON.stringify({ phone }), }); await refreshDashboard(); }); document.getElementById("code-form").addEventListener("submit", async (event) => { event.preventDefault(); const code = document.getElementById("code-input").value.trim(); await api("/api/auth/phone/submit", { method: "POST", body: JSON.stringify({ code }), }); event.currentTarget.reset(); await refreshDashboard(); }); document.getElementById("password-form").addEventListener("submit", async (event) => { event.preventDefault(); const password = document.getElementById("password-input").value.trim(); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ password }), }); event.currentTarget.reset(); await refreshDashboard(); }); document.getElementById("continuous-form").addEventListener("submit", async (event) => { event.preventDefault(); const enabled = document.getElementById("continuous-enabled").checked; const intervalMinutes = document.getElementById("continuous-interval").value; const runAllTracked = document.getElementById("continuous-all").checked; const channels = Array.from(document.querySelectorAll(".continuous-channel-checkbox:checked")).map( (item) => item.value, ); if (!runAllTracked && channels.length === 0 && enabled) { if (!confirmAction("Continuous scraping is enabled with no selected channels. Save anyway?")) return; } await api("/api/continuous", { method: "POST", body: JSON.stringify({ enabled, interval_minutes: intervalMinutes, run_all_tracked: runAllTracked, channels, }), }); await refreshDashboard(); }); document.getElementById("continuous-all").addEventListener("change", () => { renderContinuousChannelPicker(state.dashboard?.channels || [], { ...(state.continuous?.config || {}), run_all_tracked: document.getElementById("continuous-all").checked, }); }); await refreshDashboard(); window.setInterval(refreshDashboard, 5000); } main().catch((error) => { console.error(error); alert(error.message); });