2026-06-02 21:11:57 +08:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
const $ = (sel, root = document) => root.querySelector(sel);
|
|
|
|
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
|
|
|
|
|
|
|
|
let ME = { role: "admin", auth_enabled: false };
|
2026-06-03 20:47:46 +08:00
|
|
|
let SETTINGS = {};
|
|
|
|
|
let CATEGORIES = [];
|
2026-06-02 21:11:57 +08:00
|
|
|
|
|
|
|
|
// ---------- 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 = `
|
|
|
|
|
<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>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 20:47:46 +08:00
|
|
|
// ---------- 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"); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 21:11:57 +08:00
|
|
|
// ---------- 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>`);
|
2026-06-03 20:47:46 +08:00
|
|
|
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>');
|
2026-06-02 21:11:57 +08:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
· ${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 || "";
|
2026-06-03 20:47:46 +08:00
|
|
|
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;
|
2026-06-02 21:11:57 +08:00
|
|
|
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];
|
|
|
|
|
}
|
2026-06-03 20:47:46 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-06-02 21:11:57 +08:00
|
|
|
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,
|
2026-06-03 20:47:46 +08:00
|
|
|
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,
|
2026-06-02 21:11:57 +08:00
|
|
|
to_telegram: feedForm.to_telegram.checked,
|
|
|
|
|
to_webhook: feedForm.to_webhook.checked,
|
|
|
|
|
enabled: feedForm.enabled.checked,
|
2026-06-03 20:47:46 +08:00
|
|
|
category_id: feedForm.category_id.value || null,
|
2026-06-02 21:11:57 +08:00
|
|
|
};
|
|
|
|
|
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 ----------
|
2026-06-03 20:47:46 +08:00
|
|
|
// --- 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 = ""; }
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 21:11:57 +08:00
|
|
|
$("#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() {
|
2026-06-03 20:47:46 +08:00
|
|
|
SETTINGS = await api("GET", "/api/settings");
|
2026-06-02 21:11:57 +08:00
|
|
|
for (const el of sForm.elements) {
|
|
|
|
|
if (!el.name) continue;
|
2026-06-03 20:47:46 +08:00
|
|
|
if (el.type === "checkbox") el.checked = !!SETTINGS[el.name];
|
|
|
|
|
else if (SETTINGS[el.name] !== undefined) el.value = SETTINGS[el.name];
|
2026-06-02 21:11:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sForm.addEventListener("submit", async e => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const payload = {
|
|
|
|
|
default_ntfy_server: sForm.default_ntfy_server.value.trim(),
|
2026-06-02 21:47:12 +08:00
|
|
|
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,
|
2026-06-02 21:11:57 +08:00
|
|
|
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,
|
2026-06-03 20:47:46 +08:00
|
|
|
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(),
|
2026-06-02 21:11:57 +08:00
|
|
|
};
|
|
|
|
|
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", {
|
2026-06-02 22:04:05 +08:00
|
|
|
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,
|
2026-06-02 21:11:57 +08:00
|
|
|
});
|
|
|
|
|
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(() => {});
|
2026-06-03 20:47:46 +08:00
|
|
|
if (tab.dataset.tab === "reader") loadReader().catch(() => {});
|
|
|
|
|
if (tab.dataset.tab === "categories") loadCategories().catch(() => {});
|
2026-06-02 21:11:57 +08:00
|
|
|
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(() => {});
|
2026-06-03 20:47:46 +08:00
|
|
|
if ($("#tab-reader").classList.contains("active")) loadReader().catch(() => {});
|
|
|
|
|
if ($("#tab-categories").classList.contains("active")) loadCategories().catch(() => {});
|
2026-06-02 21:11:57 +08:00
|
|
|
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")}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 20:47:46 +08:00
|
|
|
// ---------- 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"); }
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 21:11:57 +08:00
|
|
|
// ---------- init ----------
|
|
|
|
|
async function init() {
|
|
|
|
|
applyI18n();
|
|
|
|
|
try { ME = await api("GET", "/api/me"); } catch (_) {}
|
2026-06-03 20:47:46 +08:00
|
|
|
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"); });
|
2026-06-02 21:11:57 +08:00
|
|
|
}
|
|
|
|
|
init();
|