RSS → ntfy bridge with modern web UI
build-and-push / docker (push) Has been cancelled

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:
dimon
2026-06-02 21:11:57 +08:00
commit bf52bc3079
28 changed files with 3396 additions and 0 deletions
+481
View File
@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtDate(iso) {
if (!iso) return t("feeds.never");
return new Date(iso).toLocaleString(localeTag(),
{ day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
}
// Localize a status code emitted by the backend (e.g. "sent:3:1").
function formatStatus(code) {
if (!code) return t("status.dash");
const i = code.indexOf(":");
const head = i === -1 ? code : code.slice(0, i);
const rest = i === -1 ? "" : code.slice(i + 1);
switch (head) {
case "init": return t("status.init", { n: rest });
case "sent": {
const [n, s] = rest.split(":");
return s ? t("status.sentSkip", { n, s }) : t("status.sent", { n });
}
case "filtered": return t("status.filtered", { s: rest });
case "nochange": return t("status.nochange");
case "parse_error": return t("status.parseError", { msg: rest });
case "send_error": return t("status.sendError", { msg: rest });
default: return code;
}
}
function isErrorStatus(code) {
return /^(parse_error|send_error)/.test(code || "");
}
// ---------- Stats + chart ----------
async function loadStats() {
try {
const s = await api("GET", "/api/stats");
$("#stats").innerHTML = `
<div class="stat"><b>${s.feeds_total}</b><span>${t("stats.feeds")}</span></div>
<div class="stat"><b>${s.feeds_enabled}</b><span>${t("stats.enabled")}</span></div>
<div class="stat ${s.feeds_failing ? "warn" : ""}"><b>${s.feeds_failing}</b><span>${t("stats.failing")}</span></div>
<div class="stat"><b>${s.notifications_sent}</b><span>${t("stats.sent")}</span></div>
<div class="stat ${s.notifications_failed ? "warn" : ""}"><b>${s.notifications_failed}</b><span>${t("stats.failed")}</span></div>`;
} catch (_) {}
}
async function loadActivity() {
let data;
try { data = await api("GET", "/api/stats/activity?days=14"); } catch { return; }
const total = data.reduce((a, d) => a + d.sent + d.failed, 0);
const wrap = $("#chart-wrap");
if (!total) { wrap.classList.add("hidden"); return; }
wrap.classList.remove("hidden");
const max = Math.max(1, ...data.map(d => d.sent + d.failed));
const W = 100, H = 38, n = data.length, gap = 1.2;
const bw = (W - gap * (n - 1)) / n;
let bars = "";
data.forEach((d, i) => {
const x = i * (bw + gap);
const sentH = (d.sent / max) * H;
const failH = (d.failed / max) * H;
const day = new Date(d.date + "T00:00").toLocaleDateString(localeTag(), { day: "2-digit", month: "short" });
const title = `${day}: ${t("chart.sent")} ${d.sent}, ${t("chart.failed")} ${d.failed}`;
bars += `<g><title>${escapeHtml(title)}</title>`;
bars += `<rect class="bar-sent" x="${x.toFixed(2)}" y="${(H - sentH).toFixed(2)}" width="${bw.toFixed(2)}" height="${sentH.toFixed(2)}" rx="0.4"/>`;
if (failH > 0)
bars += `<rect class="bar-fail" x="${x.toFixed(2)}" y="${(H - sentH - failH).toFixed(2)}" width="${bw.toFixed(2)}" height="${failH.toFixed(2)}" rx="0.4"/>`;
bars += `</g>`;
});
$("#chart").innerHTML =
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart-svg">${bars}</svg>`;
}
// ---------- 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>
&nbsp;·&nbsp; ${fmtDate(f.last_checked)}
</div>`;
$('[data-act="check"]', el).onclick = (e) => checkFeed(f, e.currentTarget);
if (admin) {
$('[data-act="edit"]', el).onclick = () => openModal(f);
$('[data-act="del"]', el).onclick = () => deleteFeed(f);
}
return el;
}
async function loadFeeds() {
const feeds = await api("GET", "/api/feeds");
const list = $("#feeds-list");
list.innerHTML = "";
$("#feeds-empty").classList.toggle("hidden", feeds.length > 0);
feeds.forEach(f => list.appendChild(feedCard(f)));
loadStats();
loadActivity();
}
async function deleteFeed(f) {
if (!confirm(t("confirm.deleteFeed", { name: f.title || f.url }))) return;
await api("DELETE", `/api/feeds/${f.id}`);
toast(t("toast.feedDeleted"));
loadFeeds();
}
async function checkFeed(f, btn) {
const old = btn.textContent;
btn.textContent = "…"; btn.disabled = true;
try {
const r = await api("POST", `/api/feeds/${f.id}/check`);
toast(formatStatus(r.status), isErrorStatus(r.status) ? "err" : "ok");
loadFeeds();
} catch (e) { toast(e.message, "err"); }
finally { btn.textContent = old; btn.disabled = false; }
}
// ---------- Feed modal ----------
const modal = $("#modal");
const feedForm = $("#feed-form");
function openModal(feed) {
feedForm.reset();
$("#preview-area").innerHTML = "";
$("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed");
feedForm.id.value = feed?.id || "";
const 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();
+336
View File
@@ -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";
}
+318
View File
@@ -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; }