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:
parent
066195bfb5
commit
3ce2304e41
3 changed files with 486 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -181,14 +181,47 @@
|
|||
|
||||
<!-- Aktions-Buttons -->
|
||||
<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="sendToBot()" id="send-btn" class="btn btn-purple flex-1">
|
||||
📱 Zum Bot senden
|
||||
<button onclick="togglePlanPanel()" id="plan-btn" class="btn btn-purple flex-1">
|
||||
📅 Einplanen & zum Bot senden
|
||||
</button>
|
||||
<button onclick="skipToday()" class="btn btn-danger">⏭️ Überspringen</button>
|
||||
</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>
|
||||
|
||||
<!-- Rechte Spalte: Telegram-Vorschau + Timer -->
|
||||
|
|
@ -236,71 +269,92 @@
|
|||
<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>
|
||||
</div>
|
||||
<div class="space-y-0">
|
||||
{% set wday_names = {'0':'Mo','1':'Di','2':'Mi','3':'Do','4':'Fr','5':'Sa','6':'So'} %}
|
||||
<div class="space-y-1">
|
||||
{% 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) %}
|
||||
<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 -->
|
||||
<div class="w-24 shrink-0">
|
||||
<div class="text-sm font-semibold {% if is_today %}text-blue-400{% else %}text-white{% endif %}">
|
||||
{% 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] }}.
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{% if is_today %}Heute{% else %}{{ d }}{% endif %}
|
||||
</div>
|
||||
<!-- Tag-Header -->
|
||||
<div class="flex items-center gap-2 pt-2 pb-1 px-1
|
||||
{% if is_today %}text-blue-400{% else %}text-slate-400{% endif %}">
|
||||
<span class="text-xs font-bold">{{ d[8:] }}.{{ d[5:7] }}.</span>
|
||||
{% 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 %}
|
||||
{% if not arts %}<span class="text-xs text-slate-600 italic">— kein Artikel</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Uhrzeit -->
|
||||
<div class="w-16 shrink-0 text-xs text-slate-400 font-mono">
|
||||
{{ channel.post_time or '19:55' }}
|
||||
</div>
|
||||
{% for art in arts %}
|
||||
<!-- Artikel-Slot -->
|
||||
<div class="rounded-lg border border-slate-700/50 hover:border-slate-600 transition bg-slate-800/30"
|
||||
id="plan-row-{{ art.id }}">
|
||||
|
||||
<!-- Status-Icon -->
|
||||
<div class="w-8 shrink-0 text-center text-base">
|
||||
{% 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 %}<span class="text-slate-600">—</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Inhalt-Preview -->
|
||||
<!-- Haupt-Zeile -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 cursor-pointer"
|
||||
onclick="loadDate('{{ d }}')">
|
||||
<span class="text-base w-5 shrink-0 text-center">{{ status_icons.get(art.status, '❓') }}</span>
|
||||
<span class="text-xs font-mono text-slate-400 w-12 shrink-0">{{ art.post_time }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if art and art.content_final %}
|
||||
<div class="text-sm text-slate-300 truncate">{{ art.content_final[:80] }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">v{{ art.version }} · {{ art.tag or '' }}</div>
|
||||
{% if art.content_final %}
|
||||
<div class="text-sm text-slate-300 truncate">{{ art.content_final[:70] }}</div>
|
||||
<div class="text-xs text-slate-500">v{{ art.version }}{% if art.tag %} · {{ art.tag }}{% endif %}</div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
<!-- Status-Badge -->
|
||||
<div class="shrink-0">
|
||||
{% if art %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full border
|
||||
{% if art.status == 'posted' %}status-posted
|
||||
{% elif art.status == 'approved' %}status-approved
|
||||
{% elif art.status == 'sent_to_bot' %}status-sent
|
||||
{% elif art.status in ('draft','pending_review') %}status-pending
|
||||
{% else %}status-skipped{% endif %}">
|
||||
{{ {'draft':'Entwurf','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'}.get(art.status, art.status) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-slate-600">leer</span>
|
||||
<!-- Umplan-Panel (eingeklappt) -->
|
||||
<div id="reschedule-panel-{{ art.id }}" class="hidden border-t border-slate-700 px-3 py-3 bg-slate-900/50 rounded-b-lg">
|
||||
<div class="text-xs text-yellow-400 font-semibold mb-2">🔄 Umplanen</div>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1">Datum</label>
|
||||
<input type="date" id="rs-date-{{ art.id }}" value="{{ d }}"
|
||||
class="text-xs py-1 px-2 w-36" onchange="checkRescheduleSlot({{ art.id }})">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1">Uhrzeit</label>
|
||||
<input type="time" id="rs-time-{{ art.id }}" value="{{ art.post_time }}" step="900"
|
||||
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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 = '<span class="spinner">📡</span> 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 = '<span class="spinner">⚙️</span> 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);
|
||||
// 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 {
|
||||
alert('Fehler: ' + d.error);
|
||||
btn.innerHTML = '📱 Zum Bot senden';
|
||||
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');
|
||||
|
|
|
|||
171
redakteur/STATE.md
Normal file
171
redakteur/STATE.md
Normal 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`
|
||||
Loading…
Add table
Reference in a new issue