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 @@
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');
});