"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 =
``;
}
// ---------- 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,
token: sForm.default_ntfy_token.value.trim(),
username: sForm.default_ntfy_username.value.trim(),
password: sForm.default_ntfy_password.value,
});
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();