327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const $ = (sel) => document.querySelector(sel);
|
||
|
|
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||
|
|
|
||
|
|
async function api(path, opts = {}) {
|
||
|
|
const res = await fetch(path, {
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
...opts,
|
||
|
|
});
|
||
|
|
let data = null;
|
||
|
|
try { data = await res.json(); } catch (_) { /* no body */ }
|
||
|
|
if (!res.ok) {
|
||
|
|
const msg = (data && (data.error || data.message)) || res.statusText;
|
||
|
|
throw new Error(msg);
|
||
|
|
}
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
function toast(message, kind = "") {
|
||
|
|
const el = $("#toast");
|
||
|
|
el.textContent = message;
|
||
|
|
el.className = "toast " + kind;
|
||
|
|
setTimeout(() => el.classList.add("hidden"), 3000);
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtTime(iso) {
|
||
|
|
if (!iso) return "—";
|
||
|
|
try { return new Date(iso).toLocaleString(locale()); }
|
||
|
|
catch (_) { return iso; }
|
||
|
|
}
|
||
|
|
function fmtTs(ts) {
|
||
|
|
if (!ts) return "—";
|
||
|
|
return new Date(ts * 1000).toLocaleString(locale());
|
||
|
|
}
|
||
|
|
function escapeHtml(s) {
|
||
|
|
return String(s).replace(/[&<>"]/g, (c) =>
|
||
|
|
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------- Language ---------------- */
|
||
|
|
const langSel = $("#lang-select");
|
||
|
|
langSel.value = LANG;
|
||
|
|
langSel.addEventListener("change", async () => {
|
||
|
|
setLang(langSel.value);
|
||
|
|
refreshStatus();
|
||
|
|
refreshLogs();
|
||
|
|
if ($("#tab-feeds").classList.contains("active")) loadFeeds();
|
||
|
|
try {
|
||
|
|
await api("/api/settings", { method: "PUT", body: JSON.stringify({ language: LANG }) });
|
||
|
|
} catch (_) { /* ignore */ }
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------------- 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 === "feeds") loadFeeds();
|
||
|
|
if (tab.dataset.tab === "settings") loadSettings();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------------- Status / dashboard ---------------- */
|
||
|
|
async function refreshStatus() {
|
||
|
|
try {
|
||
|
|
const s = await api("/api/status");
|
||
|
|
$("#stat-feeds").textContent = `${s.feed_active}/${s.feed_total}`;
|
||
|
|
$("#stat-topics").textContent = s.topics.length;
|
||
|
|
$("#stat-sent").textContent = s.sent_total;
|
||
|
|
$("#stat-history").textContent = s.history_count;
|
||
|
|
|
||
|
|
const enabled = s.enabled;
|
||
|
|
const pill = $("#engine-pill");
|
||
|
|
if (s.syncing) {
|
||
|
|
pill.textContent = t("pill_syncing"); pill.className = "pill pill-on";
|
||
|
|
} else if (enabled) {
|
||
|
|
pill.textContent = t("pill_running"); pill.className = "pill pill-on";
|
||
|
|
} else {
|
||
|
|
pill.textContent = t("pill_paused"); pill.className = "pill pill-off";
|
||
|
|
}
|
||
|
|
$("#btn-toggle").textContent = enabled ? t("pause") : t("resume");
|
||
|
|
|
||
|
|
$("#dash-engine").textContent = s.syncing ? t("st_syncing") : (enabled ? t("st_running") : t("st_paused"));
|
||
|
|
$("#dash-last").textContent = fmtTime(s.last_sync) +
|
||
|
|
(s.last_sync_ok === false ? t("err_suffix_sync_failed") : "");
|
||
|
|
$("#dash-next").textContent = enabled ? fmtTs(s.next_sync) : "—";
|
||
|
|
$("#dash-interval").textContent = s.sync_interval + " " + t("sec");
|
||
|
|
$("#dash-error").textContent = s.last_error || "—";
|
||
|
|
} catch (e) {
|
||
|
|
$("#engine-pill").textContent = t("no_connection");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refreshLogs() {
|
||
|
|
try {
|
||
|
|
const { logs } = await api("/api/logs");
|
||
|
|
const box = $("#log");
|
||
|
|
box.innerHTML = logs.map((l) => {
|
||
|
|
const time = new Date(l.time).toLocaleTimeString(locale());
|
||
|
|
return `<div class="line ${l.level}"><span class="time">${time}</span> ${escapeHtml(l.message)}</div>`;
|
||
|
|
}).join("");
|
||
|
|
} catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------- Engine controls ---------------- */
|
||
|
|
$("#btn-sync").addEventListener("click", async () => {
|
||
|
|
try { await api("/api/sync", { method: "POST" }); toast(t("sync_started"), "ok"); }
|
||
|
|
catch (e) { toast(e.message, "err"); }
|
||
|
|
setTimeout(refreshStatus, 500);
|
||
|
|
setTimeout(refreshLogs, 1500);
|
||
|
|
});
|
||
|
|
|
||
|
|
$("#btn-toggle").addEventListener("click", async () => {
|
||
|
|
const s = await api("/api/status");
|
||
|
|
const action = s.enabled ? "pause" : "resume";
|
||
|
|
await api("/api/engine", { method: "POST", body: JSON.stringify({ action }) });
|
||
|
|
refreshStatus();
|
||
|
|
});
|
||
|
|
|
||
|
|
$("#btn-clear-history").addEventListener("click", async () => {
|
||
|
|
if (!confirm(t("confirm_clear_history"))) return;
|
||
|
|
await api("/api/history/clear", { method: "POST" });
|
||
|
|
toast(t("history_cleared"), "ok");
|
||
|
|
refreshStatus();
|
||
|
|
});
|
||
|
|
|
||
|
|
$("#btn-test").addEventListener("click", async () => {
|
||
|
|
const topic = $("#test-topic").value.trim();
|
||
|
|
const message = $("#test-msg").value.trim();
|
||
|
|
if (!topic) { toast(t("need_topic"), "err"); return; }
|
||
|
|
try {
|
||
|
|
await api("/api/test-notify", { method: "POST", body: JSON.stringify({ topic, message }) });
|
||
|
|
toast(t("notify_sent"), "ok");
|
||
|
|
} catch (e) { toast(t("err_prefix") + e.message, "err"); }
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------------- OPML import / export ---------------- */
|
||
|
|
$("#btn-export-opml").addEventListener("click", () => {
|
||
|
|
window.location.href = "/api/export/opml";
|
||
|
|
});
|
||
|
|
|
||
|
|
$("#btn-import-opml").addEventListener("click", () => $("#opml-file").click());
|
||
|
|
|
||
|
|
$("#opml-file").addEventListener("change", async (e) => {
|
||
|
|
const file = e.target.files[0];
|
||
|
|
if (!file) return;
|
||
|
|
const form = new FormData();
|
||
|
|
form.append("file", file);
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/import/opml", { method: "POST", body: form });
|
||
|
|
const data = await res.json();
|
||
|
|
if (!res.ok || !data.ok) throw new Error(data.error || res.statusText);
|
||
|
|
toast(t("import_done").replace("{n}", data.added).replace("{total}", data.total), "ok");
|
||
|
|
loadFeeds(); refreshStatus();
|
||
|
|
} catch (err) {
|
||
|
|
toast(t("err_prefix") + err.message, "err");
|
||
|
|
} finally {
|
||
|
|
e.target.value = "";
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------------- Feeds ---------------- */
|
||
|
|
async function loadFeeds() {
|
||
|
|
const { feeds } = await api("/api/feeds");
|
||
|
|
const list = $("#feeds-list");
|
||
|
|
if (!feeds.length) {
|
||
|
|
list.innerHTML = `<div class="empty">${escapeHtml(t("feeds_empty"))}</div>`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
list.innerHTML = feeds.map(feedRow).join("");
|
||
|
|
$$(".feed-toggle").forEach((el) => el.addEventListener("change", onToggleFeed));
|
||
|
|
$$(".feed-edit").forEach((el) => el.addEventListener("click", onEditFeed));
|
||
|
|
$$(".feed-del").forEach((el) => el.addEventListener("click", onDeleteFeed));
|
||
|
|
}
|
||
|
|
|
||
|
|
function feedRow(f) {
|
||
|
|
const badges = [`<span class="badge topic">→ ${escapeHtml(f.topic)}</span>`,
|
||
|
|
`<span class="badge">${t("priority")} ${f.priority}</span>`];
|
||
|
|
if (f.quiet_hours) badges.push(`<span class="badge">${t("quiet")} ${escapeHtml(f.quiet_hours)}</span>`);
|
||
|
|
if (f.include_regex) badges.push(`<span class="badge">include</span>`);
|
||
|
|
if (f.exclude_regex) badges.push(`<span class="badge">exclude</span>`);
|
||
|
|
return `
|
||
|
|
<div class="feed-row">
|
||
|
|
<label class="switch">
|
||
|
|
<input type="checkbox" class="feed-toggle" data-id="${f.id}" ${f.enabled ? "checked" : ""}>
|
||
|
|
<span class="slider"></span>
|
||
|
|
</label>
|
||
|
|
<div class="feed-main">
|
||
|
|
<div class="feed-name">${escapeHtml(f.name || t("no_name"))}</div>
|
||
|
|
<div class="feed-url">${escapeHtml(f.url)}</div>
|
||
|
|
<div class="feed-badges">${badges.join("")}</div>
|
||
|
|
</div>
|
||
|
|
<div class="feed-actions">
|
||
|
|
<button class="icon-btn feed-edit" data-id="${f.id}" title="${escapeHtml(t("edit"))}">✎</button>
|
||
|
|
<button class="icon-btn feed-del" data-id="${f.id}" title="${escapeHtml(t("delete"))}">🗑</button>
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onToggleFeed(e) {
|
||
|
|
const id = e.target.dataset.id;
|
||
|
|
await api(`/api/feeds/${id}`, { method: "PUT", body: JSON.stringify({ enabled: e.target.checked }) });
|
||
|
|
toast(t("saved"), "ok");
|
||
|
|
refreshStatus();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onEditFeed(e) {
|
||
|
|
const id = e.target.dataset.id;
|
||
|
|
const { feeds } = await api("/api/feeds");
|
||
|
|
const feed = feeds.find((f) => f.id === id);
|
||
|
|
openModal(feed);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onDeleteFeed(e) {
|
||
|
|
const id = e.target.dataset.id;
|
||
|
|
if (!confirm(t("confirm_delete_feed"))) return;
|
||
|
|
await api(`/api/feeds/${id}`, { method: "DELETE" });
|
||
|
|
toast(t("feed_deleted"), "ok");
|
||
|
|
loadFeeds(); refreshStatus();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------- Feed modal ---------------- */
|
||
|
|
const modal = $("#modal");
|
||
|
|
const feedForm = $("#feed-form");
|
||
|
|
|
||
|
|
function openModal(feed) {
|
||
|
|
$("#modal-title").textContent = feed ? t("feed_edit") : t("feed_new");
|
||
|
|
$("#preview-result").innerHTML = "";
|
||
|
|
feedForm.reset();
|
||
|
|
const data = feed || { priority: 3, quiet_priority: 1, enabled: true };
|
||
|
|
for (const [k, v] of Object.entries(data)) {
|
||
|
|
const el = feedForm.elements[k];
|
||
|
|
if (!el) continue;
|
||
|
|
if (el.type === "checkbox") el.checked = !!v;
|
||
|
|
else el.value = v ?? "";
|
||
|
|
}
|
||
|
|
if (!feed) feedForm.elements["enabled"].checked = true;
|
||
|
|
modal.classList.remove("hidden");
|
||
|
|
}
|
||
|
|
function closeModal() { modal.classList.add("hidden"); }
|
||
|
|
|
||
|
|
$("#btn-add-feed").addEventListener("click", () => openModal(null));
|
||
|
|
$("#modal-close").addEventListener("click", closeModal);
|
||
|
|
modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });
|
||
|
|
|
||
|
|
feedForm.addEventListener("submit", async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
const payload = collectForm(feedForm);
|
||
|
|
const id = payload.id;
|
||
|
|
delete payload.id;
|
||
|
|
try {
|
||
|
|
if (id) await api(`/api/feeds/${id}`, { method: "PUT", body: JSON.stringify(payload) });
|
||
|
|
else await api("/api/feeds", { method: "POST", body: JSON.stringify(payload) });
|
||
|
|
toast(t("feed_saved"), "ok");
|
||
|
|
closeModal(); loadFeeds(); refreshStatus();
|
||
|
|
} catch (err) { toast(t("err_prefix") + err.message, "err"); }
|
||
|
|
});
|
||
|
|
|
||
|
|
$("#btn-preview").addEventListener("click", async () => {
|
||
|
|
const url = feedForm.elements["url"].value.trim();
|
||
|
|
const box = $("#preview-result");
|
||
|
|
if (!url) { box.innerHTML = `<span class="err">${escapeHtml(t("need_url"))}</span>`; return; }
|
||
|
|
box.textContent = t("checking");
|
||
|
|
try {
|
||
|
|
const r = await api("/api/feeds/preview", { method: "POST", body: JSON.stringify({ url }) });
|
||
|
|
if (r.error && !r.entries.length) {
|
||
|
|
box.innerHTML = `<span class="err">${escapeHtml(t("err_prefix") + r.error)}</span>`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const items = r.entries.map((it) => `<li>${escapeHtml(it.title)}</li>`).join("");
|
||
|
|
box.innerHTML = `<span class="ok">${escapeHtml(t("preview_ok"))}</span> «${escapeHtml(r.title)}», ` +
|
||
|
|
`${escapeHtml(t("preview_entries"))} ${r.count}<ul>${items}</ul>`;
|
||
|
|
} catch (err) { box.innerHTML = `<span class="err">${escapeHtml(t("err_prefix") + err.message)}</span>`; }
|
||
|
|
});
|
||
|
|
|
||
|
|
function collectForm(form) {
|
||
|
|
const out = {};
|
||
|
|
for (const el of form.elements) {
|
||
|
|
if (!el.name) continue;
|
||
|
|
if (el.type === "checkbox") out[el.name] = el.checked;
|
||
|
|
else if (el.type === "number") out[el.name] = el.value === "" ? null : Number(el.value);
|
||
|
|
else out[el.name] = el.value;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------- Settings ---------------- */
|
||
|
|
async function loadSettings() {
|
||
|
|
const s = await api("/api/settings");
|
||
|
|
const form = $("#settings-form");
|
||
|
|
for (const [k, v] of Object.entries(s)) {
|
||
|
|
const el = form.elements[k];
|
||
|
|
if (!el) continue;
|
||
|
|
if (el.type === "checkbox") el.checked = !!v;
|
||
|
|
else el.value = v ?? "";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$("#settings-form").addEventListener("submit", async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
const payload = collectForm(e.target);
|
||
|
|
try {
|
||
|
|
await api("/api/settings", { method: "PUT", body: JSON.stringify(payload) });
|
||
|
|
toast(t("saved"), "ok");
|
||
|
|
refreshStatus();
|
||
|
|
} catch (err) { toast(t("err_prefix") + err.message, "err"); }
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------------- Init ---------------- */
|
||
|
|
(async () => {
|
||
|
|
try {
|
||
|
|
const s = await api("/api/settings");
|
||
|
|
if (s.language && s.language !== LANG) {
|
||
|
|
setLang(s.language);
|
||
|
|
langSel.value = s.language;
|
||
|
|
}
|
||
|
|
} catch (_) { /* ignore */ }
|
||
|
|
refreshStatus();
|
||
|
|
refreshLogs();
|
||
|
|
})();
|
||
|
|
|
||
|
|
setInterval(refreshStatus, 5000);
|
||
|
|
setInterval(refreshLogs, 5000);
|