From 62a6a492e003ade71bcb3e3d71e59aa142ab2758 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sat, 27 Jun 2026 15:20:49 +0200 Subject: [PATCH] feat: add per-account web UI with account tabs - Account tab bar with tab switching (loadAccounts, switchAccount) - Per-account dashboard panel showing auth status, channels, jobs, continuous - Settings dialog with account management (add/delete accounts, credentials) - Auth forms per account (QR, phone, password, set credentials) - All action buttons with error handling (alert on failure) - AMOLED dark theme with Telegram-style message bubbles - Account tab and account list CSS styles --- webui/app.js | 770 ++++++++++++++++++++++++++++++++--------------- webui/index.html | 290 +++++++++++------- webui/style.css | 401 ++++++++++++++++++------ 3 files changed, 1017 insertions(+), 444 deletions(-) diff --git a/webui/app.js b/webui/app.js index 8af9b4d..e38c656 100644 --- a/webui/app.js +++ b/webui/app.js @@ -1,9 +1,17 @@ +/* ── State ───────────────────────────────────────────── */ + const state = { - dashboard: null, - auth: null, - continuous: null, + accounts: [], + activeAccount: null, + dashboards: {}, + continuous: {}, + channels: {}, + jobs: {}, + authStates: {}, }; +/* ── API helper ─────────────────────────────────────── */ + async function api(path, options = {}) { const response = await fetch(path, { headers: { "Content-Type": "application/json" }, @@ -16,6 +24,8 @@ async function api(path, options = {}) { return data; } +/* ── Format helpers ─────────────────────────────────── */ + function formatDate(value) { if (!value) return "-"; return value.replace("T", " ").replace("+00:00", " UTC"); @@ -58,47 +68,165 @@ 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 isAccountAuthorized(auth) { + const status = auth?.auth_status || auth || {}; + return ( + auth?.phase === "authorized" || + auth?.status === "authorized" || + status.status === "ready" || + status.status === "authorized" + ); } -function toggleHidden(id, hidden) { - document.getElementById(id).classList.toggle("hidden", hidden); +/* ── 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 renderAuthPanel(authData) { - state.auth = authData; - renderAuth(authData.auth_status); +function renderAccountPanel(accountId) { + const container = document.getElementById("account-panels"); + const template = document.getElementById("account-panel-template"); - 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"; + // Don't duplicate + if (document.getElementById(`panel-${accountId}`)) return; - const showQr = Boolean(authData.qr_image); - toggleHidden("qr-wrap", !showQr); - if (showQr) { - document.getElementById("qr-image").src = authData.qr_image; + 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 switchAccount(accountId) { + if (state.activeAccount === accountId) return; + state.activeAccount = 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); } - toggleHidden("code-form", authData.phase !== "code_required"); - toggleHidden("password-form", authData.phase !== "password_required"); + // Update sidebar + updateSidebarAccount(); + + // Refresh data + refreshAccount(accountId); + + // Update settings auth section + updateAuthSection(accountId); } -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 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)"; + } } -function renderChannels(channels) { - const tbody = document.getElementById("channels-table"); - tbody.innerHTML = ""; +/* ── 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 = ""; channels.forEach((channel) => { const node = template.content.firstElementChild.cloneNode(true); @@ -109,50 +237,55 @@ function renderChannels(channels) { node.querySelector(".last-date").textContent = channel.last_date || "-"; node.querySelector(".scrape-btn").addEventListener("click", async () => { - await api("/api/jobs/scrape", { + await api(`/api/accounts/${accountId}/jobs/scrape`, { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); - await refreshDashboard(); + await refreshAccount(accountId); }); node.querySelector(".export-btn").addEventListener("click", async () => { - await api("/api/jobs/export-channel", { + await api(`/api/accounts/${accountId}/jobs/export-channel`, { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); - await refreshDashboard(); + await refreshAccount(accountId); }); node.querySelector(".export-view-btn").addEventListener("click", () => { - window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}`; + window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}&account=${encodeURIComponent(accountId)}`; }); node.querySelector(".media-btn").addEventListener("click", async () => { - await api("/api/jobs/rescrape-media", { + await api(`/api/accounts/${accountId}/jobs/rescrape-media`, { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); - await refreshDashboard(); + await refreshAccount(accountId); }); node.querySelector(".remove-btn").addEventListener("click", async () => { if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return; - await api("/api/channels/remove", { + await api(`/api/accounts/${accountId}/channels/remove`, { method: "POST", body: JSON.stringify({ channel_id: channel.channel_id }), }); - await refreshDashboard(); + await refreshAccount(accountId); }); tbody.appendChild(node); }); + + // Update channel count stat + panel.querySelector(".channel-count").textContent = String(channels.length); } -function renderJobs(jobs) { - const root = document.getElementById("jobs-list"); - root.innerHTML = ""; +function renderJobs(accountId, jobs) { + const panel = document.getElementById(`panel-${accountId}`); + if (!panel) return; + const root = panel.querySelector(".jobs-list"); const template = document.getElementById("job-template"); + root.innerHTML = ""; if (!jobs.length) { root.innerHTML = '
No jobs yet.
'; @@ -171,18 +304,25 @@ function renderJobs(jobs) { }); } -function renderContinuousChannelPicker(channels, config) { - const root = document.getElementById("continuous-channel-list"); +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 || []); - root.innerHTML = ""; + chList.innerHTML = ""; - if (!channels.length) { - root.innerHTML = '
No tracked channels.
'; - return; - } - - channels.forEach((channel) => { + channels.forEach((ch) => { const label = document.createElement("label"); label.className = "checkbox-row"; label.classList.toggle("disabled", runAll); @@ -190,15 +330,59 @@ function renderContinuousChannelPicker(channels, config) { 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.value = ch.channel_id; + checkbox.checked = runAll || selected.has(ch.channel_id); checkbox.disabled = runAll; const text = document.createElement("span"); - text.textContent = `${channel.name} (${channel.channel_id})`; + text.textContent = `${ch.name} (${ch.channel_id})`; label.append(checkbox, text); - root.appendChild(label); + chList.appendChild(label); + }); + + // Meta + panel.querySelector(".continuous-status").textContent = status.running ? "running" : "stopped"; + panel.querySelector(".continuous-last-iteration").textContent = status.last_iteration_at + ? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})` + : "-"; + panel.querySelector(".continuous-last-error").textContent = status.last_error || "-"; + + // Logs + const logsRoot = panel.querySelector(".continuous-logs"); + const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); + logsRoot.innerHTML = ""; + + if (!entries.length) { + logsRoot.innerHTML = '
-INFONo logs yet.
'; + return; + } + + entries.slice().reverse().forEach((entry) => { + const row = document.createElement("div"); + row.className = `log-line ${entry.level}`; + const time = entry.timestamp ? displayTime(entry.timestamp).split(", ").pop() : entry.legacyTime || "-"; + const absolute = entry.timestamp ? displayTime(entry.timestamp) : entry.legacyTime || "-"; + row.title = absolute; + + const timeNode = document.createElement("span"); + timeNode.className = "log-time"; + timeNode.textContent = time; + + const ageNode = document.createElement("span"); + ageNode.className = "log-age"; + ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-"; + + const levelNode = document.createElement("span"); + levelNode.className = "log-level"; + levelNode.textContent = entry.level.toUpperCase(); + + const messageNode = document.createElement("span"); + messageNode.className = "log-message"; + messageNode.textContent = entry.message; + + row.append(timeNode, ageNode, levelNode, messageNode); + logsRoot.appendChild(row); }); } @@ -230,246 +414,360 @@ function normalizeLogEntry(entry) { }; } -function renderContinuousLogs(status) { - const root = document.getElementById("continuous-logs"); - const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); - root.innerHTML = ""; +function renderSummary(accountId, data) { + const panel = document.getElementById(`panel-${accountId}`); + if (!panel) return; + const d = data.dashboard || data.state || {}; + panel.querySelector(".forwarding-count").textContent = String((d.forwarding_rules || []).length); + const toggle = panel.querySelector(".scrape-media-label"); + toggle.textContent = d.scrape_media ? "ON" : "OFF"; +} - if (!entries.length) { - const empty = document.createElement("div"); - empty.className = "log-line"; - empty.innerHTML = '--INFONo logs yet.'; - root.appendChild(empty); +/* ── 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; } - 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; + document.getElementById("no-accounts-msg").classList.add("hidden"); - const timeNode = document.createElement("span"); - timeNode.className = "log-time"; - timeNode.textContent = time; + // Render tabs + renderAccountTabs(accounts); - const ageNode = document.createElement("span"); - ageNode.className = "log-age"; - ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-"; + // Render panels for each account + accounts.forEach((acc) => renderAccountPanel(acc.id)); - const levelNode = document.createElement("span"); - levelNode.className = "log-level"; - levelNode.textContent = entry.level.toUpperCase(); + // Determine active account + const activeId = state.activeAccount || accounts[0].id; + switchAccount(activeId); - const messageNode = document.createElement("span"); - messageNode.className = "log-message"; - messageNode.textContent = entry.message; - - row.append(timeNode, ageNode, levelNode, messageNode); - root.appendChild(row); - }); + // Update settings + renderSettingsAccounts(); } -function renderContinuous(data) { - state.continuous = data; - const config = data.config || {}; - const status = data.status || {}; +/* ── Settings: Accounts list ────────────────────────── */ - 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); +function renderSettingsAccounts() { + const container = document.getElementById("accounts-list"); + const template = document.getElementById("account-list-item-template"); + container.innerHTML = ""; - 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); -} + 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; -function setupTabs() { - const tabs = document.querySelectorAll(".content-tab"); - const panels = document.querySelectorAll(".tab-panel"); + 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"; - 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)); + node.querySelector(".account-select-btn").addEventListener("click", () => { + switchAccount(acc.id); + document.getElementById("settings-dialog").close(); }); + + node.querySelector(".account-remove-btn").addEventListener("click", async () => { + if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return; + try { + await api(`/api/accounts/${acc.id}`, { method: "DELETE" }); + await loadAccounts(); + if (state.activeAccount === acc.id) { + state.activeAccount = null; + } + } catch (err) { + alert(`Failed to remove account: ${err.message}`); + } + }); + + container.appendChild(node); }); } -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); +/* ── 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() { - setupTabs(); + // ── Sidebar action buttons ── + document.getElementById("scrape-all-btn").addEventListener("click", async () => { + try { await scrapeAll(); } catch (err) { alert("Scrape error: " + err.message); } + }); + document.getElementById("export-all-btn").addEventListener("click", async () => { + try { await exportAll(); } catch (err) { alert("Export error: " + err.message); } + }); + document.getElementById("refresh-dialogs-btn").addEventListener("click", async () => { + try { await refreshDialogs(); } catch (err) { alert("Dialog refresh error: " + err.message); } + }); + // ── Settings dialog ── const settingsDialog = document.getElementById("settings-dialog"); document.getElementById("open-settings-btn").addEventListener("click", () => { + // Update auth section for active account + if (state.activeAccount) { + updateAuthSection(state.activeAccount); + } + renderSettingsAccounts(); settingsDialog.showModal(); }); document.getElementById("close-settings-btn").addEventListener("click", () => { settingsDialog.close(); }); - 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) => { + // ── Add account form ── + document.getElementById("add-account-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(); + 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) { + alert("Failed to add account: " + err.message); + } }); + // ── Credentials form ── 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(); + if (!state.activeAccount) return; + try { + await saveCredentials(state.activeAccount); + } catch (err) { + alert("Failed to save credentials: " + err.message); + } }); + // ── QR login ── 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(); + if (!state.activeAccount) return; + await startQrLogin(state.activeAccount); + } catch (err) { + alert("QR login error: " + err.message); } finally { setBusy(event.currentTarget, false); } }); + // ── Phone ── 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(); + if (!state.activeAccount) return; + try { + await requestPhoneCode(state.activeAccount); + } catch (err) { + alert("Phone code request error: " + err.message); + } }); + // ── Code ── 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(); + if (!state.activeAccount) return; + try { + await submitPhoneCode(state.activeAccount); + } catch (err) { + alert("Code submit error: " + err.message); + } }); + // ── Password ── 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; + if (!state.activeAccount) return; + try { + await submitPassword(state.activeAccount); + } catch (err) { + alert("Password error: " + err.message); } - 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, - }); + // ── Media toggle ── + document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => { + const checked = event.currentTarget.checked; + try { await toggleMedia(checked); } catch (err) { alert("Media toggle error: " + err.message); } }); - await refreshDashboard(); - window.setInterval(refreshDashboard, 5000); + // ── Load accounts ── + await loadAccounts(); + + // ── Periodic refresh ── + window.setInterval(() => { + if (state.activeAccount) { + refreshAccount(state.activeAccount); + } + }, 5000); } main().catch((error) => { diff --git a/webui/index.html b/webui/index.html index faea273..f5be6ac 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ Telegram Scraper Control Panel - +
@@ -22,10 +22,10 @@ Health -
-
Telegram Status
-
Checking...
-

+
+
Active Account
+
Loading...
+

@@ -38,127 +38,54 @@
-
- - + + + + +
+
-
-
-
-
Channels
-
0
+ + - -
-
-
-

Tracked Channels

-

Stored in data/state.json. Message stats come from SQLite.

-
-
- - - -
-
- -
- - - - - - - - - - - -
ChannelMessagesMediaLast messageActions
-
-
- -
-
-
-

Jobs

-

Queued work runs one job at a time to keep the Telegram session stable.

-
- -
-
-
-
- -
-
-
-
-

Continuous Scraping

-

Runs on a timer and keeps a compact, highlighted run log.

-
-
- -
- - - -
-
Continuous channel set
-
-
- -
- -
-
Status: -
-
Last run: -
-
Last error: -
-
- -
-
+
+
Settings
-

Telegram & Scraping

+

Global & Accounts

+
-
Telegram Login
+
Accounts
+
+ + +
+ + +
+
Account Auth: -
@@ -190,6 +117,7 @@
+
Scraping
+ + + + + + + + + + - + + + + diff --git a/webui/style.css b/webui/style.css index 3898a00..2e40e14 100644 --- a/webui/style.css +++ b/webui/style.css @@ -1,17 +1,24 @@ :root { color-scheme: dark; - --bg-color: #050505; - --panel: #0c0c0c; - --panel-alt: #080808; - --text-color: #e0e0e0; - --accent: #ffffff; - --dim: #6f6f6f; - --line: #2a2a2a; - --danger: #ff6b6b; - --ok: #7ee787; - --warn: #f0c674; - --info: #8ab4f8; - --font-mono: "Courier New", Courier, monospace; + --bg-color: #000000; + --panel: #0b0b0b; + --panel-solid: #101010; + --panel-alt: #050505; + --text-color: #f2f3f5; + --accent: #1fb9aa; + --accent-2: #58a6ff; + --bubble-own: #12342f; + --bubble-other: #171717; + --dim: #9aa0a6; + --line: #242424; + --danger: #ff7373; + --ok: #65e6a4; + --warn: #f6c969; + --info: #8ec7ff; + --radius: 18px; + --shadow: none; + --font-ui: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; } * { @@ -20,11 +27,11 @@ body { margin: 0; - background: var(--bg-color); + background: #000; color: var(--text-color); - font-family: var(--font-mono); - font-size: 16px; - line-height: 1.55; + font-family: var(--font-ui); + font-size: 15px; + line-height: 1.5; } a { @@ -35,8 +42,9 @@ a { a:hover, button:hover { - background: var(--text-color); - color: var(--bg-color); + border-color: #333; + background: #151515; + color: var(--text-color); } code, @@ -50,19 +58,19 @@ button { height: 100vh; overflow: hidden; display: grid; - grid-template-columns: 300px minmax(0, 1fr); + grid-template-columns: 308px minmax(0, 1fr); } .sidebar { - background: var(--panel-alt); - border-right: 1px dashed var(--line); - padding: 28px 24px; + background: #050505; + border-right: 1px solid var(--line); + padding: 24px 20px; } .content { overflow-y: auto; - padding: 28px; - max-width: 1320px; + padding: 24px; + max-width: 1440px; width: 100%; } @@ -70,21 +78,16 @@ button { .dialog-header h2 { margin: 0; color: var(--accent); - font-size: 1.45rem; - text-transform: uppercase; - letter-spacing: 0; + font-size: 1.5rem; + letter-spacing: -0.04em; } .eyebrow, .section-title { color: var(--dim); - font-size: 0.78rem; + font-size: 0.75rem; text-transform: uppercase; -} - -.eyebrow::before, -.section-title::before { - content: "./"; + letter-spacing: 0.08em; } .muted { @@ -112,8 +115,9 @@ input, .content-tab, .viewer-channel-item { border: 1px solid var(--line); - background: var(--panel); + background: #101010; color: var(--text-color); + border-radius: 999px; } .nav-link, @@ -125,21 +129,23 @@ input, min-height: 42px; padding: 9px 13px; cursor: pointer; + transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease; } .nav-link.active, .button.primary, .content-tab.active { - border-color: var(--accent); - color: var(--accent); - box-shadow: inset 3px 0 0 var(--accent); + border-color: #0f6f67; + color: #ffffff; + background: #0f6f67; + box-shadow: none; } .nav-link.active:hover, .button.primary:hover, .content-tab.active:hover { - background: var(--panel); - color: var(--accent); + background: #128277; + color: #ffffff; } .button.danger { @@ -164,7 +170,9 @@ input, .message-card, .settings-dialog { background: var(--panel); - border: 1px dashed var(--line); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); } .status-card, @@ -185,10 +193,10 @@ input, .status-badge { display: inline-flex; - padding: 4px 8px; + padding: 6px 10px; border: 1px solid var(--line); + border-radius: 999px; color: var(--ok); - text-transform: uppercase; } .content-tabs { @@ -228,8 +236,9 @@ input, .stat-value { margin-top: 8px; - font-size: 2rem; + font-size: 2.25rem; font-weight: 700; + letter-spacing: -0.05em; } .stat-compact { @@ -250,8 +259,9 @@ input, input { min-width: 160px; - padding: 9px 11px; + padding: 10px 13px; outline: 0; + border-radius: 14px; } input:focus { @@ -286,7 +296,8 @@ input:focus { position: absolute; inset: 0; border: 1px solid var(--line); - background: #101010; + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; } .switch-slider::before { @@ -297,6 +308,7 @@ input:focus { width: 16px; height: 16px; background: var(--dim); + border-radius: 50%; transition: transform 0.16s ease, background-color 0.16s ease; } @@ -323,11 +335,11 @@ td { padding: 12px 10px; text-align: left; vertical-align: top; - border-bottom: 1px dashed var(--line); + border-bottom: 1px solid var(--line); } tbody tr:hover { - background: #101010; + background: #111; } .action-row { @@ -375,7 +387,9 @@ tbody tr:hover { gap: 10px; min-height: 38px; padding: 8px; - border: 1px dashed var(--line); + border: 1px solid var(--line); + border-radius: 14px; + background: #0f0f0f; } .checkbox-row input { @@ -391,7 +405,8 @@ tbody tr:hover { max-height: 420px; overflow: auto; border: 1px solid var(--line); - background: #030303; + background: rgba(0, 0, 0, 0.2); + border-radius: 16px; } .log-line { @@ -404,7 +419,7 @@ tbody tr:hover { } .log-line:hover { - background: #101010; + background: #111; } .log-time, @@ -455,13 +470,14 @@ tbody tr:hover { .settings-section { margin-top: 18px; padding-top: 16px; - border-top: 1px dashed var(--line); + border-top: 1px solid var(--line); } .qr-wrap { margin-top: 14px; padding: 12px; - border: 1px dashed var(--line); + border: 1px solid var(--line); + border-radius: 16px; } .qr-wrap img { @@ -481,18 +497,18 @@ tbody tr:hover { .viewer-shell { display: grid; - grid-template-columns: 300px minmax(0, 1fr); + grid-template-columns: 360px minmax(0, 1fr); height: 100vh; overflow: hidden; } .viewer-sidebar { - background: var(--panel-alt); - border-right: 1px dashed var(--line); - padding: 28px 24px; + background: #050505; + border-right: 1px solid var(--line); + padding: 14px 10px; display: flex; flex-direction: column; - gap: 20px; + gap: 12px; overflow-y: auto; } @@ -505,48 +521,104 @@ tbody tr:hover { align-items: flex-start; justify-content: space-between; gap: 16px; + padding: 10px 6px 8px; } .viewer-sidebar-head h1 { margin: 0; color: var(--accent); font-size: 1.45rem; - text-transform: uppercase; - letter-spacing: 0; + letter-spacing: -0.04em; } .viewer-channel-list { display: grid; - gap: 10px; + gap: 2px; } .viewer-channel-item { display: grid; - gap: 4px; + grid-template-columns: 52px minmax(0, 1fr); + align-items: center; + gap: 10px; width: 100%; - padding: 12px; + min-height: 68px; + padding: 8px 10px; text-align: left; cursor: pointer; - border: 1px solid var(--line); - background: var(--panel); + border: 1px solid transparent; + background: transparent; color: var(--text-color); + border-radius: 12px; } .viewer-channel-item.active { - border-color: var(--accent); - box-shadow: inset 3px 0 0 var(--accent); + border-color: transparent; + background: #12342f; + box-shadow: none; +} + +.viewer-channel-item:hover { + background: #111; +} + +.viewer-channel-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: #5f6b70; + color: white; + font-weight: 700; + letter-spacing: -0.03em; +} + +.viewer-channel-copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.viewer-channel-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; } .viewer-channel-name { - color: var(--accent); + color: var(--text-color); font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.viewer-channel-meta { +.viewer-channel-time, +.viewer-channel-preview { color: var(--dim); font-size: 0.82rem; } +.viewer-channel-preview { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-channel-count { + min-width: 22px; + padding: 2px 7px; + border-radius: 999px; + background: #12342f; + color: var(--accent); + font-size: 0.76rem; + text-align: center; +} + /* ---------- Main panel ---------- */ .viewer-main { @@ -554,27 +626,31 @@ tbody tr:hover { flex-direction: column; height: 100vh; 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; } .viewer-header { flex-shrink: 0; - padding: 20px 28px; - border-bottom: 1px dashed var(--line); - background: var(--panel-alt); + padding: 16px 24px; + border-bottom: 1px solid var(--line); + background: #050505; } .viewer-header h2 { margin: 0; color: var(--accent); font-size: 1.45rem; - text-transform: uppercase; - letter-spacing: 0; + letter-spacing: -0.04em; } .messages-list { flex: 1; overflow-y: auto; - padding: 12px 28px 20px; + padding: 18px 34px 22px; display: flex; flex-direction: column; } @@ -594,9 +670,7 @@ tbody tr:hover { gap: 12px; margin: 12px 0; font-size: 0.78rem; - color: var(--dim); - text-transform: uppercase; - letter-spacing: 0.03em; + color: #d6e6e8; flex-shrink: 0; } @@ -605,7 +679,14 @@ tbody tr:hover { content: ""; flex: 1; height: 1px; - background: var(--line); + background: #242424; +} + +.chat-date-sep { + align-self: center; + padding: 5px 12px; + border-radius: 999px; + background: #111; } .chat-date-sep:empty { @@ -627,15 +708,16 @@ tbody tr:hover { /* ---------- Chat bubble ---------- */ .chat-bubble { - max-width: 75%; - padding: 7px 11px; - background: #1c1c1c; - border: 1px solid var(--line); - border-radius: 1px; - margin-bottom: 4px; + max-width: min(680px, 74%); + padding: 8px 12px 7px; + background: var(--bubble-other); + border: 1px solid #242424; + border-radius: 18px 18px 18px 6px; + margin-bottom: 6px; overflow-wrap: anywhere; word-break: break-word; position: relative; + box-shadow: none; } .chat-bubble::before { @@ -647,19 +729,20 @@ tbody tr:hover { .chat-bubble-wrap:not(.chat-bubble-wrap--own) .chat-bubble::before { left: -12px; - border-right-color: #1c1c1c; + border-right-color: var(--bubble-other); border-left: 0; } .chat-bubble-wrap--own .chat-bubble::before { right: -12px; - border-left-color: #1e3a5f; + border-left-color: var(--bubble-own); border-right: 0; } .chat-bubble-wrap--own .chat-bubble { - background: #1e3a5f; - border-color: #2b5278; + background: var(--bubble-own); + border-color: #1f5b52; + border-radius: 18px 18px 6px 18px; } .chat-bubble--highlight { @@ -675,7 +758,7 @@ tbody tr:hover { .chat-sender { font-size: 0.82rem; - color: var(--info); + color: var(--accent); font-weight: 700; margin-bottom: 2px; } @@ -690,8 +773,10 @@ tbody tr:hover { display: flex; gap: 6px; margin-bottom: 4px; - padding: 4px 0; + padding: 6px 8px; cursor: pointer; + border-radius: 10px; + background: rgba(0, 0, 0, 0.18); } .chat-reply.hidden { @@ -702,7 +787,7 @@ tbody tr:hover { flex-shrink: 0; width: 2px; background: var(--accent); - border-radius: 1px; + border-radius: 999px; opacity: 0.5; } @@ -729,6 +814,7 @@ tbody tr:hover { .chat-text { white-space: pre-wrap; line-height: 1.45; + font-size: 0.96rem; } .chat-text:empty { @@ -749,7 +835,8 @@ tbody tr:hover { .chat-media video { display: block; max-width: 100%; - border: 1px solid var(--line); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; } /* ---------- Footer ---------- */ @@ -783,7 +870,7 @@ tbody tr:hover { } .api-method { - border-top: 1px dashed var(--line); + border-top: 1px solid var(--line); padding-top: 12px; } @@ -827,7 +914,7 @@ tbody tr:hover { .sidebar, .viewer-sidebar { border-right: 0; - border-bottom: 1px dashed var(--line); + border-bottom: 1px solid var(--line); } .panel-grid { @@ -835,6 +922,124 @@ tbody tr:hover { } } +/* ── Account Tabs ─────────────────────────────────────── */ + +.account-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); +} + +.account-tab { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 7px 14px; + border: 1px solid var(--line); + border-radius: 999px; + background: #101010; + color: var(--text-color); + cursor: pointer; + font: inherit; + font-size: 0.88rem; +} + +.account-tab.active { + border-color: #0f6f67; + color: #ffffff; + background: #0f6f67; +} + +.account-tab .account-tab-status { + font-size: 0.72rem; + opacity: 0.7; +} + +.account-tab .account-tab-status.ok { + color: var(--ok); +} + +.account-tab .account-tab-status.errored { + color: var(--danger); +} + +/* ── Account Panels ──────────────────────────────────── */ + +.account-panel { + display: block; +} + +.account-panel.hidden { + display: none; +} + +/* ── Settings: Account List ──────────────────────────── */ + +.accounts-list { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.account-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: #0f0f0f; +} + +.account-list-info { + display: grid; + gap: 2px; + min-width: 0; +} + +.account-list-label { + font-weight: 700; +} + +.account-list-id { + font-size: 0.82rem; +} + +.account-list-auth-status { + font-size: 0.78rem; +} + +.account-list-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.add-account-form { + display: grid; + gap: 8px; + margin-top: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: #0f0f0f; +} + +/* ── Active Account Card ─────────────────────────────── */ + +#active-account-name { + word-break: break-all; +} + +#active-account-status { + margin-top: 6px; +} + /* ---------- Mobile: tablet & phone ---------- */ @media (max-width: 768px) { @@ -844,13 +1049,13 @@ tbody tr:hover { .viewer-sidebar { position: fixed; - left: -300px; + left: -360px; top: 0; bottom: 0; - width: 280px; + width: 340px; z-index: 100; transition: left 0.2s ease; - border-right: 1px dashed var(--line); + border-right: 1px solid var(--line); border-bottom: 0; } @@ -939,7 +1144,7 @@ tbody tr:hover { } .viewer-sidebar { - width: 260px; + width: 300px; padding: 16px; }