feat(v2): Atom-Feed-Konfig-Seite + Eigene-Abos-Verwaltung

Backend (Filter sind seit jeher da):
- /api/feed.xml?bundesland=&partei=&limit=
- /api/subscriptions GET/POST/DELETE

UI:
- /v2/feed: Form mit BL/Partei/Limit, generiert Feed-URL live, Buttons Oeffnen/
  URL-Kopieren/In-Feedly. Default-BL aus Header-Selektor uebernommen
- /v2/abos: Liste eigener Abos + Form zum Anlegen/Loeschen, BL-Dropdown,
  Partei-Freitext, Frequenz daily/weekly
- Sidebar 'Daten'-Gruppe um beide Eintraege erweitert (statt Direkt-Link auf
  /api/feed.xml)
- Beide Routen mit Depends(require_auth) — Anonyme bekommen 401-Redirect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-25 22:34:55 +02:00
parent 7f070b5e6c
commit 7cbd46f88d
4 changed files with 307 additions and 1 deletions

View File

@ -2268,6 +2268,26 @@ async def v2_tags(request: Request, current_user: Optional[dict] = Depends(get_c
})
@app.get("/v2/abos", response_class=HTMLResponse)
async def v2_abos(request: Request, current_user: dict = Depends(require_auth)):
"""Eigene E-Mail-Abos verwalten — auth-only."""
return templates.TemplateResponse("v2/screens/abos.html", {
"request": request,
"v2_active_nav": "abos",
**_v2_template_context(current_user),
})
@app.get("/v2/feed", response_class=HTMLResponse)
async def v2_feed(request: Request, current_user: dict = Depends(require_auth)):
"""Atom-Feed-Konfigurations-Seite — auth-only."""
return templates.TemplateResponse("v2/screens/feed.html", {
"request": request,
"v2_active_nav": "feed",
**_v2_template_context(current_user),
})
@app.get("/v2/cluster", response_class=HTMLResponse)
async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)):
"""Cluster-Liste — nur für Admins."""

View File

@ -57,7 +57,8 @@
<div class="v2-nav-label">— Daten</div>
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a>
<a href="/v2/abos" class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">{{ icon("envelope-simple", 14) }} Meine Abos</a>
</div>
{% endif %}

View File

@ -0,0 +1,163 @@
{% extends "v2/base.html" %}
{% block title %}Meine Abos — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "abos" %}
{% block head_extra %}
<style>
.abo-form {
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
margin-bottom: 24px; max-width: 760px;
padding: 12px 14px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-bg-subtle);
}
.abo-form label {
display: block; font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7;
margin-bottom: 4px;
}
.abo-form select, .abo-form input[type="text"] {
font-family: var(--font-mono); font-size: 13px;
padding: 6px 8px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
}
.abo-form .submit {
font-family: var(--font-display); font-size: 12px; font-weight: 700;
padding: 7px 14px; background: var(--ecg-teal); color: #fff;
border: none; border-radius: 4px; cursor: pointer; letter-spacing: 0.04em;
}
.abo-row {
display: flex; align-items: center; gap: 14px;
padding: 10px 0; border-bottom: 1px solid var(--ecg-border);
font-size: 13px;
}
.abo-row:last-child { border-bottom: none; }
.abo-tag {
font-family: var(--font-mono); font-size: 11px;
padding: 3px 7px; border: 1px solid var(--ecg-border); border-radius: 3px;
background: var(--ecg-card-bg);
}
.abo-del {
font-family: var(--font-mono); font-size: 11px;
padding: 4px 10px; background: none; border: 1px solid var(--ecg-border);
border-radius: 3px; cursor: pointer; color: var(--redline-contra, #c00);
}
.abo-del:hover { background: var(--redline-contra-bg, #f9e6e6); }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Meine E-Mail-Abos</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Tägliche Zusammenfassung neuer Bewertungen — gefiltert nach Bundesland und/oder Partei.
</p>
</div>
<form class="abo-form" onsubmit="aboCreate(event)">
<div>
<label for="abo-bl">Bundesland</label>
<select id="abo-bl">
<option value="">— alle —</option>
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
</select>
</div>
<div>
<label for="abo-partei">Partei</label>
<input type="text" id="abo-partei" placeholder="z.B. SPD oder leer = alle" autocomplete="off">
</div>
<div>
<label for="abo-freq">Frequenz</label>
<select id="abo-freq">
<option value="daily">täglich</option>
<option value="weekly">wöchentlich</option>
</select>
</div>
<button type="submit" class="submit">Abo anlegen</button>
</form>
<div id="abo-status" style="margin-bottom:8px;font-family:var(--font-mono);font-size:12px;opacity:0.7;"></div>
<div id="abo-list">Lade …</div>
{% endblock %}
{% block body_scripts %}
<script>
async function aboLoad() {
var listEl = document.getElementById('abo-list');
try {
var r = await fetch('/api/subscriptions');
if (r.status === 401) {
listEl.innerHTML = '<p style="color:var(--ecg-dark);opacity:0.7;">Bitte erst anmelden.</p>';
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
return;
}
var subs = await r.json();
if (!subs || !subs.length) {
listEl.innerHTML = '<p style="opacity:0.6;font-style:italic;">Du hast noch keine Abos. Lege oben eines an.</p>';
return;
}
listEl.innerHTML = subs.map(function(s) {
var bl = s.bundesland || '—';
var p = s.partei || '—';
var f = s.frequency || 'daily';
var ls = s.last_sent ? ('zuletzt: ' + s.last_sent.substring(0,10)) : 'noch nie versandt';
return '<div class="abo-row">'
+ '<span class="abo-tag">BL ' + escHtml(bl) + '</span>'
+ '<span class="abo-tag">Partei ' + escHtml(p) + '</span>'
+ '<span class="abo-tag">' + escHtml(f) + '</span>'
+ '<span style="flex:1;opacity:0.6;font-family:var(--font-mono);font-size:11px;">' + escHtml(ls) + '</span>'
+ '<button class="abo-del" onclick="aboDelete(' + s.id + ')">✕ Löschen</button>'
+ '</div>';
}).join('');
} catch (e) {
listEl.innerHTML = '<p style="color:#c00;">Fehler: ' + escHtml(e.message) + '</p>';
}
}
async function aboCreate(e) {
e.preventDefault();
var bl = document.getElementById('abo-bl').value;
var part = document.getElementById('abo-partei').value.trim();
var freq = document.getElementById('abo-freq').value;
var fd = new FormData();
if (bl) fd.append('bundesland', bl);
if (part) fd.append('partei', part);
fd.append('frequency', freq);
var r = await fetch('/api/subscriptions', { method: 'POST', body: fd });
if (r.status === 401) {
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
return;
}
if (!r.ok) {
var err = await r.json().catch(()=>({detail:'Fehler'}));
setStatus('Fehler: ' + (err.detail || r.status), true);
return;
}
setStatus('Abo angelegt.');
document.getElementById('abo-partei').value = '';
aboLoad();
}
async function aboDelete(id) {
if (!confirm('Abo wirklich löschen?')) return;
var r = await fetch('/api/subscriptions/' + id, { method: 'DELETE' });
if (r.ok) { setStatus('Abo gelöscht.'); aboLoad(); }
else { setStatus('Löschen fehlgeschlagen.', true); }
}
function setStatus(msg, isErr) {
var el = document.getElementById('abo-status');
el.textContent = msg;
el.style.color = isErr ? '#c00' : 'var(--ecg-teal)';
setTimeout(function(){ el.textContent=''; }, 4000);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', aboLoad);
</script>
{% endblock %}

View File

@ -0,0 +1,122 @@
{% extends "v2/base.html" %}
{% block title %}Atom-Feed — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "feed" %}
{% block head_extra %}
<style>
.feed-form {
display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px;
align-items: center; margin-bottom: 24px; max-width: 560px;
padding: 14px; border: 1px solid var(--ecg-border); border-radius: 4px;
background: var(--ecg-bg-subtle);
}
.feed-form label { font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7; }
.feed-form select, .feed-form input {
font-family: var(--font-mono); font-size: 13px;
padding: 6px 8px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
}
.feed-url-box {
margin-top: 16px; padding: 14px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-card-bg);
}
.feed-url {
font-family: var(--font-mono); font-size: 12px; padding: 8px 10px;
border: 1px solid var(--ecg-border); border-radius: 3px; word-break: break-all;
background: var(--paper); color: var(--ecg-dark); display: block;
margin: 8px 0;
}
.feed-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.feed-btn {
font-family: var(--font-mono); font-size: 12px; padding: 6px 14px;
background: var(--ecg-teal); color: #fff; border: none; border-radius: 3px;
cursor: pointer; text-decoration: none; display: inline-flex;
align-items: center; gap: 6px;
}
.feed-btn.secondary { background: none; color: var(--ecg-dark); border: 1px solid var(--ecg-border); }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Atom-Feed</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Konfigurierbarer Feed der neuesten Bewertungen — abonnierbar mit jedem RSS/Atom-Reader.
</p>
</div>
<form class="feed-form" onsubmit="event.preventDefault();feedUpdate();">
<label for="feed-bl">Bundesland</label>
<select id="feed-bl" onchange="feedUpdate()">
<option value="">— alle —</option>
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
</select>
<label for="feed-partei">Partei</label>
<input type="text" id="feed-partei" placeholder="z.B. SPD (leer = alle)" oninput="feedUpdate()" autocomplete="off">
<label for="feed-limit">Anzahl</label>
<input type="number" id="feed-limit" min="1" max="200" value="50" oninput="feedUpdate()">
</form>
<div class="feed-url-box">
<div style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.07em;opacity:0.7;">Feed-URL</div>
<code class="feed-url" id="feed-url">/api/feed.xml</code>
<div class="feed-actions">
<a id="feed-open" href="/api/feed.xml" class="feed-btn" target="_blank" rel="noopener">📰 Öffnen</a>
<button class="feed-btn secondary" onclick="feedCopy()">📋 URL kopieren</button>
<a id="feed-reader" href="" class="feed-btn secondary" target="_blank" rel="noopener" title="In Feedly öffnen">In Feedly</a>
</div>
</div>
<div style="margin-top:24px;font-size:12px;color:var(--ecg-dark);opacity:0.7;line-height:1.6;max-width:600px;">
<p><strong>Hinweis:</strong> Du kannst die Feed-URL in jedem RSS-Reader (z.B. Feedly, NewsBlur, Inoreader, NetNewsWire, Thunderbird) abonnieren. Der Feed ist Atom 1.0 und liefert die letzten Bewertungen mit Score, Empfehlung und Kurzbegründung.</p>
<p>Wenn du regelmäßige Mails statt Pull-Feed willst, lege ein <a href="/v2/abos" style="color:var(--ecg-teal);">E-Mail-Abo</a> an.</p>
</div>
{% endblock %}
{% block body_scripts %}
<script>
function feedUpdate() {
var bl = document.getElementById('feed-bl').value;
var part = document.getElementById('feed-partei').value.trim();
var limit = document.getElementById('feed-limit').value;
var qs = [];
if (bl) qs.push('bundesland=' + encodeURIComponent(bl));
if (part) qs.push('partei=' + encodeURIComponent(part));
if (limit && limit !== '50') qs.push('limit=' + encodeURIComponent(limit));
var path = '/api/feed.xml' + (qs.length ? ('?' + qs.join('&')) : '');
var full = location.origin + path;
document.getElementById('feed-url').textContent = full;
document.getElementById('feed-open').href = path;
document.getElementById('feed-reader').href = 'https://feedly.com/i/subscription/feed%2F' + encodeURIComponent(full);
}
async function feedCopy() {
var url = document.getElementById('feed-url').textContent;
if (navigator.clipboard) await navigator.clipboard.writeText(url);
else { prompt('Kopieren:', url); }
}
// Bundesland aus globaler Auswahl als Default übernehmen
document.addEventListener('DOMContentLoaded', function() {
var globalBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
if (globalBl && globalBl !== 'ALL') {
var sel = document.getElementById('feed-bl');
if (sel) sel.value = globalBl;
}
feedUpdate();
});
window.addEventListener('v2-bl-changed', function(e) {
var sel = document.getElementById('feed-bl');
if (sel) {
sel.value = (e.detail && e.detail.bl !== 'ALL') ? e.detail.bl : '';
feedUpdate();
}
});
</script>
{% endblock %}