fuenfvoracht: Board-Interaktivität + Redakteur Sprint-Plan

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
This commit is contained in:
root 2026-02-27 06:38:37 +07:00
parent 066195bfb5
commit 3ce2304e41
3 changed files with 486 additions and 88 deletions

View file

@ -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 ## Offene Punkte / TODOs
- [ ] 15-Min-Einplan-Panel in Dashboard-UI integrieren (API vorhanden) - [ ] Redakteure-Verwaltung UI in settings.html (API vorhanden)
- [ ] Board: Umplanen/Löschen Buttons in index.html - [ ] Kanal-ID in Settings-UI editierbar (API vorhanden)
- [ ] Redakteure-Verwaltung in settings.html
- [ ] Kanal-ID in Settings-UI editierbar
- [ ] Media-Einbettung im Editor (Video/Link Drag & Drop) - [ ] Media-Einbettung im Editor (Video/Link Drag & Drop)
- [ ] Letzter-Post Zeitstempel im Dashboard anzeigen - [ ] Letzter-Post Zeitstempel im Dashboard anzeigen

View file

@ -181,14 +181,47 @@
<!-- Aktions-Buttons --> <!-- Aktions-Buttons -->
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<button onclick="saveArticle()" class="btn btn-ghost">💾 Speichern</button>
<button onclick="regenerate()" class="btn btn-ghost">🔄 Neu generieren</button> <button onclick="regenerate()" class="btn btn-ghost">🔄 Neu generieren</button>
<button onclick="sendToBot()" id="send-btn" class="btn btn-purple flex-1"> <button onclick="togglePlanPanel()" id="plan-btn" class="btn btn-purple flex-1">
📱 Zum Bot senden 📅 Einplanen & zum Bot senden
</button> </button>
<button onclick="skipToday()" class="btn btn-danger">⏭️ Überspringen</button> <button onclick="skipToday()" class="btn btn-danger">⏭️ Überspringen</button>
</div> </div>
<!-- Einplan-Panel -->
<div id="plan-panel" class="hidden mt-1 rounded-xl border border-purple-700 bg-purple-950/30 p-4 space-y-3">
<div class="text-sm font-semibold text-purple-300">📅 Artikel einplanen</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-slate-400 block mb-1">Datum</label>
<input type="date" id="plan-date" class="w-full text-sm py-1.5 px-2"
onchange="checkSlot()">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Uhrzeit (15-Min-Raster)</label>
<input type="time" id="plan-time" step="900" class="w-full text-sm py-1.5 px-2"
onchange="checkSlot()">
</div>
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Bot-Benachrichtigung</label>
<select id="plan-notify" class="w-full text-sm py-1.5 px-2">
<option value="auto">🤖 Automatisch (heute → sofort, sonst Vortag 17:00)</option>
<option value="sofort">⚡ Sofort zum Review senden</option>
</select>
</div>
<div id="slot-status" class="text-xs hidden"></div>
<div class="flex gap-2 pt-1">
<button onclick="confirmPlan()" id="confirm-plan-btn"
class="btn btn-success flex-1">✅ Einplanen bestätigen</button>
<button onclick="togglePlanPanel()" class="btn btn-ghost">✕ Abbrechen</button>
</div>
</div>
</div> </div>
<!-- Rechte Spalte: Telegram-Vorschau + Timer --> <!-- Rechte Spalte: Telegram-Vorschau + Timer -->
@ -236,71 +269,92 @@
<h2 class="text-base font-semibold text-white">📅 Redaktionsplan — Nächste 7 Tage</h2> <h2 class="text-base font-semibold text-white">📅 Redaktionsplan — Nächste 7 Tage</h2>
<span class="text-xs text-slate-500">Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪</span> <span class="text-xs text-slate-500">Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪</span>
</div> </div>
<div class="space-y-0"> <div class="space-y-1">
{% 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 %} {% for d in plan_days %}
{% set art = plan_articles.get(d) %} {% set arts = plan_articles.get(d, []) %}
{% set is_today = (d == today) %} {% set is_today = (d == today) %}
<div onclick="loadDate('{{ d }}')"
class="flex items-center gap-4 py-3 px-3 -mx-3 rounded-lg cursor-pointer transition
hover:bg-slate-700/40
{% if is_today %}bg-blue-900/20 border border-blue-800/50{% else %}border-b border-slate-700/30{% endif %}"
id="plan-row-{{ d }}">
<!-- Wochentag + Datum --> <!-- Tag-Header -->
<div class="w-24 shrink-0"> <div class="flex items-center gap-2 pt-2 pb-1 px-1
<div class="text-sm font-semibold {% if is_today %}text-blue-400{% else %}text-white{% endif %}"> {% if is_today %}text-blue-400{% else %}text-slate-400{% endif %}">
{% set d_date = d.split('-') %} <span class="text-xs font-bold">{{ d[8:] }}.{{ d[5:7] }}.</span>
{% set weekday_idx = (d_date[0]|int, d_date[1]|int, d_date[2]|int) %} {% if is_today %}<span class="text-xs bg-blue-900/40 border border-blue-700 text-blue-400 px-2 py-0.5 rounded-full">Heute</span>{% endif %}
{{ d[8:] }}.{{ d[5:7] }}. {% if not arts %}<span class="text-xs text-slate-600 italic">— kein Artikel</span>{% endif %}
</div>
<div class="text-xs text-slate-500">
{% if is_today %}Heute{% else %}{{ d }}{% endif %}
</div>
</div> </div>
<!-- Uhrzeit --> {% for art in arts %}
<div class="w-16 shrink-0 text-xs text-slate-400 font-mono"> <!-- Artikel-Slot -->
{{ channel.post_time or '19:55' }} <div class="rounded-lg border border-slate-700/50 hover:border-slate-600 transition bg-slate-800/30"
</div> id="plan-row-{{ art.id }}">
<!-- Status-Icon --> <!-- Haupt-Zeile -->
<div class="w-8 shrink-0 text-center text-base"> <div class="flex items-center gap-3 px-3 py-2.5 cursor-pointer"
{% if art %} onclick="loadDate('{{ d }}')">
{% if art.status == 'posted' %}📤 <span class="text-base w-5 shrink-0 text-center">{{ status_icons.get(art.status, '❓') }}</span>
{% elif art.status == 'approved' %}✅ <span class="text-xs font-mono text-slate-400 w-12 shrink-0">{{ art.post_time }}</span>
{% elif art.status == 'sent_to_bot' %}📱
{% elif art.status in ('pending_review','draft') %}📝
{% else %}⏭️{% endif %}
{% else %}<span class="text-slate-600"></span>{% endif %}
</div>
<!-- Inhalt-Preview -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
{% if art and art.content_final %} {% if art.content_final %}
<div class="text-sm text-slate-300 truncate">{{ art.content_final[:80] }}</div> <div class="text-sm text-slate-300 truncate">{{ art.content_final[:70] }}</div>
<div class="text-xs text-slate-500 mt-0.5">v{{ art.version }} · {{ art.tag or '' }}</div> <div class="text-xs text-slate-500">v{{ art.version }}{% if art.tag %} · {{ art.tag }}{% endif %}</div>
{% else %} {% else %}
<div class="text-sm text-slate-600 italic">Kein Artikel geplant</div> <div class="text-sm text-slate-600 italic">Kein Inhalt</div>
{% endif %}
</div>
<span class="text-xs px-2 py-0.5 rounded-full border shrink-0 {{ status_css.get(art.status, 'status-pending') }}">
{{ status_labels.get(art.status, art.status) }}
</span>
<!-- Aktions-Buttons (nur wenn nicht gepostet) -->
{% if art.status != 'posted' %}
<div class="flex items-center gap-1 shrink-0" onclick="event.stopPropagation()">
<button onclick="openReschedule({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
class="text-slate-500 hover:text-yellow-400 text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition"
title="Umplanen">🔄</button>
<button onclick="deleteArticle({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
class="text-slate-500 hover:text-red-400 text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition"
title="Löschen">🗑️</button>
</div>
{% endif %} {% endif %}
</div> </div>
<!-- Status-Badge --> <!-- Umplan-Panel (eingeklappt) -->
<div class="shrink-0"> <div id="reschedule-panel-{{ art.id }}" class="hidden border-t border-slate-700 px-3 py-3 bg-slate-900/50 rounded-b-lg">
{% if art %} <div class="text-xs text-yellow-400 font-semibold mb-2">🔄 Umplanen</div>
<span class="text-xs px-2 py-0.5 rounded-full border <div class="flex gap-2 items-end flex-wrap">
{% if art.status == 'posted' %}status-posted <div>
{% elif art.status == 'approved' %}status-approved <label class="text-xs text-slate-500 block mb-1">Datum</label>
{% elif art.status == 'sent_to_bot' %}status-sent <input type="date" id="rs-date-{{ art.id }}" value="{{ d }}"
{% elif art.status in ('draft','pending_review') %}status-pending class="text-xs py-1 px-2 w-36" onchange="checkRescheduleSlot({{ art.id }})">
{% else %}status-skipped{% endif %}"> </div>
{{ {'draft':'Entwurf','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'}.get(art.status, art.status) }} <div>
</span> <label class="text-xs text-slate-500 block mb-1">Uhrzeit</label>
{% else %} <input type="time" id="rs-time-{{ art.id }}" value="{{ art.post_time }}" step="900"
<span class="text-xs text-slate-600">leer</span> class="text-xs py-1 px-2 w-28" onchange="checkRescheduleSlot({{ art.id }})">
</div>
<button onclick="confirmReschedule({{ art.id }})"
id="rs-confirm-{{ art.id }}"
class="text-xs bg-yellow-700 hover:bg-yellow-600 text-white px-3 py-1.5 rounded-lg transition">
✓ Bestätigen
</button>
<button onclick="closeReschedule({{ art.id }})"
class="text-xs text-slate-500 hover:text-white px-2 py-1.5 rounded-lg transition">
Abbrechen
</button>
</div>
<div id="rs-status-{{ art.id }}" class="text-xs mt-2 hidden"></div>
</div>
</div>
{% endfor %}
{% if not arts %}
<div class="h-1"></div>
{% endif %} {% endif %}
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -567,42 +621,128 @@ async function generateArticle() {
async function regenerate() { await generateArticle(); } async function regenerate() { await generateArticle(); }
// ── Artikel speichern ────────────────────────────────────────────────────── // ── Artikel speichern (stiller Zwischenspeicher) ───────────────────────────
async function saveArticle() { async function saveArticle() {
const content = document.getElementById('article-editor').value.trim(); const content = document.getElementById('article-editor').value.trim();
if (!content) return; if (!content) return;
const channel = document.getElementById('post-time-input')?.value || '19:55';
await fetch('/api/article/' + selectedDate + '/save', { await fetch('/api/article/' + selectedDate + '/save', {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify({content}) body: JSON.stringify({content, post_time: channel})
}); });
showToast('💾 Gespeichert (' + selectedDate + ')'); showToast('💾 Gespeichert (' + selectedDate + ')');
} }
// ── Zum Bot senden ───────────────────────────────────────────────────────── // ── Einplan-Panel ──────────────────────────────────────────────────────────
async function sendToBot() { function togglePlanPanel() {
const panel = document.getElementById('plan-panel');
const content = document.getElementById('article-editor').value.trim(); 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'); const isHidden = panel.classList.contains('hidden');
btn.innerHTML = '<span class="spinner">📡</span> Sende…'; 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 = '<span class="spinner">⚙️</span> Wird eingeplant…';
btn.disabled = true; btn.disabled = true;
await fetch('/api/article/' + selectedDate + '/save', { try {
method:'POST', headers:{'Content-Type':'application/json'}, // 1. Speichern (inkl. post_time)
body: JSON.stringify({content}) 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'}); // 2. Einplanen + notify_at setzen
const d = await r.json(); 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) { // Panel schließen
updateStatusBadge('sent_to_bot', '📱 Beim Bot'); document.getElementById('plan-panel').classList.add('hidden');
btn.innerHTML = '✅ Gesendet'; updateStatusBadge('scheduled', '🗓️ Eingeplant');
setTimeout(() => { btn.innerHTML = '📱 Zum Bot senden'; btn.disabled = false; }, 3000);
if (notify === 'sofort') {
showToast(`✅ Eingeplant ${date} ${time} Uhr — Review sofort gesendet!`);
} else { } else {
alert('Fehler: ' + d.error); showToast(`✅ Eingeplant ${date} ${time} Uhr — Bot-Benachrichtigung folgt automatisch.`);
btn.innerHTML = '📱 Zum Bot senden'; }
btn.innerHTML = '✅ Einplanen bestätigen';
} catch(e) {
alert('Fehler: ' + e.message);
btn.innerHTML = '✅ Einplanen bestätigen';
btn.disabled = false; btn.disabled = false;
} }
} }
@ -635,6 +775,81 @@ function updateStatusBadge(status, label) {
(status === 'sent_to_bot' ? 'sent' : status === 'pending_review' ? 'pending' : status); (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 ───────────────────────────────────────────────────────── // ── Aufenthaltsort ─────────────────────────────────────────────────────────
function toggleLocationMenu() { function toggleLocationMenu() {
const menu = document.getElementById('location-menu'); const menu = document.getElementById('location-menu');

171
redakteur/STATE.md Normal file
View file

@ -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`