Features: feed CRUD, per-feed ntfy target (incl. private servers), Telegram/webhook channels, keyword filters, image attachments, per-feed intervals, OPML import/export, notification history & stats, users with roles, admin alerts, RU/EN i18n, light/dark theme, notification preview, history search, activity chart. Dockerized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
"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 = `
|
||||
<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>`;
|
||||
}
|
||||
|
||||
// ---------- 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>`);
|
||||
|
||||
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 || "";
|
||||
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 = `<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,
|
||||
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 => `<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() {
|
||||
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(),
|
||||
check_interval: parseInt(sForm.check_interval.value, 10),
|
||||
auth_enabled: sForm.auth_enabled.checked,
|
||||
telegram_enabled: sForm.telegram_enabled.checked,
|
||||
telegram_token: sForm.telegram_token.value.trim(),
|
||||
telegram_chat_id: sForm.telegram_chat_id.value.trim(),
|
||||
webhook_enabled: sForm.webhook_enabled.checked,
|
||||
webhook_url: sForm.webhook_url.value.trim(),
|
||||
alerts_enabled: sForm.alerts_enabled.checked,
|
||||
alert_topic: sForm.alert_topic.value.trim(),
|
||||
alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3,
|
||||
};
|
||||
try {
|
||||
await api("PUT", "/api/settings", payload);
|
||||
toast(t("toast.settingsSaved"));
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
});
|
||||
|
||||
$("#test-btn").onclick = async () => {
|
||||
const topic = $("#test-topic").value.trim();
|
||||
if (!topic) { toast(t("toast.needTestTopic"), "err"); return; }
|
||||
try {
|
||||
const r = await api("POST", "/api/test", {
|
||||
server: sForm.default_ntfy_server.value.trim(), topic,
|
||||
});
|
||||
toast(t("toast.sentTo", { dest: r.sent_to }));
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
};
|
||||
|
||||
// ---------- Tabs ----------
|
||||
$$(".tab").forEach(tab => tab.addEventListener("click", () => {
|
||||
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||
$$(".tab-panel").forEach(p => p.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
$(`#tab-${tab.dataset.tab}`).classList.add("active");
|
||||
if (tab.dataset.tab === "history") loadHistory().catch(() => {});
|
||||
if (tab.dataset.tab === "users") loadUsers().catch(() => {});
|
||||
}));
|
||||
|
||||
// ---------- Theme + language ----------
|
||||
$("#theme-btn").onclick = () => setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||
|
||||
const langSelect = $("#lang-select");
|
||||
langSelect.value = getLang();
|
||||
langSelect.onchange = () => {
|
||||
setLang(langSelect.value);
|
||||
applyI18n();
|
||||
renderWhoami();
|
||||
// Re-render dynamic content in the new language.
|
||||
loadFeeds().catch(() => {});
|
||||
if ($("#tab-history").classList.contains("active")) loadHistory().catch(() => {});
|
||||
if ($("#tab-users").classList.contains("active")) loadUsers().catch(() => {});
|
||||
};
|
||||
|
||||
function renderWhoami() {
|
||||
if (ME.auth_enabled) {
|
||||
$("#whoami").textContent = `${ME.username} · ${ME.role === "admin" ? t("role.admin") : t("role.viewer")}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- init ----------
|
||||
async function init() {
|
||||
applyI18n();
|
||||
try { ME = await api("GET", "/api/me"); } catch (_) {}
|
||||
if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden"));
|
||||
if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); }
|
||||
loadFeeds().catch(e => toast(e.message, "err"));
|
||||
if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err"));
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,336 @@
|
||||
"use strict";
|
||||
/* Lightweight i18n: dictionaries + t() + applyI18n(). Shared by login & app. */
|
||||
|
||||
const I18N = {
|
||||
ru: {
|
||||
"nav.feeds": "Ленты",
|
||||
"nav.history": "История",
|
||||
"nav.users": "Пользователи",
|
||||
"nav.settings": "Настройки",
|
||||
"topbar.logout": "Выйти",
|
||||
"theme.toggle": "Сменить тему",
|
||||
|
||||
"stats.feeds": "лент",
|
||||
"stats.enabled": "активных",
|
||||
"stats.failing": "с ошибками",
|
||||
"stats.sent": "отправлено",
|
||||
"stats.failed": "сбоев",
|
||||
|
||||
"chart.title": "Активность за 14 дней",
|
||||
"chart.sent": "Отправлено",
|
||||
"chart.failed": "Сбои",
|
||||
"chart.empty": "Нет данных за период",
|
||||
|
||||
"feeds.heading": "RSS-ленты",
|
||||
"feeds.checkAll": "↻ Проверить все",
|
||||
"feeds.import": "⬆ Импорт OPML",
|
||||
"feeds.export": "⬇ Экспорт OPML",
|
||||
"feeds.add": "+ Добавить ленту",
|
||||
"feeds.empty": "Пока нет ни одной ленты. Добавьте первую, чтобы начать получать уведомления.",
|
||||
"feeds.never": "ещё не проверялась",
|
||||
"feeds.noTopic": "— тема не задана —",
|
||||
|
||||
"history.heading": "История уведомлений",
|
||||
"history.refresh": "↻ Обновить",
|
||||
"history.clear": "Очистить",
|
||||
"history.search": "Поиск по заголовку или ленте…",
|
||||
"history.onlyErrors": "Только ошибки",
|
||||
"history.empty": "История пуста.",
|
||||
|
||||
"users.heading": "Пользователи",
|
||||
"users.add": "+ Добавить пользователя",
|
||||
"users.admin": "👑 администратор",
|
||||
"users.viewer": "👁 наблюдатель",
|
||||
|
||||
"settings.heading": "Настройки",
|
||||
"settings.ntfy": "ntfy",
|
||||
"settings.defaultServer": "Сервер ntfy по умолчанию",
|
||||
"settings.defaultServerHint": "Используется для лент, у которых не задан собственный сервер.",
|
||||
"settings.testPh": "тема для теста, напр. my-news",
|
||||
"settings.testBtn": "Отправить тест",
|
||||
"settings.check": "Проверка",
|
||||
"settings.interval": "Интервал проверки по умолчанию (минуты)",
|
||||
"settings.intervalHint": "Можно переопределить для каждой ленты отдельно.",
|
||||
"settings.telegram": "Telegram",
|
||||
"settings.tgEnable": "Включить доставку в Telegram",
|
||||
"settings.tgToken": "Bot Token",
|
||||
"settings.tgChat": "Chat ID",
|
||||
"settings.tgHint": "Создайте бота через @BotFather, добавьте его в чат и укажите chat_id. Затем включите канал в нужных лентах.",
|
||||
"settings.webhook": "Webhook",
|
||||
"settings.whEnable": "Включить доставку через webhook",
|
||||
"settings.whUrl": "URL webhook",
|
||||
"settings.whHint": "POST с JSON: feed, feed_url, title, body, link, image.",
|
||||
"settings.alerts": "Оповещения администратора",
|
||||
"settings.alertEnable": "Уведомлять, если лента «упала»",
|
||||
"settings.alertTopic": "Тема ntfy для алертов",
|
||||
"settings.alertThreshold": "Порог (ошибок подряд)",
|
||||
"settings.auth": "Авторизация",
|
||||
"settings.authRequire": "Требовать вход в веб-панель",
|
||||
"settings.authHint": "Учётные записи управляются во вкладке «Пользователи».",
|
||||
"settings.save": "Сохранить настройки",
|
||||
|
||||
"modal.addFeed": "Добавить ленту",
|
||||
"modal.editFeed": "Редактировать ленту",
|
||||
"modal.cancel": "Отмена",
|
||||
"modal.save": "Сохранить",
|
||||
"feed.url": "URL ленты *",
|
||||
"feed.title": "Название",
|
||||
"feed.titleOpt": "(необязательно, определится автоматически)",
|
||||
"feed.server": "Сервер ntfy",
|
||||
"feed.serverHint": "(пусто = по умолчанию)",
|
||||
"feed.topic": "Тема ntfy",
|
||||
"feed.priv": "Приватный ntfy-сервер (авторизация)",
|
||||
"feed.token": "Access token",
|
||||
"feed.tokenHint": "(tk_…, приоритетнее логина)",
|
||||
"feed.login": "Логин",
|
||||
"feed.password": "Пароль",
|
||||
"feed.priority": "Приоритет",
|
||||
"feed.p1": "1 — минимальный",
|
||||
"feed.p2": "2 — низкий",
|
||||
"feed.p3": "3 — обычный",
|
||||
"feed.p4": "4 — высокий",
|
||||
"feed.p5": "5 — максимальный",
|
||||
"feed.intervalMin": "Интервал, мин",
|
||||
"feed.intervalHint": "(0 = общий)",
|
||||
"feed.tags": "Теги / эмодзи",
|
||||
"feed.commaHint": "(через запятую)",
|
||||
"feed.filterInc": "Фильтр: только с этими словами",
|
||||
"feed.filterExc": "Фильтр: исключить слова",
|
||||
"feed.attach": "Прикреплять картинку",
|
||||
"feed.dupTg": "Дублировать в Telegram",
|
||||
"feed.toWebhook": "Отправлять в webhook",
|
||||
"feed.enabled": "Лента включена",
|
||||
"feed.preview": "👁 Предпросмотр",
|
||||
"feed.previewLoading": "Загрузка…",
|
||||
"feed.previewHint": "Введите URL и нажмите «Предпросмотр», чтобы увидеть последнюю запись.",
|
||||
|
||||
"user.addTitle": "Добавить пользователя",
|
||||
"user.editTitle": "Редактировать пользователя",
|
||||
"user.login": "Логин *",
|
||||
"user.password": "Пароль",
|
||||
"user.pwReq": "*",
|
||||
"user.pwKeep": "(пусто = не менять)",
|
||||
"user.role": "Роль",
|
||||
"user.roleAdmin": "Администратор (полный доступ)",
|
||||
"user.roleViewer": "Наблюдатель (только просмотр)",
|
||||
|
||||
"toast.feedDeleted": "Лента удалена",
|
||||
"toast.feedAdded": "Лента добавлена",
|
||||
"toast.feedUpdated": "Лента обновлена",
|
||||
"toast.saved": "Сохранено",
|
||||
"toast.deleted": "Удалён",
|
||||
"toast.checkDone": "Проверка завершена",
|
||||
"toast.historyCleared": "История очищена",
|
||||
"toast.settingsSaved": "Настройки сохранены",
|
||||
"toast.sentTo": "Отправлено в {dest}",
|
||||
"toast.imported": "Импортировано {added} из {total}",
|
||||
"toast.needTestTopic": "Укажите тему для теста",
|
||||
"toast.needUrl": "Сначала укажите URL ленты",
|
||||
|
||||
"confirm.deleteFeed": "Удалить ленту «{name}»?",
|
||||
"confirm.deleteUser": "Удалить пользователя «{name}»?",
|
||||
"confirm.clearHistory": "Очистить всю историю?",
|
||||
|
||||
"status.init": "Инициализировано ({n} записей)",
|
||||
"status.sent": "Отправлено {n} новых",
|
||||
"status.sentSkip": "Отправлено {n} новых, пропущено {s}",
|
||||
"status.filtered": "Без изменений (отфильтровано {s})",
|
||||
"status.nochange": "Без изменений",
|
||||
"status.parseError": "Ошибка: {msg}",
|
||||
"status.sendError": "Ошибка отправки: {msg}",
|
||||
"status.dash": "—",
|
||||
|
||||
"role.admin": "админ",
|
||||
"role.viewer": "наблюдатель",
|
||||
"login.subtitle": "Войдите, чтобы продолжить",
|
||||
"login.user": "Логин",
|
||||
"login.pass": "Пароль",
|
||||
"login.submit": "Войти",
|
||||
"login.error": "Неверный логин или пароль",
|
||||
},
|
||||
|
||||
en: {
|
||||
"nav.feeds": "Feeds",
|
||||
"nav.history": "History",
|
||||
"nav.users": "Users",
|
||||
"nav.settings": "Settings",
|
||||
"topbar.logout": "Log out",
|
||||
"theme.toggle": "Toggle theme",
|
||||
|
||||
"stats.feeds": "feeds",
|
||||
"stats.enabled": "active",
|
||||
"stats.failing": "failing",
|
||||
"stats.sent": "sent",
|
||||
"stats.failed": "failed",
|
||||
|
||||
"chart.title": "Activity (last 14 days)",
|
||||
"chart.sent": "Sent",
|
||||
"chart.failed": "Failed",
|
||||
"chart.empty": "No data for this period",
|
||||
|
||||
"feeds.heading": "RSS feeds",
|
||||
"feeds.checkAll": "↻ Check all",
|
||||
"feeds.import": "⬆ Import OPML",
|
||||
"feeds.export": "⬇ Export OPML",
|
||||
"feeds.add": "+ Add feed",
|
||||
"feeds.empty": "No feeds yet. Add your first one to start receiving notifications.",
|
||||
"feeds.never": "not checked yet",
|
||||
"feeds.noTopic": "— no topic set —",
|
||||
|
||||
"history.heading": "Notification history",
|
||||
"history.refresh": "↻ Refresh",
|
||||
"history.clear": "Clear",
|
||||
"history.search": "Search by title or feed…",
|
||||
"history.onlyErrors": "Errors only",
|
||||
"history.empty": "History is empty.",
|
||||
|
||||
"users.heading": "Users",
|
||||
"users.add": "+ Add user",
|
||||
"users.admin": "👑 administrator",
|
||||
"users.viewer": "👁 viewer",
|
||||
|
||||
"settings.heading": "Settings",
|
||||
"settings.ntfy": "ntfy",
|
||||
"settings.defaultServer": "Default ntfy server",
|
||||
"settings.defaultServerHint": "Used for feeds that don't define their own server.",
|
||||
"settings.testPh": "topic to test, e.g. my-news",
|
||||
"settings.testBtn": "Send test",
|
||||
"settings.check": "Polling",
|
||||
"settings.interval": "Default poll interval (minutes)",
|
||||
"settings.intervalHint": "Can be overridden per feed.",
|
||||
"settings.telegram": "Telegram",
|
||||
"settings.tgEnable": "Enable Telegram delivery",
|
||||
"settings.tgToken": "Bot Token",
|
||||
"settings.tgChat": "Chat ID",
|
||||
"settings.tgHint": "Create a bot via @BotFather, add it to a chat and set the chat_id. Then enable the channel on the feeds you want.",
|
||||
"settings.webhook": "Webhook",
|
||||
"settings.whEnable": "Enable webhook delivery",
|
||||
"settings.whUrl": "Webhook URL",
|
||||
"settings.whHint": "POST with JSON: feed, feed_url, title, body, link, image.",
|
||||
"settings.alerts": "Admin alerts",
|
||||
"settings.alertEnable": "Notify when a feed keeps failing",
|
||||
"settings.alertTopic": "ntfy topic for alerts",
|
||||
"settings.alertThreshold": "Threshold (consecutive errors)",
|
||||
"settings.auth": "Authentication",
|
||||
"settings.authRequire": "Require login to the web panel",
|
||||
"settings.authHint": "Accounts are managed on the «Users» tab.",
|
||||
"settings.save": "Save settings",
|
||||
|
||||
"modal.addFeed": "Add feed",
|
||||
"modal.editFeed": "Edit feed",
|
||||
"modal.cancel": "Cancel",
|
||||
"modal.save": "Save",
|
||||
"feed.url": "Feed URL *",
|
||||
"feed.title": "Title",
|
||||
"feed.titleOpt": "(optional, detected automatically)",
|
||||
"feed.server": "ntfy server",
|
||||
"feed.serverHint": "(empty = default)",
|
||||
"feed.topic": "ntfy topic",
|
||||
"feed.priv": "Private ntfy server (authentication)",
|
||||
"feed.token": "Access token",
|
||||
"feed.tokenHint": "(tk_…, takes precedence over login)",
|
||||
"feed.login": "Username",
|
||||
"feed.password": "Password",
|
||||
"feed.priority": "Priority",
|
||||
"feed.p1": "1 — min",
|
||||
"feed.p2": "2 — low",
|
||||
"feed.p3": "3 — default",
|
||||
"feed.p4": "4 — high",
|
||||
"feed.p5": "5 — max",
|
||||
"feed.intervalMin": "Interval, min",
|
||||
"feed.intervalHint": "(0 = global)",
|
||||
"feed.tags": "Tags / emojis",
|
||||
"feed.commaHint": "(comma separated)",
|
||||
"feed.filterInc": "Filter: only with these words",
|
||||
"feed.filterExc": "Filter: exclude words",
|
||||
"feed.attach": "Attach image",
|
||||
"feed.dupTg": "Mirror to Telegram",
|
||||
"feed.toWebhook": "Send to webhook",
|
||||
"feed.enabled": "Feed enabled",
|
||||
"feed.preview": "👁 Preview",
|
||||
"feed.previewLoading": "Loading…",
|
||||
"feed.previewHint": "Enter a URL and click «Preview» to see the latest entry.",
|
||||
|
||||
"user.addTitle": "Add user",
|
||||
"user.editTitle": "Edit user",
|
||||
"user.login": "Username *",
|
||||
"user.password": "Password",
|
||||
"user.pwReq": "*",
|
||||
"user.pwKeep": "(empty = keep current)",
|
||||
"user.role": "Role",
|
||||
"user.roleAdmin": "Administrator (full access)",
|
||||
"user.roleViewer": "Viewer (read-only)",
|
||||
|
||||
"toast.feedDeleted": "Feed deleted",
|
||||
"toast.feedAdded": "Feed added",
|
||||
"toast.feedUpdated": "Feed updated",
|
||||
"toast.saved": "Saved",
|
||||
"toast.deleted": "Deleted",
|
||||
"toast.checkDone": "Check complete",
|
||||
"toast.historyCleared": "History cleared",
|
||||
"toast.settingsSaved": "Settings saved",
|
||||
"toast.sentTo": "Sent to {dest}",
|
||||
"toast.imported": "Imported {added} of {total}",
|
||||
"toast.needTestTopic": "Enter a topic to test",
|
||||
"toast.needUrl": "Enter the feed URL first",
|
||||
|
||||
"confirm.deleteFeed": "Delete feed «{name}»?",
|
||||
"confirm.deleteUser": "Delete user «{name}»?",
|
||||
"confirm.clearHistory": "Clear the entire history?",
|
||||
|
||||
"status.init": "Initialized ({n} entries)",
|
||||
"status.sent": "Sent {n} new",
|
||||
"status.sentSkip": "Sent {n} new, skipped {s}",
|
||||
"status.filtered": "No changes (filtered out {s})",
|
||||
"status.nochange": "No changes",
|
||||
"status.parseError": "Error: {msg}",
|
||||
"status.sendError": "Send error: {msg}",
|
||||
"status.dash": "—",
|
||||
|
||||
"role.admin": "admin",
|
||||
"role.viewer": "viewer",
|
||||
"login.subtitle": "Sign in to continue",
|
||||
"login.user": "Username",
|
||||
"login.pass": "Password",
|
||||
"login.submit": "Sign in",
|
||||
"login.error": "Wrong username or password",
|
||||
},
|
||||
};
|
||||
|
||||
function getLang() {
|
||||
const l = localStorage.getItem("lang");
|
||||
if (l === "ru" || l === "en") return l;
|
||||
return (navigator.language || "en").startsWith("ru") ? "ru" : "en";
|
||||
}
|
||||
function setLang(lang) {
|
||||
localStorage.setItem("lang", lang);
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
function t(key, params) {
|
||||
let s = (I18N[getLang()] || I18N.en)[key] ?? key;
|
||||
if (params) for (const k in params) s = s.replaceAll(`{${k}}`, params[k]);
|
||||
return s;
|
||||
}
|
||||
function applyI18n(root = document) {
|
||||
root.querySelectorAll("[data-i18n]").forEach(el => {
|
||||
el.textContent = t(el.getAttribute("data-i18n"));
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-ph]").forEach(el => {
|
||||
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-ph")));
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-title]").forEach(el => {
|
||||
el.setAttribute("title", t(el.getAttribute("data-i18n-title")));
|
||||
});
|
||||
}
|
||||
|
||||
/* Theme + locale helpers shared across pages. */
|
||||
function getTheme() {
|
||||
return localStorage.getItem("theme") === "light" ? "light" : "dark";
|
||||
}
|
||||
function setTheme(theme) {
|
||||
localStorage.setItem("theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
function localeTag() {
|
||||
return getLang() === "ru" ? "ru-RU" : "en-US";
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
:root {
|
||||
--bg: #0e1117;
|
||||
--bg-soft: #161b22;
|
||||
--bg-card: #1b2230;
|
||||
--border: #2a3343;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b97a8;
|
||||
--primary: #4f7cff;
|
||||
--primary-hover: #3d68ec;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--radius: 14px;
|
||||
--shadow: 0 8px 30px rgba(0, 0, 0, .35);
|
||||
--topbar-bg: rgba(22, 27, 34, .7);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f4f6fb;
|
||||
--bg-soft: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--border: #dde3ec;
|
||||
--text: #1b2230;
|
||||
--muted: #5d6b7e;
|
||||
--primary: #3d68ec;
|
||||
--primary-hover: #2f56d4;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warn: #d97706;
|
||||
--shadow: 0 6px 22px rgba(40, 60, 100, .12);
|
||||
--topbar-bg: rgba(255, 255, 255, .8);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 80% -10%, rgba(79, 124, 255, .14), transparent 60%),
|
||||
radial-gradient(900px 500px at -10% 10%, rgba(34, 197, 94, .08), transparent 55%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0; font-weight: 600; }
|
||||
.muted { color: var(--muted); font-weight: 400; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ---------- Topbar ---------- */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 14px 28px;
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 18px; display: flex; align-items: center; gap: 8px; }
|
||||
.logo { font-size: 22px; }
|
||||
.tabs { display: flex; gap: 6px; margin-left: 8px; }
|
||||
.tab {
|
||||
background: none; border: none; color: var(--muted);
|
||||
padding: 8px 16px; border-radius: 10px; cursor: pointer;
|
||||
font-size: 15px; font-weight: 500; font-family: inherit; transition: .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
|
||||
.tab.active { color: var(--text); background: rgba(79, 124, 255, .16); }
|
||||
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||||
#whoami { font-size: 13px; }
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 32px 24px 80px; }
|
||||
.tab-panel { display: none; animation: fade .25s ease; }
|
||||
.tab-panel.active { display: block; }
|
||||
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.panel-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 22px; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.panel-head h2 { font-size: 22px; }
|
||||
.panel-head-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
font-family: inherit; font-size: 14px; font-weight: 500;
|
||||
padding: 9px 16px; border-radius: 10px; border: 1px solid transparent;
|
||||
cursor: pointer; transition: .15s; text-decoration: none; display: inline-flex;
|
||||
align-items: center; gap: 6px; color: var(--text); background: var(--bg-soft);
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn:active { transform: none; }
|
||||
.btn.primary { background: var(--primary); color: #fff; }
|
||||
.btn.primary:hover { background: var(--primary-hover); }
|
||||
.btn.ghost { background: transparent; border-color: var(--border); }
|
||||
.btn.ghost:hover { background: rgba(255, 255, 255, .05); }
|
||||
.btn.danger { background: transparent; border-color: rgba(239, 68, 68, .4); color: #ff9a9a; }
|
||||
.btn.danger:hover { background: rgba(239, 68, 68, .12); }
|
||||
.btn.block { width: 100%; justify-content: center; }
|
||||
.btn.small { padding: 6px 12px; font-size: 13px; }
|
||||
.icon-btn {
|
||||
background: none; border: none; color: var(--muted); font-size: 18px;
|
||||
cursor: pointer; padding: 4px 8px; border-radius: 8px;
|
||||
}
|
||||
.icon-btn:hover { color: var(--text); background: rgba(255, 255, 255, .06); }
|
||||
|
||||
/* ---------- Cards (feeds) ---------- */
|
||||
.cards { display: grid; gap: 14px; }
|
||||
.feed-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.feed-card.disabled { opacity: .55; }
|
||||
.feed-top { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.feed-title { font-weight: 600; font-size: 16px; word-break: break-word; }
|
||||
.feed-url { font-size: 12.5px; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
||||
.feed-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.chip {
|
||||
font-size: 12px; padding: 3px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .06); color: var(--muted);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.chip.topic { background: rgba(79, 124, 255, .16); color: #aebfff; }
|
||||
.chip.tg { background: rgba(34, 158, 217, .18); color: #7fd0f0; }
|
||||
.feed-status { font-size: 12.5px; color: var(--muted); }
|
||||
.feed-status .ok { color: var(--success); }
|
||||
.feed-status .err { color: var(--danger); }
|
||||
.feed-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
|
||||
/* badge dot */
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
|
||||
.dot.on { background: var(--success); box-shadow: 0 0 8px rgba(34, 197, 94, .6); }
|
||||
.dot.off { background: var(--muted); }
|
||||
|
||||
/* ---------- Empty ---------- */
|
||||
.empty { text-align: center; padding: 70px 20px; color: var(--muted); }
|
||||
.empty-icon { font-size: 52px; margin-bottom: 12px; }
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
label { display: block; font-size: 13.5px; font-weight: 500; margin-bottom: 14px; }
|
||||
label small { font-weight: 400; }
|
||||
input[type=text], input[type=url], input[type=number], input[type=password], select {
|
||||
width: 100%; margin-top: 6px; padding: 10px 12px;
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-radius: 10px; color: var(--text); font-family: inherit; font-size: 14px;
|
||||
transition: .15s;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79, 124, 255, .18); }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
|
||||
@media (max-width: 560px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.settings-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 26px; box-shadow: var(--shadow);
|
||||
}
|
||||
.settings-card h3 {
|
||||
font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); margin: 22px 0 14px; padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.settings-card h3:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
.form-actions { margin-top: 24px; display: flex; justify-content: flex-end; }
|
||||
.inline-test { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||
.inline-test input { margin-top: 0; }
|
||||
.auth-fields { padding-left: 2px; }
|
||||
|
||||
/* switch */
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.switch { appearance: none; width: 44px; height: 24px; border-radius: 999px;
|
||||
background: var(--border); position: relative; cursor: pointer; transition: .2s; margin: 0; flex-shrink: 0; }
|
||||
.switch::after { content: ''; position: absolute; width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #fff; top: 3px; left: 3px; transition: .2s; }
|
||||
.switch:checked { background: var(--primary); }
|
||||
.switch:checked::after { left: 23px; }
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, .6);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center;
|
||||
justify-content: center; z-index: 50; padding: 20px; animation: fade .15s;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 18px; width: 100%; max-width: 540px; box-shadow: var(--shadow);
|
||||
max-height: 90vh; display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--border); }
|
||||
.modal-body { padding: 22px 24px; overflow-y: auto; }
|
||||
.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ---------- Login ---------- */
|
||||
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.login-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 18px; padding: 38px 34px; width: 100%; max-width: 380px;
|
||||
box-shadow: var(--shadow); text-align: center;
|
||||
}
|
||||
.login-logo { font-size: 46px; margin-bottom: 10px; }
|
||||
.login-card h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.login-card p { margin: 0 0 22px; }
|
||||
.login-card label { text-align: left; }
|
||||
.login-card .btn { margin-top: 8px; }
|
||||
|
||||
/* ---------- Alerts / toast ---------- */
|
||||
.alert { padding: 10px 14px; border-radius: 10px; font-size: 13.5px; margin-bottom: 16px; text-align: left; }
|
||||
.alert.error { background: rgba(239, 68, 68, .14); color: #ffb4b4; border: 1px solid rgba(239, 68, 68, .3); }
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||
background: var(--bg-card); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 12px 20px; border-radius: 12px; box-shadow: var(--shadow);
|
||||
font-size: 14px; opacity: 0; transition: .25s; z-index: 100; max-width: 90vw;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.toast.ok { border-color: rgba(34, 197, 94, .5); }
|
||||
.toast.err { border-color: rgba(239, 68, 68, .5); }
|
||||
|
||||
/* ---------- Stats ---------- */
|
||||
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; }
|
||||
.stat {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 12px 18px; min-width: 92px; text-align: center;
|
||||
}
|
||||
.stat b { display: block; font-size: 24px; font-weight: 700; }
|
||||
.stat span { font-size: 12px; color: var(--muted); }
|
||||
.stat.warn b { color: var(--warn); }
|
||||
|
||||
/* ---------- Details / advanced ---------- */
|
||||
details.adv {
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 4px 14px; margin-bottom: 14px; background: rgba(255, 255, 255, .02);
|
||||
}
|
||||
details.adv summary {
|
||||
cursor: pointer; font-size: 13.5px; font-weight: 500; padding: 8px 0;
|
||||
color: var(--muted); list-style: none;
|
||||
}
|
||||
details.adv summary::-webkit-details-marker { display: none; }
|
||||
details.adv summary::before { content: "▸ "; }
|
||||
details.adv[open] summary::before { content: "▾ "; }
|
||||
details.adv[open] { padding-bottom: 8px; }
|
||||
|
||||
.switch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; margin-top: 4px; }
|
||||
@media (max-width: 560px) { .switch-grid { grid-template-columns: 1fr; } }
|
||||
.switch-grid .switch-row { margin-bottom: 10px; }
|
||||
|
||||
/* ---------- History ---------- */
|
||||
.history { display: flex; flex-direction: column; gap: 8px; }
|
||||
.history-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--success); border-radius: 10px; padding: 12px 16px;
|
||||
}
|
||||
.history-row.err { border-left-color: var(--danger); }
|
||||
.history-icon { font-size: 16px; }
|
||||
.history-main { flex: 1; min-width: 0; }
|
||||
.history-title { font-weight: 500; font-size: 14.5px; word-break: break-word; }
|
||||
.history-title a { color: var(--text); text-decoration: none; }
|
||||
.history-title a:hover { color: var(--primary); text-decoration: underline; }
|
||||
.history-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; font-size: 12.5px; }
|
||||
.history-sub .err { color: #ff9a9a; }
|
||||
.history-time { font-size: 12px; white-space: nowrap; }
|
||||
|
||||
/* ---------- Language select / topbar controls ---------- */
|
||||
.lang-select {
|
||||
width: auto; margin: 0; padding: 6px 8px; font-size: 13px;
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.login-controls { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 18px; }
|
||||
|
||||
/* ---------- Activity chart ---------- */
|
||||
.chart-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px 18px 12px; margin-bottom: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.chart-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; color: var(--muted); margin-bottom: 10px; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.chart-legend { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.chart-legend .lg { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||
.chart-legend .lg.sent { background: var(--success); }
|
||||
.chart-legend .lg.failed { background: var(--danger); margin-left: 8px; }
|
||||
#chart { width: 100%; }
|
||||
.chart-svg { width: 100%; height: 90px; display: block; }
|
||||
.chart-svg .bar-sent { fill: var(--success); }
|
||||
.chart-svg .bar-fail { fill: var(--danger); }
|
||||
.chart-svg rect { transition: opacity .15s; }
|
||||
.chart-svg g:hover rect { opacity: .75; }
|
||||
|
||||
/* ---------- History toolbar ---------- */
|
||||
.history-toolbar { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.history-toolbar input[type=search] { flex: 1; min-width: 200px; margin: 0; }
|
||||
.check-inline { display: inline-flex; align-items: center; gap: 7px; margin: 0; font-size: 13.5px; white-space: nowrap; cursor: pointer; }
|
||||
.check-inline input { width: 16px; height: 16px; margin: 0; accent-color: var(--primary); }
|
||||
|
||||
/* ---------- Notification preview ---------- */
|
||||
.preview-block { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
#preview-area { margin-top: 12px; }
|
||||
.ntfy-preview {
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--primary); border-radius: 10px; padding: 12px 14px;
|
||||
}
|
||||
.np-head { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.np-title { font-weight: 600; font-size: 15px; margin-bottom: 6px; word-break: break-word; }
|
||||
.np-body { font-size: 13.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
||||
.ntfy-preview img { max-width: 100%; border-radius: 8px; margin-top: 10px; display: block; }
|
||||
Reference in New Issue
Block a user