From 3ce2304e412628aa4ae8ec9846dcc133b14840ac Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 06:38:37 +0700 Subject: [PATCH] =?UTF-8?q?fuenfvoracht:=20Board-Interaktivit=C3=A4t=20+?= =?UTF-8?q?=20Redakteur=20Sprint-Plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FünfVorAcht: - Redaktionsplan zeigt jetzt mehrere Artikel pro Tag - Umplanen-Button (Inline-Panel mit Slot-Prüfung, 15-Min-Raster) - Löschen-Button mit Sicherheitsabfrage - STATE.md aktualisiert (Changelog 26.02.2026) Redakteur: - STATE.md mit vollständigem Sprint-Plan angelegt - Übernommene Komponenten aus FünfVorAcht dokumentiert - 4 Sprints: Infrastruktur → WP-Anbindung → Editor → Polish Made-with: Cursor --- fuenfvoracht/STATE.md | 20 +- fuenfvoracht/src/templates/index.html | 383 ++++++++++++++++++++------ redakteur/STATE.md | 171 ++++++++++++ 3 files changed, 486 insertions(+), 88 deletions(-) create mode 100644 redakteur/STATE.md diff --git a/fuenfvoracht/STATE.md b/fuenfvoracht/STATE.md index 8d02768d..b3e1ff33 100644 --- a/fuenfvoracht/STATE.md +++ b/fuenfvoracht/STATE.md @@ -179,11 +179,23 @@ Events: `article_generated`, `article_saved`, `article_scheduled`, `article_sent --- +## Changelog + +### 26.02.2026 — Board-Interaktivität +- **Redaktionsplan komplett überarbeitet:** Mehrere Artikel pro Tag sichtbar (vorher: einer pro Tag) +- **🔄 Umplanen:** Inline-Panel direkt unter dem Artikel — Datum + Uhrzeit mit Live-Slot-Prüfung, 15-Minuten-Raster +- **🗑️ Löschen:** Sicherheitsabfrage + sofortige Entfernung aus DB +- **✏️ Bearbeiten:** Klick auf Artikel-Text öffnet Artikel im Studio (war schon vorhanden) +- Bei `posted`-Artikeln sind Aktions-Buttons ausgeblendet + +### 24.–26.02.2026 — Vollständiger Aufbau +- Initiales System, Multi-Reviewer, Branding, Logging, Scheduling, Morgen-Briefing, Fehler-Alarm, Reviewer-Verwaltung, Anleitung, Tailwind self-hosted, Performance-Fix + +--- + ## Offene Punkte / TODOs -- [ ] 15-Min-Einplan-Panel in Dashboard-UI integrieren (API vorhanden) -- [ ] Board: Umplanen/Löschen Buttons in index.html -- [ ] Redakteure-Verwaltung in settings.html -- [ ] Kanal-ID in Settings-UI editierbar +- [ ] Redakteure-Verwaltung UI in settings.html (API vorhanden) +- [ ] Kanal-ID in Settings-UI editierbar (API vorhanden) - [ ] Media-Einbettung im Editor (Video/Link Drag & Drop) - [ ] Letzter-Post Zeitstempel im Dashboard anzeigen diff --git a/fuenfvoracht/src/templates/index.html b/fuenfvoracht/src/templates/index.html index ecaeb805..31850669 100644 --- a/fuenfvoracht/src/templates/index.html +++ b/fuenfvoracht/src/templates/index.html @@ -181,14 +181,47 @@
- -
+ + + @@ -236,71 +269,92 @@

📅 Redaktionsplan — Nächste 7 Tage

Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪 -
- {% set wday_names = {'0':'Mo','1':'Di','2':'Mi','3':'Do','4':'Fr','5':'Sa','6':'So'} %} +
+ {% set status_icons = {'draft':'📝','scheduled':'🗓️','sent_to_bot':'📱','approved':'✅','posted':'📤','skipped':'⏭️','pending_review':'⏳'} %} + {% set status_labels = {'draft':'Entwurf','scheduled':'Eingeplant','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'} %} + {% set status_css = {'posted':'status-posted','approved':'status-approved','sent_to_bot':'status-sent','scheduled':'status-sent','draft':'status-pending','pending_review':'status-pending','skipped':'status-skipped'} %} + {% for d in plan_days %} - {% set art = plan_articles.get(d) %} + {% set arts = plan_articles.get(d, []) %} {% set is_today = (d == today) %} -
- -
-
- {% set d_date = d.split('-') %} - {% set weekday_idx = (d_date[0]|int, d_date[1]|int, d_date[2]|int) %} - {{ d[8:] }}.{{ d[5:7] }}. -
-
- {% if is_today %}Heute{% else %}{{ d }}{% endif %} -
-
- - -
- {{ channel.post_time or '19:55' }} -
- - -
- {% if art %} - {% if art.status == 'posted' %}📤 - {% elif art.status == 'approved' %}✅ - {% elif art.status == 'sent_to_bot' %}📱 - {% elif art.status in ('pending_review','draft') %}📝 - {% else %}⏭️{% endif %} - {% else %}{% endif %} -
- - -
- {% if art and art.content_final %} -
{{ art.content_final[:80] }}
-
v{{ art.version }} · {{ art.tag or '' }}
- {% else %} -
Kein Artikel geplant
- {% endif %} -
- - -
- {% if art %} - - {{ {'draft':'Entwurf','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'}.get(art.status, art.status) }} - - {% else %} - leer - {% endif %} -
+ +
+ {{ d[8:] }}.{{ d[5:7] }}. + {% if is_today %}Heute{% endif %} + {% if not arts %}— kein Artikel{% endif %}
+ + {% for art in arts %} + +
+ + +
+ {{ status_icons.get(art.status, '❓') }} + {{ art.post_time }} +
+ {% if art.content_final %} +
{{ art.content_final[:70] }}
+
v{{ art.version }}{% if art.tag %} · {{ art.tag }}{% endif %}
+ {% else %} +
Kein Inhalt
+ {% endif %} +
+ + {{ status_labels.get(art.status, art.status) }} + + + + {% if art.status != 'posted' %} +
+ + +
+ {% endif %} +
+ + + + +
+ {% endfor %} + + {% if not arts %} +
+ {% endif %} + {% endfor %}
@@ -567,42 +621,128 @@ async function generateArticle() { async function regenerate() { await generateArticle(); } -// ── Artikel speichern ────────────────────────────────────────────────────── +// ── Artikel speichern (stiller Zwischenspeicher) ─────────────────────────── async function saveArticle() { const content = document.getElementById('article-editor').value.trim(); if (!content) return; + const channel = document.getElementById('post-time-input')?.value || '19:55'; await fetch('/api/article/' + selectedDate + '/save', { method: 'POST', headers: {'Content-Type':'application/json'}, - body: JSON.stringify({content}) + body: JSON.stringify({content, post_time: channel}) }); showToast('💾 Gespeichert (' + selectedDate + ')'); } -// ── Zum Bot senden ───────────────────────────────────────────────────────── -async function sendToBot() { +// ── Einplan-Panel ────────────────────────────────────────────────────────── +function togglePlanPanel() { + const panel = document.getElementById('plan-panel'); const content = document.getElementById('article-editor').value.trim(); - if (!content) { alert('Bitte erst Artikel generieren oder eingeben.'); return; } + if (!content) { showToast('⚠️ Bitte erst Artikel generieren oder eingeben.'); return; } - const btn = document.getElementById('send-btn'); - btn.innerHTML = '📡 Sende…'; + const isHidden = panel.classList.contains('hidden'); + panel.classList.toggle('hidden'); + + if (isHidden) { + // Defaults setzen: ausgewähltes Datum + Standard-Uhrzeit + const dateInput = document.getElementById('plan-date'); + const timeInput = document.getElementById('plan-time'); + dateInput.value = selectedDate; + // Nächsten vollen 15-Min-Slot ab jetzt berechnen + const now = new Date(); + const m = Math.ceil(now.getMinutes() / 15) * 15; + const h = m === 60 ? now.getHours() + 1 : now.getHours(); + const defaultTime = `${String(h % 24).padStart(2,'0')}:${String(m % 60).padStart(2,'0')}`; + timeInput.value = defaultTime; + // Notify-Modus anpassen + const notifySelect = document.getElementById('plan-notify'); + if (selectedDate === TODAY) { + notifySelect.value = 'sofort'; + } else { + notifySelect.value = 'auto'; + } + checkSlot(); + document.getElementById('plan-date').focus(); + } +} + +async function checkSlot() { + const date = document.getElementById('plan-date').value; + const time = document.getElementById('plan-time').value; + const statusEl = document.getElementById('slot-status'); + const confirmBtn = document.getElementById('confirm-plan-btn'); + if (!date || !time) return; + + // Zeit auf 15-Min-Raster runden + const [h, m] = time.split(':').map(Number); + const roundedM = Math.round(m / 15) * 15; + const roundedH = roundedM === 60 ? h + 1 : h; + const roundedTime = `${String(roundedH % 24).padStart(2,'0')}:${String(roundedM % 60).padStart(2,'0')}`; + document.getElementById('plan-time').value = roundedTime; + + try { + const r = await fetch(`/api/slots/${date}`); + const d = await r.json(); + statusEl.classList.remove('hidden'); + if (d.taken && d.taken.includes(roundedTime)) { + statusEl.className = 'text-xs text-red-400 bg-red-900/20 rounded p-2'; + statusEl.textContent = `❌ Slot ${date} ${roundedTime} Uhr ist bereits belegt. Bitte andere Uhrzeit wählen.`; + confirmBtn.disabled = true; + confirmBtn.classList.add('opacity-50'); + } else { + statusEl.className = 'text-xs text-green-400 bg-green-900/20 rounded p-2'; + statusEl.textContent = `✅ Slot ${date} ${roundedTime} Uhr ist frei.`; + confirmBtn.disabled = false; + confirmBtn.classList.remove('opacity-50'); + } + } catch(e) { + statusEl.classList.add('hidden'); + } +} + +async function confirmPlan() { + const content = document.getElementById('article-editor').value.trim(); + const date = document.getElementById('plan-date').value; + const time = document.getElementById('plan-time').value; + const notify = document.getElementById('plan-notify').value; + const btn = document.getElementById('confirm-plan-btn'); + + if (!content || !date || !time) return; + + btn.innerHTML = '⚙️ Wird eingeplant…'; btn.disabled = true; - await fetch('/api/article/' + selectedDate + '/save', { - method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({content}) - }); + try { + // 1. Speichern (inkl. post_time) + const saveRes = await fetch(`/api/article/${date}/save`, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({content, post_time: time}) + }); + const saveData = await saveRes.json(); + if (!saveData.success) throw new Error(saveData.error || 'Speichern fehlgeschlagen'); - const r = await fetch('/api/article/' + selectedDate + '/send-to-bot', {method:'POST'}); - const d = await r.json(); + // 2. Einplanen + notify_at setzen + const schedRes = await fetch(`/api/article/${date}/schedule`, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({post_time: time, notify_mode: notify}) + }); + const schedData = await schedRes.json(); + if (!schedData.success) throw new Error(schedData.error || 'Einplanen fehlgeschlagen'); - if (d.success) { - updateStatusBadge('sent_to_bot', '📱 Beim Bot'); - btn.innerHTML = '✅ Gesendet'; - setTimeout(() => { btn.innerHTML = '📱 Zum Bot senden'; btn.disabled = false; }, 3000); - } else { - alert('Fehler: ' + d.error); - btn.innerHTML = '📱 Zum Bot senden'; + // Panel schließen + document.getElementById('plan-panel').classList.add('hidden'); + updateStatusBadge('scheduled', '🗓️ Eingeplant'); + + if (notify === 'sofort') { + showToast(`✅ Eingeplant ${date} ${time} Uhr — Review sofort gesendet!`); + } else { + showToast(`✅ Eingeplant ${date} ${time} Uhr — Bot-Benachrichtigung folgt automatisch.`); + } + btn.innerHTML = '✅ Einplanen bestätigen'; + + } catch(e) { + alert('Fehler: ' + e.message); + btn.innerHTML = '✅ Einplanen bestätigen'; btn.disabled = false; } } @@ -635,6 +775,81 @@ function updateStatusBadge(status, label) { (status === 'sent_to_bot' ? 'sent' : status === 'pending_review' ? 'pending' : status); } +// ── Board: Umplanen ──────────────────────────────────────────────────────── +function openReschedule(id, date, time) { + closeAllReschedule(); + document.getElementById(`reschedule-panel-${id}`).classList.remove('hidden'); + document.getElementById(`rs-date-${id}`).value = date; + document.getElementById(`rs-time-${id}`).value = time; + checkRescheduleSlot(id); +} + +function closeReschedule(id) { + document.getElementById(`reschedule-panel-${id}`).classList.add('hidden'); +} + +function closeAllReschedule() { + document.querySelectorAll('[id^="reschedule-panel-"]').forEach(el => el.classList.add('hidden')); +} + +async function checkRescheduleSlot(id) { + const date = document.getElementById(`rs-date-${id}`).value; + const time = document.getElementById(`rs-time-${id}`).value; + const statusEl = document.getElementById(`rs-status-${id}`); + const confirmBtn = document.getElementById(`rs-confirm-${id}`); + if (!date || !time) return; + + const [h, m] = time.split(':').map(Number); + const roundedM = Math.round(m / 15) * 15; + const roundedH = roundedM === 60 ? h + 1 : h; + const roundedTime = `${String(roundedH % 24).padStart(2,'0')}:${String(roundedM % 60).padStart(2,'0')}`; + document.getElementById(`rs-time-${id}`).value = roundedTime; + + const r = await fetch(`/api/slots/${date}`); + const d = await r.json(); + statusEl.classList.remove('hidden'); + if (d.taken && d.taken.includes(roundedTime)) { + statusEl.className = 'text-xs mt-2 text-red-400'; + statusEl.textContent = `❌ Slot ${date} ${roundedTime} bereits belegt`; + confirmBtn.disabled = true; + confirmBtn.classList.add('opacity-50'); + } else { + statusEl.className = 'text-xs mt-2 text-green-400'; + statusEl.textContent = `✅ Slot ${date} ${roundedTime} ist frei`; + confirmBtn.disabled = false; + confirmBtn.classList.remove('opacity-50'); + } +} + +async function confirmReschedule(id) { + const date = document.getElementById(`rs-date-${id}`).value; + const time = document.getElementById(`rs-time-${id}`).value; + const r = await fetch(`/api/article/${id}/reschedule`, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({date, post_time: time}) + }); + const d = await r.json(); + if (d.success) { + showToast(`✅ Umgeplant auf ${date} ${time} Uhr`); + setTimeout(() => location.reload(), 1200); + } else { + showToast('❌ ' + (d.error || 'Fehler beim Umplanen')); + } +} + +// ── Board: Löschen ───────────────────────────────────────────────────────── +async function deleteArticle(id, date, time) { + if (!confirm(`Artikel vom ${date} ${time} Uhr wirklich löschen?\n\nDieser Vorgang kann nicht rückgängig gemacht werden.`)) return; + const r = await fetch(`/api/article/${id}/delete`, {method: 'POST'}); + const d = await r.json(); + if (d.success) { + showToast(`🗑️ Artikel ${date} ${time} gelöscht`); + setTimeout(() => location.reload(), 1000); + } else { + showToast('❌ Fehler beim Löschen'); + } +} + // ── Aufenthaltsort ───────────────────────────────────────────────────────── function toggleLocationMenu() { const menu = document.getElementById('location-menu'); diff --git a/redakteur/STATE.md b/redakteur/STATE.md new file mode 100644 index 00000000..319d7494 --- /dev/null +++ b/redakteur/STATE.md @@ -0,0 +1,171 @@ +# STATE: Redakteur +**Stand: 26.02.2026** + +--- + +## Status + +🗓️ **In Planung — Umsetzung ab sofort** + +--- + +## Konzept + +KI-gestützter WordPress-Artikel-Generator mit Dashboard. +Eigenständiges System, unabhängig von FünfVorAcht (CT 112). +Basis: ~70% Code-Übernahme aus FünfVorAcht, angepasst für WordPress statt Telegram-Kanal. + +--- + +## Geplante Infrastruktur + +| CT | Dienst | Host | IP geplant | Status | +|----|--------|------|-----------|--------| +| 113 | Redakteur | pve-hetzner | 10.10.10.113 | ⏳ Noch nicht erstellt | + +- **Stack:** Python/Flask + Docker (wie CT 112) +- **Dashboard:** `https://redakteur.orbitalo.net` (Cloudflare Tunnel) +- **Lokal:** `http://[Tailscale-IP]:8080` +- **Repo:** `git.orbitalo.net/orbitalo/redakteur` +- **Ziel-WordPress:** CT 101 (arakava-news-2.orbitalo.net) + +--- + +## Was aus FünfVorAcht übernommen wird + +### Direkt kopieren (0 Änderungen) + +| Datei | Beschreibung | +|-------|-------------| +| `logger.py` | Strukturiertes JSON-Logging | +| `openrouter.py` | OpenRouter API-Wrapper | +| `Dockerfile.web` | Tailwind self-hosted, Gunicorn | +| `requirements-web.txt` | Flask, APScheduler, pytz, ... | + +### Stark übernehmen, leicht anpassen + +| Datei | Was übernommen wird | Was sich ändert | +|-------|---------------------|-----------------| +| `database.py` | articles, settings, reviewers, prompts | Neue Felder: wp_post_id, category, featured_image, seo_* | +| `app.py` | Auth, Settings, Prompt-Verwaltung, Scheduling-Logik | WordPress REST API Routen statt Bot-Routen | +| `settings.html` | Reviewer-Management, API-Key | WP URL + Application Password | +| `prompts.html` | Prompt-Editor | Neue Variablen: Ton, SEO, Kategorie | +| `history.html` | Post-Historie | Spalte WP-URL statt Telegram-ID | +| `hilfe.html` | Struktur + Layout | Neue Inhalte für WP-Workflow | + +### Komplett neu bauen + +| Was | Warum neu | +|-----|-----------| +| `templates/index.html` | Zwei-Spalten-Editor, Medien-Manager, WP-Vorschau | +| `wordpress.py` | WordPress REST API Client (Artikel, Kategorien, Medien) | +| Telegram-Notifier | Nur Post-Bestätigung nach Veröffentlichung, kein Bot-Scheduler | + +--- + +## Funktionsumfang + +### Dashboard + +| Feature | Status | +|---------|--------| +| KI-Artikel generieren (OpenRouter) | ✅ übernommen | +| Prompt-System (anpassbar) | ✅ übernommen | +| Zwei-Spalten-Editor: Markdown links, WP-Vorschau rechts | 🆕 neu | +| Ton wählbar: Informativ / Meinungsstark / Reportage | 🆕 neu | +| SEO automatisch mitgeneriert (Meta-Title, Description, Keyword) | 🆕 neu | +| Featured Image: og:image aus Quell-URL oder manuell | 🆕 neu | +| YouTube: als Hero oder im Text einbindbar | 🆕 neu | +| WP-Kategorien zuweisbar | 🆕 neu | +| Kalender-Ansicht (Nächste 7 Tage, mehrere Slots) | ✅ übernommen | +| Umplanen / Löschen im Board | ✅ übernommen | +| Redakteure-Verwaltung (Telegram-Benachrichtigung) | ✅ übernommen | +| Strukturiertes JSON-Logging | ✅ übernommen | +| Fehler-Alarm per Telegram | ✅ übernommen | +| Anleitung (/hilfe) | ✅ übernommen | + +### Veröffentlichung + +| Option | Details | +|--------|---------| +| Sofort | POST an WordPress REST API → direkt live | +| Entwurf | Speichern als WP-Entwurf, kein Publish | +| Geplant | Datum + Uhrzeit → WP scheduled post oder lokaler Scheduler | + +### Telegram-Benachrichtigung (nach Veröffentlichung) +- Kein Freigabe-Workflow wie bei FünfVorAcht +- Nur Bestätigungs-Ping: "✅ Artikel veröffentlicht: [Titel] → [URL]" + +--- + +## WordPress REST API + +- **Endpoint:** `https://arakava-news-2.orbitalo.net/wp-json/wp/v2/` +- **Auth:** Application Password (in Settings-UI konfigurierbar) +- **Operationen:** + - Artikel erstellen/aktualisieren (POST/PUT `/posts`) + - Kategorien abrufen (GET `/categories`) + - Featured Image hochladen (POST `/media`) + - Artikel-Status: `draft`, `publish`, `future` (geplant) + +--- + +## Sprint-Plan + +### Sprint 1 — Infrastruktur + Grundgerüst + +| Prio | Task | +|------|------| +| 1 | CT 113 auf pve-hetzner erstellen (wie CT 112) | +| 2 | Forgejo-Repo `redakteur` anlegen | +| 3 | `logger.py`, `openrouter.py`, Dockerfiles, requirements kopieren | +| 4 | `database.py` mit WP-Feldern ableiten | +| 5 | `app.py` Grundstruktur + Auth übernehmen | +| 6 | MOTD + Tailscale + Cloudflare Tunnel einrichten | + +### Sprint 2 — WordPress-Anbindung + +| Prio | Task | +|------|------| +| 7 | `wordpress.py` — REST API Client (Artikel, Kategorien, Medien-Upload) | +| 8 | Settings-UI: WP URL + Application Password konfigurierbar | +| 9 | Publish-Workflow: Sofort / Entwurf / Geplant | +| 10 | Telegram-Bestätigungs-Ping nach Veröffentlichung | + +### Sprint 3 — Editor + Medien + +| Prio | Task | +|------|------| +| 11 | Zwei-Spalten-Editor mit Echtzeit-WP-Vorschau | +| 12 | SEO-Felder (Meta-Title, Description, Keyword) automatisch aus KI | +| 13 | Featured Image aus og:image oder manuell eingeben | +| 14 | YouTube-Einbindung (Hero oben / im Text) | +| 15 | WP-Kategorien zuweisbar | + +### Sprint 4 — Polish + +| Prio | Task | +|------|------| +| 16 | Ton-Auswahl (Informativ / Meinungsstark / Reportage) | +| 17 | Mehrere Quellen kombinierbar für einen Artikel | +| 18 | Zweite Instanz vorbereiten (Contabo USA/Singapur, eigene .env) | + +--- + +## Abhängigkeiten + +- CT 101: WordPress REST API muss erreichbar sein +- CT 111: Forgejo für Code-Verwaltung +- OpenRouter API Key (aus credentials.md) +- Telegram Bot Token für Benachrichtigungen + +--- + +## Logging + +Identisch mit FünfVorAcht (`logger.py`): +```json +{"ts": "2026-02-26T14:00:00Z", "level": "INFO", "event": "article_published", "wp_post_id": 1234, "url": "https://..."} +``` + +Events: `article_generated`, `article_saved`, `article_published`, `article_failed`, `media_uploaded`, `wp_error`