"use strict"; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; let ME = { role: "admin", auth_enabled: false }; // ---------- API helper ---------- async function api(method, url, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } const res = await fetch(url, opts); if (res.status === 401) { location.href = "/login"; throw new Error("auth"); } const data = res.headers.get("content-type")?.includes("json") ? await res.json() : null; if (!res.ok) throw new Error(data?.detail || `Error ${res.status}`); return data; } // ---------- Toast ---------- let toastTimer; function toast(msg, kind = "ok") { const el = $("#toast"); el.textContent = msg; el.className = `toast show ${kind}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.className = "toast hidden"; }, 3400); } // ---------- utils ---------- function escapeHtml(str) { return String(str ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function fmtDate(iso) { if (!iso) return t("feeds.never"); return new Date(iso).toLocaleString(localeTag(), { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); } // Localize a status code emitted by the backend (e.g. "sent:3:1"). function formatStatus(code) { if (!code) return t("status.dash"); const i = code.indexOf(":"); const head = i === -1 ? code : code.slice(0, i); const rest = i === -1 ? "" : code.slice(i + 1); switch (head) { case "init": return t("status.init", { n: rest }); case "sent": { const [n, s] = rest.split(":"); return s ? t("status.sentSkip", { n, s }) : t("status.sent", { n }); } case "filtered": return t("status.filtered", { s: rest }); case "nochange": return t("status.nochange"); case "parse_error": return t("status.parseError", { msg: rest }); case "send_error": return t("status.sendError", { msg: rest }); default: return code; } } function isErrorStatus(code) { return /^(parse_error|send_error)/.test(code || ""); } // ---------- Stats + chart ---------- async function loadStats() { try { const s = await api("GET", "/api/stats"); $("#stats").innerHTML = `
${s.feeds_total}${t("stats.feeds")}
${s.feeds_enabled}${t("stats.enabled")}
${s.feeds_failing}${t("stats.failing")}
${s.notifications_sent}${t("stats.sent")}
${s.notifications_failed}${t("stats.failed")}
`; } catch (_) {} } async function loadActivity() { let data; try { data = await api("GET", "/api/stats/activity?days=14"); } catch { return; } const total = data.reduce((a, d) => a + d.sent + d.failed, 0); const wrap = $("#chart-wrap"); if (!total) { wrap.classList.add("hidden"); return; } wrap.classList.remove("hidden"); const max = Math.max(1, ...data.map(d => d.sent + d.failed)); const W = 100, H = 38, n = data.length, gap = 1.2; const bw = (W - gap * (n - 1)) / n; let bars = ""; data.forEach((d, i) => { const x = i * (bw + gap); const sentH = (d.sent / max) * H; const failH = (d.failed / max) * H; const day = new Date(d.date + "T00:00").toLocaleDateString(localeTag(), { day: "2-digit", month: "short" }); const title = `${day}: ${t("chart.sent")} ${d.sent}, ${t("chart.failed")} ${d.failed}`; bars += `${escapeHtml(title)}`; bars += ``; if (failH > 0) bars += ``; bars += ``; }); $("#chart").innerHTML = `${bars}`; } // ---------- Feeds ---------- function feedCard(f) { const el = document.createElement("div"); el.className = "feed-card" + (f.enabled ? "" : " disabled"); const chips = []; chips.push(`๐Ÿ“จ ${escapeHtml(f.ntfy_topic || t("feeds.noTopic"))}`); if (f.ntfy_server) chips.push(`๐Ÿ–ฅ๏ธ ${escapeHtml(f.ntfy_server)}`); if (f.ntfy_token || f.ntfy_username) chips.push(`๐Ÿ” auth`); chips.push(`โšก P${f.priority}`); if (f.interval) chips.push(`โฑ ${f.interval}m`); if (f.to_telegram) chips.push(`โœˆ๏ธ TG`); if (f.to_webhook) chips.push(`๐Ÿ”— hook`); if (f.filter_include || f.filter_exclude) chips.push(`๐Ÿงฉ`); if (f.tags) chips.push(`๐Ÿท๏ธ ${escapeHtml(f.tags)}`); const admin = ME.role === "admin"; el.innerHTML = `
${escapeHtml(f.title || f.url)}
${escapeHtml(f.url)}
${chips.join("")}
${admin ? ` ` : ""}
${escapeHtml(formatStatus(f.last_status))}  ยท  ${fmtDate(f.last_checked)}
`; $('[data-act="check"]', el).onclick = (e) => checkFeed(f, e.currentTarget); if (admin) { $('[data-act="edit"]', el).onclick = () => openModal(f); $('[data-act="del"]', el).onclick = () => deleteFeed(f); } return el; } async function loadFeeds() { const feeds = await api("GET", "/api/feeds"); const list = $("#feeds-list"); list.innerHTML = ""; $("#feeds-empty").classList.toggle("hidden", feeds.length > 0); feeds.forEach(f => list.appendChild(feedCard(f))); loadStats(); loadActivity(); } async function deleteFeed(f) { if (!confirm(t("confirm.deleteFeed", { name: f.title || f.url }))) return; await api("DELETE", `/api/feeds/${f.id}`); toast(t("toast.feedDeleted")); loadFeeds(); } async function checkFeed(f, btn) { const old = btn.textContent; btn.textContent = "โ€ฆ"; btn.disabled = true; try { const r = await api("POST", `/api/feeds/${f.id}/check`); toast(formatStatus(r.status), isErrorStatus(r.status) ? "err" : "ok"); loadFeeds(); } catch (e) { toast(e.message, "err"); } finally { btn.textContent = old; btn.disabled = false; } } // ---------- Feed modal ---------- const modal = $("#modal"); const feedForm = $("#feed-form"); function openModal(feed) { feedForm.reset(); $("#preview-area").innerHTML = ""; $("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed"); feedForm.id.value = feed?.id || ""; const f = feed || { attach_image: true, enabled: true, priority: 3, interval: 0 }; for (const el of feedForm.elements) { if (!el.name || el.name === "id") continue; if (el.type === "checkbox") el.checked = !!f[el.name]; else if (f[el.name] !== undefined) el.value = f[el.name]; } modal.classList.remove("hidden"); } function closeModal() { modal.classList.add("hidden"); } $("#add-feed").onclick = () => openModal(null); $("#modal-close").onclick = closeModal; $("#modal-cancel").onclick = closeModal; modal.addEventListener("click", e => { if (e.target === modal) closeModal(); }); $("#preview-btn").onclick = async () => { const url = feedForm.url.value.trim(); if (!url) { toast(t("toast.needUrl"), "err"); return; } const area = $("#preview-area"); area.innerHTML = `
${t("feed.previewLoading")}
`; try { const p = await api("POST", "/api/preview", { url, filter_include: feedForm.filter_include.value.trim(), filter_exclude: feedForm.filter_exclude.value.trim(), }); const img = p.image ? `` : ""; area.innerHTML = `
๐Ÿ“ก ${escapeHtml(p.source || feedForm.title.value || "")}
${escapeHtml(p.title)}
${escapeHtml(p.body || "")}
${img}
`; } catch (err) { area.innerHTML = `
${escapeHtml(err.message)}
`; } }; feedForm.addEventListener("submit", async e => { e.preventDefault(); const payload = { url: feedForm.url.value.trim(), title: feedForm.title.value.trim(), ntfy_server: feedForm.ntfy_server.value.trim(), ntfy_topic: feedForm.ntfy_topic.value.trim(), ntfy_token: feedForm.ntfy_token.value.trim(), ntfy_username: feedForm.ntfy_username.value.trim(), ntfy_password: feedForm.ntfy_password.value, priority: parseInt(feedForm.priority.value, 10), interval: parseInt(feedForm.interval.value, 10) || 0, tags: feedForm.tags.value.trim(), filter_include: feedForm.filter_include.value.trim(), filter_exclude: feedForm.filter_exclude.value.trim(), attach_image: feedForm.attach_image.checked, to_telegram: feedForm.to_telegram.checked, to_webhook: feedForm.to_webhook.checked, enabled: feedForm.enabled.checked, }; const id = feedForm.id.value; try { if (id) await api("PUT", `/api/feeds/${id}`, payload); else await api("POST", "/api/feeds", payload); toast(id ? t("toast.feedUpdated") : t("toast.feedAdded")); closeModal(); loadFeeds(); } catch (err) { toast(err.message, "err"); } }); $("#check-all").onclick = async (e) => { const btn = e.currentTarget; btn.disabled = true; const feeds = await api("GET", "/api/feeds"); for (const f of feeds.filter(x => x.enabled)) { try { await api("POST", `/api/feeds/${f.id}/check`); } catch (_) {} } btn.disabled = false; toast(t("toast.checkDone")); loadFeeds(); }; // ---------- OPML ---------- $("#export-btn").onclick = () => { location.href = "/api/feeds/export"; }; $("#import-btn").onclick = () => $("#opml-file").click(); $("#opml-file").onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const fd = new FormData(); fd.append("file", file); try { const res = await fetch("/api/feeds/import", { method: "POST", body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Error"); toast(t("toast.imported", { added: data.added, total: data.total })); loadFeeds(); } catch (err) { toast(err.message, "err"); } finally { e.target.value = ""; } }; // ---------- History ---------- let historyTimer; async function loadHistory() { const q = encodeURIComponent($("#history-search").value.trim()); const onlyErr = $("#history-errors").checked; const notes = await api("GET", `/api/history?limit=200&q=${q}&only_errors=${onlyErr}`); const list = $("#history-list"); list.innerHTML = ""; $("#history-empty").classList.toggle("hidden", notes.length > 0); notes.forEach(n => { const el = document.createElement("div"); el.className = "history-row " + (n.ok ? "ok" : "err"); const channels = n.channels ? n.channels.split(",").map(c => `${escapeHtml(c)}`).join("") : ""; el.innerHTML = `
${n.ok ? "โœ…" : "โš ๏ธ"}
${n.link ? `${escapeHtml(n.title)}` : escapeHtml(n.title)}
${escapeHtml(n.feed_title || "")} ${channels} ${n.detail ? `${escapeHtml(n.detail)}` : ""}
${fmtDate(n.created_at)}
`; list.appendChild(el); }); } function debouncedHistory() { clearTimeout(historyTimer); historyTimer = setTimeout(() => loadHistory().catch(e => toast(e.message, "err")), 250); } $("#history-search").oninput = debouncedHistory; $("#history-errors").onchange = debouncedHistory; $("#history-refresh").onclick = () => loadHistory().catch(e => toast(e.message, "err")); $("#history-clear").onclick = async () => { if (!confirm(t("confirm.clearHistory"))) return; await api("DELETE", "/api/history"); toast(t("toast.historyCleared")); loadHistory(); }; // ---------- Users ---------- const userModal = $("#user-modal"); const userForm = $("#user-form"); async function loadUsers() { const users = await api("GET", "/api/users"); const list = $("#users-list"); list.innerHTML = ""; users.forEach(u => { const el = document.createElement("div"); el.className = "feed-card"; el.innerHTML = `
${escapeHtml(u.username)}
${u.role === "admin" ? t("users.admin") : t("users.viewer")}
`; $('[data-act="edit"]', el).onclick = () => openUserModal(u); $('[data-act="del"]', el).onclick = async () => { if (!confirm(t("confirm.deleteUser", { name: u.username }))) return; try { await api("DELETE", `/api/users/${u.id}`); toast(t("toast.deleted")); loadUsers(); } catch (e) { toast(e.message, "err"); } }; list.appendChild(el); }); } function openUserModal(user) { userForm.reset(); $("#user-modal-title").textContent = user ? t("user.editTitle") : t("user.addTitle"); $("#pw-hint").textContent = user ? t("user.pwKeep") : t("user.pwReq"); userForm.id.value = user?.id || ""; userForm.username.value = user?.username || ""; userForm.role.value = user?.role || "admin"; userModal.classList.remove("hidden"); } function closeUserModal() { userModal.classList.add("hidden"); } $("#add-user").onclick = () => openUserModal(null); $("#user-modal-close").onclick = closeUserModal; $("#user-modal-cancel").onclick = closeUserModal; userModal.addEventListener("click", e => { if (e.target === userModal) closeUserModal(); }); userForm.addEventListener("submit", async e => { e.preventDefault(); const payload = { username: userForm.username.value.trim(), password: userForm.password.value, role: userForm.role.value, }; const id = userForm.id.value; try { if (id) await api("PUT", `/api/users/${id}`, payload); else await api("POST", "/api/users", payload); toast(t("toast.saved")); closeUserModal(); loadUsers(); } catch (err) { toast(err.message, "err"); } }); // ---------- Settings ---------- const sForm = $("#settings-form"); async function loadSettings() { const s = await api("GET", "/api/settings"); for (const el of sForm.elements) { if (!el.name) continue; if (el.type === "checkbox") el.checked = !!s[el.name]; else if (s[el.name] !== undefined) el.value = s[el.name]; } } sForm.addEventListener("submit", async e => { e.preventDefault(); const payload = { default_ntfy_server: sForm.default_ntfy_server.value.trim(), default_ntfy_token: sForm.default_ntfy_token.value.trim(), default_ntfy_username: sForm.default_ntfy_username.value.trim(), default_ntfy_password: sForm.default_ntfy_password.value, check_interval: parseInt(sForm.check_interval.value, 10), auth_enabled: sForm.auth_enabled.checked, telegram_enabled: sForm.telegram_enabled.checked, telegram_token: sForm.telegram_token.value.trim(), telegram_chat_id: sForm.telegram_chat_id.value.trim(), webhook_enabled: sForm.webhook_enabled.checked, webhook_url: sForm.webhook_url.value.trim(), alerts_enabled: sForm.alerts_enabled.checked, alert_topic: sForm.alert_topic.value.trim(), alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3, }; try { await api("PUT", "/api/settings", payload); toast(t("toast.settingsSaved")); } catch (err) { toast(err.message, "err"); } }); $("#test-btn").onclick = async () => { const topic = $("#test-topic").value.trim(); if (!topic) { toast(t("toast.needTestTopic"), "err"); return; } try { const r = await api("POST", "/api/test", { server: sForm.default_ntfy_server.value.trim(), topic, }); toast(t("toast.sentTo", { dest: r.sent_to })); } catch (err) { toast(err.message, "err"); } }; // ---------- Tabs ---------- $$(".tab").forEach(tab => tab.addEventListener("click", () => { $$(".tab").forEach(t => t.classList.remove("active")); $$(".tab-panel").forEach(p => p.classList.remove("active")); tab.classList.add("active"); $(`#tab-${tab.dataset.tab}`).classList.add("active"); if (tab.dataset.tab === "history") loadHistory().catch(() => {}); if (tab.dataset.tab === "users") loadUsers().catch(() => {}); })); // ---------- Theme + language ---------- $("#theme-btn").onclick = () => setTheme(getTheme() === "dark" ? "light" : "dark"); const langSelect = $("#lang-select"); langSelect.value = getLang(); langSelect.onchange = () => { setLang(langSelect.value); applyI18n(); renderWhoami(); // Re-render dynamic content in the new language. loadFeeds().catch(() => {}); if ($("#tab-history").classList.contains("active")) loadHistory().catch(() => {}); if ($("#tab-users").classList.contains("active")) loadUsers().catch(() => {}); }; function renderWhoami() { if (ME.auth_enabled) { $("#whoami").textContent = `${ME.username} ยท ${ME.role === "admin" ? t("role.admin") : t("role.viewer")}`; } } // ---------- init ---------- async function init() { applyI18n(); try { ME = await api("GET", "/api/me"); } catch (_) {} if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden")); if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); } loadFeeds().catch(e => toast(e.message, "err")); if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err")); } init();