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 @@
- {% 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 %}
+
+
+
+
+
🔄 Umplanen
+
+
+ Datum
+
+
+
+ Uhrzeit
+
+
+
+ ✓ Bestätigen
+
+
+ Abbrechen
+
+
+
+
+
+
+ {% 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`