Files
rss-ntfy/app/static/app.js
T
dimon 834092a3ec
build-and-push / docker (push) Has been cancelled
8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
- Full article extraction via trafilatura (fetch_full_article)
- Digest mode with configurable period (digest_enabled, digest_period_hours)
- ntfy Actions buttons (Open article, Open feed)
- Notification templates with {title}, {body}, {link}, {source}, {image_url}
- FTS5 full-text search in notification history
- Database backup/restore (download/upload .db)
- HTTP/SOCKS proxy for RSS feed fetching (proxy_url setting)
- Built-in RSS reader tab with categories, unread counts, article detail view
- Auto-category 'Общее' for feeds without a category
- Article storage (Article table) for reader
- DigestEntry model for pending digest entries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:47:46 +08:00

728 lines
29 KiB
JavaScript

"use strict";
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
let ME = { role: "admin", auth_enabled: false };
let SETTINGS = {};
let CATEGORIES = [];
// ---------- 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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = `
<div class="stat"><b>${s.feeds_total}</b><span>${t("stats.feeds")}</span></div>
<div class="stat"><b>${s.feeds_enabled}</b><span>${t("stats.enabled")}</span></div>
<div class="stat ${s.feeds_failing ? "warn" : ""}"><b>${s.feeds_failing}</b><span>${t("stats.failing")}</span></div>
<div class="stat"><b>${s.notifications_sent}</b><span>${t("stats.sent")}</span></div>
<div class="stat ${s.notifications_failed ? "warn" : ""}"><b>${s.notifications_failed}</b><span>${t("stats.failed")}</span></div>`;
} 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 += `<g><title>${escapeHtml(title)}</title>`;
bars += `<rect class="bar-sent" x="${x.toFixed(2)}" y="${(H - sentH).toFixed(2)}" width="${bw.toFixed(2)}" height="${sentH.toFixed(2)}" rx="0.4"/>`;
if (failH > 0)
bars += `<rect class="bar-fail" x="${x.toFixed(2)}" y="${(H - sentH - failH).toFixed(2)}" width="${bw.toFixed(2)}" height="${failH.toFixed(2)}" rx="0.4"/>`;
bars += `</g>`;
});
$("#chart").innerHTML =
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart-svg">${bars}</svg>`;
}
// ---------- Categories ----------
async function loadCategories() {
try { CATEGORIES = await api("GET", "/api/categories"); } catch (e) { CATEGORIES = []; }
var list = document.getElementById("categories-list");
if (!list) return;
list.innerHTML = "";
var empty = document.getElementById("categories-empty");
if (empty) empty.classList.toggle("hidden", CATEGORIES.length > 0);
CATEGORIES.forEach(function(c) { list.appendChild(categoryCard(c)); });
}
function categoryCard(c) {
var el = document.createElement("div");
el.className = "feed-card";
el.innerHTML = '<div class="feed-top"><span class="dot on"></span>' +
'<div style="flex:1">' +
'<div class="feed-title">' + escapeHtml(c.name) + '</div>' +
'<div class="feed-meta"><span class="chip">' + t("cat.sortOrder") + ': ' + c.sort_order + '</span></div>' +
'</div>' +
'<div class="feed-actions">' +
'<button class="btn ghost small" data-act="edit">✎</button>' +
'<button class="btn danger small" data-act="del">🗑</button>' +
'</div></div>';
el.querySelector('[data-act="edit"]').onclick = function() { openCatModal(c); };
el.querySelector('[data-act="del"]').onclick = function() { deleteCategory(c); };
return el;
}
var catModal = document.getElementById("cat-modal");
var catForm = document.getElementById("cat-form");
function openCatModal(cat) {
catForm.reset();
document.getElementById("cat-modal-title").textContent = cat ? t("cat.editTitle") : t("cat.addTitle");
catForm.id.value = cat ? (cat.id || "") : "";
catForm.name.value = cat ? (cat.name || "") : "";
catForm.sort_order.value = (cat && cat.sort_order != null) ? cat.sort_order : 0;
catModal.classList.remove("hidden");
}
function closeCatModal() { catModal.classList.add("hidden"); }
document.getElementById("add-category").onclick = function() { openCatModal(null); };
document.getElementById("cat-modal-close").onclick = closeCatModal;
document.getElementById("cat-modal-cancel").onclick = closeCatModal;
catModal.addEventListener("click", function(e) { if (e.target === catModal) closeCatModal(); });
catForm.addEventListener("submit", async function(e) {
e.preventDefault();
var payload = {
name: catForm.name.value.trim(),
sort_order: parseInt(catForm.sort_order.value, 10) || 0,
};
var id = catForm.id.value;
try {
if (id) await api("PUT", "/api/categories/" + id, payload);
else await api("POST", "/api/categories", payload);
toast(id ? t("toast.categoryUpdated") : t("toast.categoryAdded"));
closeCatModal();
await loadCategories();
loadFeeds().catch(function() {});
} catch (err) { toast(err.message, "err"); }
});
async function deleteCategory(c) {
var feedCount = 0;
try {
var feeds = await api("GET", "/api/feeds");
feedCount = feeds.filter(function(f) { return f.category_id === c.id; }).length;
} catch (e) {}
var msg = feedCount
? t("confirm.deleteCategoryFeeds", { name: c.name, n: feedCount })
: t("confirm.deleteCategory", { name: c.name });
if (!confirm(msg)) return;
try {
await api("DELETE", "/api/categories/" + c.id);
toast(t("toast.categoryDeleted"));
await loadCategories();
loadFeeds().catch(function() {});
} catch (err) { toast(err.message, "err"); }
}
// ---------- Feeds ----------
function feedCard(f) {
const el = document.createElement("div");
el.className = "feed-card" + (f.enabled ? "" : " disabled");
const chips = [];
chips.push(`<span class="chip topic">📨 ${escapeHtml(f.ntfy_topic || t("feeds.noTopic"))}</span>`);
if (f.ntfy_server) chips.push(`<span class="chip">🖥️ ${escapeHtml(f.ntfy_server)}</span>`);
if (f.ntfy_token || f.ntfy_username) chips.push(`<span class="chip">🔐 auth</span>`);
chips.push(`<span class="chip">⚡ P${f.priority}</span>`);
if (f.interval) chips.push(`<span class="chip">⏱ ${f.interval}m</span>`);
if (f.to_telegram) chips.push(`<span class="chip tg">✈️ TG</span>`);
if (f.to_webhook) chips.push(`<span class="chip">🔗 hook</span>`);
if (f.filter_include || f.filter_exclude) chips.push(`<span class="chip">🧩</span>`);
if (f.tags) chips.push(`<span class="chip">🏷️ ${escapeHtml(f.tags)}</span>`);
if (f.send_full_content) chips.push('<span class="chip full">📄 full</span>');
if (f.fetch_full_article) chips.push('<span class="chip full">📄 trafilatura</span>');
if (f.digest_enabled) chips.push('<span class="chip">📬 digest ' + f.digest_period_hours + 'h</span>');
var cat = CATEGORIES.find(function(c) { return c.id === f.category_id; });
if (cat) chips.push('<span class="chip cat">🏷️ ' + escapeHtml(cat.name) + '</span>');
const admin = ME.role === "admin";
el.innerHTML = `
<div class="feed-top">
<span class="dot ${f.enabled ? "on" : "off"}"></span>
<div style="min-width:0;flex:1">
<div class="feed-title">${escapeHtml(f.title || f.url)}</div>
<div class="feed-url">${escapeHtml(f.url)}</div>
<div class="feed-meta">${chips.join("")}</div>
</div>
<div class="feed-actions">
<button class="btn ghost small" data-act="check">↻</button>
${admin ? `<button class="btn ghost small" data-act="edit">✎</button>
<button class="btn danger small" data-act="del">🗑</button>` : ""}
</div>
</div>
<div class="feed-status">
<span class="${isErrorStatus(f.last_status) ? "err" : (f.last_status.startsWith("sent") ? "ok" : "")}">${escapeHtml(formatStatus(f.last_status))}</span>
&nbsp;·&nbsp; ${fmtDate(f.last_checked)}
</div>`;
$('[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 defaults = {
attach_image: SETTINGS.default_attach_image ?? true,
enabled: true,
priority: SETTINGS.default_priority ?? 3,
interval: SETTINGS.default_interval ?? 0,
tags: SETTINGS.default_tags ?? "",
};
const f = feed || defaults;
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];
}
// Populate category dropdown
const sel = feedForm.category_id;
if (sel) {
sel.innerHTML = '<option value="">' + t("feed.categoryNone") + '</option>';
CATEGORIES.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + escapeHtml(c.name) + '</option>';
});
if (feed && feed.category_id) sel.value = feed.category_id;
}
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 = `<div class="muted">${t("feed.previewLoading")}</div>`;
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 ? `<img src="${escapeHtml(p.image)}" alt="" loading="lazy">` : "";
area.innerHTML = `
<div class="ntfy-preview">
<div class="np-head">📡 ${escapeHtml(p.source || feedForm.title.value || "")}</div>
<div class="np-title">${escapeHtml(p.title)}</div>
<div class="np-body">${escapeHtml(p.body || "")}</div>
${img}
</div>`;
} catch (err) {
area.innerHTML = `<div class="alert error">${escapeHtml(err.message)}</div>`;
}
};
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,
send_full_content: feedForm.send_full_content.checked,
fetch_full_article: feedForm.fetch_full_article.checked,
digest_enabled: feedForm.digest_enabled.checked,
digest_period_hours: parseInt(feedForm.digest_period_hours.value, 10) || 24,
to_telegram: feedForm.to_telegram.checked,
to_webhook: feedForm.to_webhook.checked,
enabled: feedForm.enabled.checked,
category_id: feedForm.category_id.value || null,
};
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 ----------
// --- Backup / Restore ---
$("#backup-btn").onclick = function() { location.href = "/api/backup"; };
$("#restore-btn").onclick = function() { document.getElementById("restore-file").click(); };
$("#restore-file").onchange = async function(e) {
var file = e.target.files[0];
if (!file) return;
if (!confirm(t("confirm.restore"))) { e.target.value = ""; return; }
var fd = new FormData();
fd.append("file", file);
try {
var res = await fetch("/api/backup", { method: "POST", body: fd });
var data = await res.json();
if (!res.ok) throw new Error(data.detail || "Error");
toast(t("toast.restored"));
setTimeout(function() { location.reload(); }, 1500);
} catch (err) { toast(err.message, "err"); }
finally { e.target.value = ""; }
};
$("#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 => `<span class="chip">${escapeHtml(c)}</span>`).join("")
: "";
el.innerHTML = `
<div class="history-icon">${n.ok ? "✅" : "⚠️"}</div>
<div class="history-main">
<div class="history-title">${n.link
? `<a href="${escapeHtml(n.link)}" target="_blank" rel="noopener">${escapeHtml(n.title)}</a>`
: escapeHtml(n.title)}</div>
<div class="history-sub">
<span class="muted">${escapeHtml(n.feed_title || "")}</span> ${channels}
${n.detail ? `<span class="err">${escapeHtml(n.detail)}</span>` : ""}
</div>
</div>
<div class="history-time muted">${fmtDate(n.created_at)}</div>`;
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 = `
<div class="feed-top">
<span class="dot on"></span>
<div style="flex:1">
<div class="feed-title">${escapeHtml(u.username)}</div>
<div class="feed-meta"><span class="chip ${u.role === "admin" ? "topic" : ""}">
${u.role === "admin" ? t("users.admin") : t("users.viewer")}</span></div>
</div>
<div class="feed-actions">
<button class="btn ghost small" data-act="edit">✎</button>
<button class="btn danger small" data-act="del">🗑</button>
</div>
</div>`;
$('[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() {
SETTINGS = await api("GET", "/api/settings");
for (const el of sForm.elements) {
if (!el.name) continue;
if (el.type === "checkbox") el.checked = !!SETTINGS[el.name];
else if (SETTINGS[el.name] !== undefined) el.value = SETTINGS[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,
default_priority: parseInt(sForm.default_priority.value, 10) || 3,
default_tags: sForm.default_tags.value.trim(),
default_attach_image: sForm.default_attach_image.checked,
default_interval: parseInt(sForm.default_interval.value, 10) || 0,
notification_template: sForm.notification_template.value,
proxy_url: sForm.proxy_url.value.trim(),
};
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 === "reader") loadReader().catch(() => {});
if (tab.dataset.tab === "categories") loadCategories().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-reader").classList.contains("active")) loadReader().catch(() => {});
if ($("#tab-categories").classList.contains("active")) loadCategories().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")}`;
}
}
// ---------- Reader ----------
var READER = { cats: [], selectedCat: "all" };
async function loadReaderStats() {
try { READER.cats = await api("GET", "/api/articles/stats"); } catch (e) { READER.cats = []; }
var list = document.getElementById("reader-cat-list");
if (!list) return;
list.innerHTML = '<div class="reader-cat active" data-cat="all"><span>' + t("reader.all") + '</span></div>';
var totalUnread = 0;
READER.cats.forEach(function(c) {
totalUnread += c.unread;
list.innerHTML += '<div class="reader-cat" data-cat="' + c.category_id + '">' +
'<span>' + escapeHtml(c.category_name) + '</span>' +
(c.unread ? '<span class="badge">' + c.unread + '</span>' : '') +
'</div>';
});
var allEl = list.querySelector('[data-cat="all"]');
if (allEl && totalUnread) allEl.innerHTML = '<span>' + t("reader.all") + '</span><span class="badge">' + totalUnread + '</span>';
var cats = list.querySelectorAll('.reader-cat');
cats.forEach(function(el) {
el.onclick = function() {
cats.forEach(function(x) { x.classList.remove("active"); });
el.classList.add("active");
READER.selectedCat = el.dataset.cat;
loadReaderArticles();
};
});
}
async function loadReaderArticles() {
var url = "/api/articles?limit=100";
if (READER.selectedCat !== "all") url += "&category_id=" + READER.selectedCat;
var articles;
try { articles = await api("GET", url); } catch (e) { return; }
var list = document.getElementById("reader-list");
var empty = document.getElementById("reader-empty");
list.innerHTML = "";
if (empty) empty.classList.toggle("hidden", articles.length > 0);
articles.forEach(function(a) {
var el = document.createElement("div");
el.className = "article-row" + (a.is_read ? "" : " unread");
var imgHtml = a.image ? '<img src="' + escapeHtml(a.image) + '" alt="" loading="lazy" style="max-width:80px;max-height:60px;border-radius:6px;flex-shrink:0">' : '';
el.innerHTML = '<span class="article-dot">' + (a.is_read ? '○' : '●') + '</span>' +
'<div class="article-content">' +
'<div class="article-title">' + escapeHtml(a.title) + '</div>' +
'<div class="article-meta">' +
'<span class="chip">' + escapeHtml(a.feed_title) + '</span>' +
'<span class="muted">' + fmtDate(a.created_at) + '</span>' +
'</div>' +
'<div class="article-body-preview">' + escapeHtml(a.body) + '</div>' +
'</div>' +
imgHtml;
el.onclick = async function() {
try {
var full = await api("GET", "/api/articles/" + a.id);
showArticleDetail(full);
} catch (err) { toast(err.message, "err"); }
};
list.appendChild(el);
});
}
function showArticleDetail(article) {
var detail = document.getElementById("reader-detail");
var list = document.getElementById("reader-list");
detail.classList.remove("hidden");
list.classList.add("hidden");
var body = article.full_html || escapeHtml(article.body).replace(/\n/g, '<br>');
detail.innerHTML = '<button class="btn ghost" id="reader-back">← ' + t("reader.back") + '</button>' +
'<h2>' + escapeHtml(article.title) + '</h2>' +
'<div class="article-meta" style="margin:12px 0">' +
'<span class="chip">' + escapeHtml(article.feed_title) + '</span>' +
'<span class="muted">' + fmtDate(article.created_at) + '</span>' +
(article.link ? '<a href="' + escapeHtml(article.link) + '" target="_blank" rel="noopener" class="btn ghost small">' + t("reader.open") + '</a>' : '') +
'</div>' +
'<div class="article-body">' + body + '</div>';
document.getElementById("reader-back").onclick = function() {
detail.classList.add("hidden");
list.classList.remove("hidden");
};
}
async function loadReader() {
await loadReaderStats();
await loadReaderArticles();
}
document.getElementById("reader-mark-all").onclick = async function() {
var url = "/api/articles/read-all";
if (READER.selectedCat !== "all") url += "?category_id=" + READER.selectedCat;
try {
var r = await api("PUT", url);
toast(t("toast.articlesMarked"));
loadReader();
} catch (err) { toast(err.message, "err"); }
};
// ---------- init ----------
async function init() {
applyI18n();
try { ME = await api("GET", "/api/me"); } catch (_) {}
if (ME.role !== "admin") $$(".admin-only").forEach(function(el) { el.classList.add("hidden"); });
if (ME.auth_enabled) { document.getElementById("logout-btn").style.display = ""; renderWhoami(); }
if (ME.role === "admin") {
loadCategories().catch(function() {});
loadSettings().catch(function(e) { toast(e.message, "err"); });
}
loadFeeds().catch(function(e) { toast(e.message, "err"); });
}
init();