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:
parent
7f070b5e6c
commit
7cbd46f88d
20
app/main.py
20
app/main.py
@ -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."""
|
||||
|
||||
@ -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 %}
|
||||
|
||||
|
||||
163
app/templates/v2/screens/abos.html
Normal file
163
app/templates/v2/screens/abos.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', aboLoad);
|
||||
</script>
|
||||
{% endblock %}
|
||||
122
app/templates/v2/screens/feed.html
Normal file
122
app/templates/v2/screens/feed.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user