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,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}RSS → ntfy{% endblock %}</title>
|
||||
<script>
|
||||
// Apply saved theme/lang before paint to avoid a flash.
|
||||
(function () {
|
||||
try {
|
||||
var th = localStorage.getItem("theme") === "light" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", th);
|
||||
var l = localStorage.getItem("lang");
|
||||
if (l !== "ru" && l !== "en")
|
||||
l = (navigator.language || "en").indexOf("ru") === 0 ? "ru" : "en";
|
||||
document.documentElement.lang = l;
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
<script src="/static/i18n.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,254 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}RSS → ntfy{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="logo">📡</span> RSS → ntfy</div>
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="feeds" data-i18n="nav.feeds">Ленты</button>
|
||||
<button class="tab" data-tab="history" data-i18n="nav.history">История</button>
|
||||
<button class="tab admin-only" data-tab="users" data-i18n="nav.users">Пользователи</button>
|
||||
<button class="tab admin-only" data-tab="settings" data-i18n="nav.settings">Настройки</button>
|
||||
</nav>
|
||||
<div class="topbar-actions">
|
||||
<span id="whoami" class="muted"></span>
|
||||
<select id="lang-select" class="lang-select">
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<button class="icon-btn" id="theme-btn" data-i18n-title="theme.toggle">🌓</button>
|
||||
<a class="btn ghost" href="/logout" id="logout-btn" data-i18n="topbar.logout" style="display:none">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<!-- ===================== FEEDS ===================== -->
|
||||
<section id="tab-feeds" class="tab-panel active">
|
||||
<div id="stats" class="stats"></div>
|
||||
<div id="chart-wrap" class="chart-card hidden">
|
||||
<div class="chart-head">
|
||||
<span data-i18n="chart.title">Активность за 14 дней</span>
|
||||
<span class="chart-legend">
|
||||
<i class="lg sent"></i><span data-i18n="chart.sent">Отправлено</span>
|
||||
<i class="lg failed"></i><span data-i18n="chart.failed">Сбои</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
<div class="panel-head">
|
||||
<h2 data-i18n="feeds.heading">RSS-ленты</h2>
|
||||
<div class="panel-head-actions">
|
||||
<button class="btn ghost" id="check-all" data-i18n="feeds.checkAll">↻ Проверить все</button>
|
||||
<button class="btn ghost admin-only" id="import-btn" data-i18n="feeds.import">⬆ Импорт OPML</button>
|
||||
<button class="btn ghost" id="export-btn" data-i18n="feeds.export">⬇ Экспорт OPML</button>
|
||||
<button class="btn primary admin-only" id="add-feed" data-i18n="feeds.add">+ Добавить ленту</button>
|
||||
<input type="file" id="opml-file" accept=".opml,.xml,text/xml" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<div id="feeds-list" class="cards"></div>
|
||||
<div id="feeds-empty" class="empty hidden">
|
||||
<div class="empty-icon">🗞️</div>
|
||||
<p data-i18n="feeds.empty"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== HISTORY ===================== -->
|
||||
<section id="tab-history" class="tab-panel">
|
||||
<div class="panel-head">
|
||||
<h2 data-i18n="history.heading">История уведомлений</h2>
|
||||
<div class="panel-head-actions">
|
||||
<button class="btn ghost" id="history-refresh" data-i18n="history.refresh">↻ Обновить</button>
|
||||
<button class="btn danger admin-only" id="history-clear" data-i18n="history.clear">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-toolbar">
|
||||
<input type="search" id="history-search" data-i18n-ph="history.search" placeholder="Поиск…">
|
||||
<label class="check-inline"><input type="checkbox" id="history-errors">
|
||||
<span data-i18n="history.onlyErrors">Только ошибки</span></label>
|
||||
</div>
|
||||
<div id="history-list" class="history"></div>
|
||||
<div id="history-empty" class="empty hidden">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p data-i18n="history.empty">История пуста.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== USERS ===================== -->
|
||||
<section id="tab-users" class="tab-panel">
|
||||
<div class="panel-head">
|
||||
<h2 data-i18n="users.heading">Пользователи</h2>
|
||||
<button class="btn primary" id="add-user" data-i18n="users.add">+ Добавить пользователя</button>
|
||||
</div>
|
||||
<div id="users-list" class="cards"></div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== SETTINGS ===================== -->
|
||||
<section id="tab-settings" class="tab-panel">
|
||||
<div class="panel-head"><h2 data-i18n="settings.heading">Настройки</h2></div>
|
||||
|
||||
<form id="settings-form" class="settings-card">
|
||||
<h3 data-i18n="settings.ntfy">ntfy</h3>
|
||||
<label><span data-i18n="settings.defaultServer">Сервер ntfy по умолчанию</span>
|
||||
<input type="text" name="default_ntfy_server" placeholder="https://ntfy.sh">
|
||||
<small class="muted" data-i18n="settings.defaultServerHint"></small>
|
||||
</label>
|
||||
<div class="inline-test">
|
||||
<input type="text" id="test-topic" data-i18n-ph="settings.testPh">
|
||||
<button type="button" class="btn ghost" id="test-btn" data-i18n="settings.testBtn">Отправить тест</button>
|
||||
</div>
|
||||
|
||||
<h3 data-i18n="settings.check">Проверка</h3>
|
||||
<label><span data-i18n="settings.interval">Интервал проверки по умолчанию (минуты)</span>
|
||||
<input type="number" name="check_interval" min="1" value="5">
|
||||
<small class="muted" data-i18n="settings.intervalHint"></small>
|
||||
</label>
|
||||
|
||||
<h3 data-i18n="settings.telegram">Telegram</h3>
|
||||
<label class="switch-row"><span data-i18n="settings.tgEnable">Включить доставку в Telegram</span>
|
||||
<input type="checkbox" name="telegram_enabled" class="switch"></label>
|
||||
<div class="grid-2">
|
||||
<label><span data-i18n="settings.tgToken">Bot Token</span>
|
||||
<input type="text" name="telegram_token" placeholder="123456:ABC-..."></label>
|
||||
<label><span data-i18n="settings.tgChat">Chat ID</span>
|
||||
<input type="text" name="telegram_chat_id" placeholder="-1001234567890"></label>
|
||||
</div>
|
||||
<small class="muted" data-i18n="settings.tgHint"></small>
|
||||
|
||||
<h3 data-i18n="settings.webhook">Webhook</h3>
|
||||
<label class="switch-row"><span data-i18n="settings.whEnable">Включить доставку через webhook</span>
|
||||
<input type="checkbox" name="webhook_enabled" class="switch"></label>
|
||||
<label><span data-i18n="settings.whUrl">URL webhook</span>
|
||||
<input type="text" name="webhook_url" placeholder="https://example.com/hook">
|
||||
<small class="muted" data-i18n="settings.whHint"></small></label>
|
||||
|
||||
<h3 data-i18n="settings.alerts">Оповещения администратора</h3>
|
||||
<label class="switch-row"><span data-i18n="settings.alertEnable">Уведомлять, если лента «упала»</span>
|
||||
<input type="checkbox" name="alerts_enabled" class="switch"></label>
|
||||
<div class="grid-2">
|
||||
<label><span data-i18n="settings.alertTopic">Тема ntfy для алертов</span>
|
||||
<input type="text" name="alert_topic" placeholder="rss-alerts"></label>
|
||||
<label><span data-i18n="settings.alertThreshold">Порог (ошибок подряд)</span>
|
||||
<input type="number" name="alert_threshold" min="1" value="3"></label>
|
||||
</div>
|
||||
|
||||
<h3 data-i18n="settings.auth">Авторизация</h3>
|
||||
<label class="switch-row"><span data-i18n="settings.authRequire">Требовать вход в веб-панель</span>
|
||||
<input type="checkbox" name="auth_enabled" class="switch"></label>
|
||||
<small class="muted" data-i18n="settings.authHint"></small>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn primary" data-i18n="settings.save">Сохранить настройки</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ===================== FEED MODAL ===================== -->
|
||||
<div id="modal" class="modal-backdrop hidden">
|
||||
<form class="modal" id="feed-form">
|
||||
<div class="modal-head">
|
||||
<h3 id="modal-title" data-i18n="modal.addFeed">Добавить ленту</h3>
|
||||
<button type="button" class="icon-btn" id="modal-close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id">
|
||||
<label><span data-i18n="feed.url">URL ленты *</span>
|
||||
<input type="url" name="url" placeholder="https://example.com/feed.xml" required>
|
||||
</label>
|
||||
<label><span data-i18n="feed.title">Название</span> <small class="muted" data-i18n="feed.titleOpt"></small>
|
||||
<input type="text" name="title">
|
||||
</label>
|
||||
<div class="grid-2">
|
||||
<label><span data-i18n="feed.server">Сервер ntfy</span> <small class="muted" data-i18n="feed.serverHint"></small>
|
||||
<input type="text" name="ntfy_server" placeholder="https://ntfy.sh"></label>
|
||||
<label><span data-i18n="feed.topic">Тема ntfy</span>
|
||||
<input type="text" name="ntfy_topic" placeholder="my-news"></label>
|
||||
</div>
|
||||
|
||||
<details class="adv">
|
||||
<summary data-i18n="feed.priv">Приватный ntfy-сервер (авторизация)</summary>
|
||||
<label><span data-i18n="feed.token">Access token</span> <small class="muted" data-i18n="feed.tokenHint"></small>
|
||||
<input type="text" name="ntfy_token" placeholder="tk_..."></label>
|
||||
<div class="grid-2">
|
||||
<label><span data-i18n="feed.login">Логин</span>
|
||||
<input type="text" name="ntfy_username" autocomplete="off"></label>
|
||||
<label><span data-i18n="feed.password">Пароль</span>
|
||||
<input type="password" name="ntfy_password" autocomplete="new-password"></label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="grid-2">
|
||||
<label><span data-i18n="feed.priority">Приоритет</span>
|
||||
<select name="priority">
|
||||
<option value="1" data-i18n="feed.p1"></option>
|
||||
<option value="2" data-i18n="feed.p2"></option>
|
||||
<option value="3" selected data-i18n="feed.p3"></option>
|
||||
<option value="4" data-i18n="feed.p4"></option>
|
||||
<option value="5" data-i18n="feed.p5"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small>
|
||||
<input type="number" name="interval" min="0" value="0"></label>
|
||||
</div>
|
||||
<label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small>
|
||||
<input type="text" name="tags" placeholder="newspaper,fire"></label>
|
||||
|
||||
<div class="grid-2">
|
||||
<label><span data-i18n="feed.filterInc">Фильтр: только с этими словами</span> <small class="muted" data-i18n="feed.commaHint"></small>
|
||||
<input type="text" name="filter_include" placeholder="python, ai"></label>
|
||||
<label><span data-i18n="feed.filterExc">Фильтр: исключить слова</span> <small class="muted" data-i18n="feed.commaHint"></small>
|
||||
<input type="text" name="filter_exclude" placeholder="sponsored"></label>
|
||||
</div>
|
||||
|
||||
<div class="switch-grid">
|
||||
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
|
||||
<input type="checkbox" name="attach_image" class="switch" checked></label>
|
||||
<label class="switch-row"><span data-i18n="feed.dupTg">Дублировать в Telegram</span>
|
||||
<input type="checkbox" name="to_telegram" class="switch"></label>
|
||||
<label class="switch-row"><span data-i18n="feed.toWebhook">Отправлять в webhook</span>
|
||||
<input type="checkbox" name="to_webhook" class="switch"></label>
|
||||
<label class="switch-row"><span data-i18n="feed.enabled">Лента включена</span>
|
||||
<input type="checkbox" name="enabled" class="switch" checked></label>
|
||||
</div>
|
||||
|
||||
<div class="preview-block">
|
||||
<button type="button" class="btn ghost small" id="preview-btn" data-i18n="feed.preview">👁 Предпросмотр</button>
|
||||
<div id="preview-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" class="btn ghost" id="modal-cancel" data-i18n="modal.cancel">Отмена</button>
|
||||
<button type="submit" class="btn primary" data-i18n="modal.save">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ===================== USER MODAL ===================== -->
|
||||
<div id="user-modal" class="modal-backdrop hidden">
|
||||
<form class="modal" id="user-form">
|
||||
<div class="modal-head">
|
||||
<h3 id="user-modal-title" data-i18n="user.addTitle">Добавить пользователя</h3>
|
||||
<button type="button" class="icon-btn" id="user-modal-close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id">
|
||||
<label><span data-i18n="user.login">Логин *</span>
|
||||
<input type="text" name="username" autocomplete="off" required></label>
|
||||
<label><span data-i18n="user.password">Пароль</span> <small class="muted" id="pw-hint"></small>
|
||||
<input type="password" name="password" autocomplete="new-password"></label>
|
||||
<label><span data-i18n="user.role">Роль</span>
|
||||
<select name="role">
|
||||
<option value="admin" data-i18n="user.roleAdmin"></option>
|
||||
<option value="viewer" data-i18n="user.roleViewer"></option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" class="btn ghost" id="user-modal-cancel" data-i18n="modal.cancel">Отмена</button>
|
||||
<button type="submit" class="btn primary" data-i18n="modal.save">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/app.js"></script>{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}RSS → ntfy{% endblock %}
|
||||
{% block body %}
|
||||
<div class="login-wrap">
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<div class="login-logo">📡</div>
|
||||
<h1>RSS → ntfy</h1>
|
||||
<p class="muted" data-i18n="login.subtitle">Войдите, чтобы продолжить</p>
|
||||
{% if error %}<div class="alert error" data-i18n="login.error">{{ error }}</div>{% endif %}
|
||||
<label><span data-i18n="login.user">Логин</span>
|
||||
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label><span data-i18n="login.pass">Пароль</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit" class="btn primary block" data-i18n="login.submit">Войти</button>
|
||||
<div class="login-controls">
|
||||
<button type="button" class="icon-btn" id="theme-btn" data-i18n-title="theme.toggle">🌓</button>
|
||||
<select id="lang-select" class="lang-select">
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
applyI18n();
|
||||
const ls = document.getElementById("lang-select");
|
||||
ls.value = getLang();
|
||||
ls.onchange = () => { setLang(ls.value); applyI18n(); };
|
||||
document.getElementById("theme-btn").onclick = () =>
|
||||
setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user