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
+254
View File
@@ -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&nbsp;&nbsp;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 %}