homelab-brain/fuenfvoracht/src/templates/index.html
root 3ce2304e41 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
2026-02-27 06:38:44 +07:00

950 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🕗 FünfVorAcht Dashboard</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
.status-draft { background: #1e293b22; border-color: #475569; color: #94a3b8; }
.status-pending { background: #92400e22; border-color: #d97706; color: #fbbf24; }
.status-sent { background: #4c1d9522; border-color: #7c3aed; color: #c4b5fd; }
.status-approved { background: #14532d22; border-color: #16a34a; color: #4ade80; }
.status-posted { background: #1e3a5f22; border-color: #3b82f6; color: #60a5fa; }
.status-skipped { background: #1e293b; border-color: #475569; color: #64748b; }
.tg-preview { background: #17212b; border-left: 3px solid #3b82f6; font-family: -apple-system,system-ui,sans-serif; white-space: pre-wrap; line-height: 1.7; }
textarea { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; resize: vertical; font-family: inherit; line-height: 1.6; }
textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f620; }
input[type=text], input[type=time], select {
background: #0f172a; border: 1px solid #334155; color: #e2e8f0;
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.875rem;
}
input:focus, select:focus { outline: none; border-color: #3b82f6; }
.btn { border-radius: 8px; font-size: 0.875rem; font-weight: 500; padding: 0.5rem 1.25rem; transition: all .15s; cursor: pointer; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-success { background: #15803d; color: #fff; }
.btn-success:hover { background: #166534; }
.btn-purple { background: #6d28d9; color: #fff; }
.btn-purple:hover { background: #5b21b6; }
.btn-ghost { background: #334155; color: #cbd5e1; }
.btn-ghost:hover { background: #475569; }
.btn-danger { background: #991b1b; color: #fff; }
.btn-danger:hover { background: #7f1d1d; }
.spinner { animation: spin 1s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.countdown { font-variant-numeric: tabular-nums; }
</style>
</head>
<body class="min-h-screen">
<!-- Nav -->
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<span class="text-xl">🕗</span>
<span class="text-lg font-bold text-white">FünfVorAcht</span>
</div>
<div class="flex items-center gap-5 text-sm">
<a href="/" class="text-blue-400 font-semibold">Studio</a>
<a href="/history" class="text-slate-400 hover:text-white">History</a>
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
<!-- Aufenthaltsort-Schalter -->
<div class="relative">
<button onclick="toggleLocationMenu()"
class="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 border border-slate-600 px-3 py-1.5 rounded-lg text-sm transition"
id="location-btn">
<span id="location-flag">{{ current_location.flag if current_location else '🌍' }}</span>
<span id="location-name" class="text-slate-200">{{ current_location.name if current_location else 'Ort wählen' }}</span>
<span class="text-slate-500"></span>
</button>
<div id="location-menu" class="hidden absolute right-0 top-10 bg-slate-800 border border-slate-600 rounded-xl shadow-xl z-50 w-52 py-1 overflow-hidden">
{% for loc in locations %}
<button onclick="setLocation({{ loc.id }}, '{{ loc.flag }}', '{{ loc.name }}')"
class="w-full text-left px-4 py-2.5 hover:bg-slate-700 flex items-center gap-3 text-sm
{% if current_location and current_location.id == loc.id %}bg-slate-700 text-blue-400{% else %}text-slate-200{% endif %}">
<span class="text-base">{{ loc.flag }}</span>
<div>
<div class="font-medium">{{ loc.name }}</div>
<div class="text-xs text-slate-400">{{ loc.timezone }}</div>
</div>
{% if current_location and current_location.id == loc.id %}
<span class="ml-auto text-blue-400"></span>
{% endif %}
</button>
{% endfor %}
</div>
</div>
<!-- Live-Uhr + Countdown -->
<div class="text-right">
<div class="flex items-center gap-2 justify-end">
<span class="text-white font-mono text-sm" id="live-clock-berlin">--:--:--</span>
<span class="text-xs text-slate-500">🇩🇪</span>
</div>
<div id="local-clock-row" class="flex items-center gap-2 justify-end" style="display:none">
<span class="text-slate-400 font-mono text-xs" id="live-clock-local">--:--:--</span>
<span class="text-xs text-slate-600" id="local-flag"></span>
</div>
<div class="text-xs text-green-400 countdown" id="countdown">--</div>
</div>
</div>
</nav>
<div class="max-w-6xl mx-auto px-6 py-6 space-y-6">
<!-- ═══ ARTIKEL-STUDIO ═══ -->
<div class="card p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold text-white">✏️ Artikel-Studio — <span id="studio-date" class="text-blue-400">{{ today }}</span></h2>
<div class="flex items-center gap-3">
{% if article_today %}
<span id="status-badge" class="border text-xs px-3 py-1 rounded-full
{% if article_today.status == 'draft' %}status-draft
{% elif article_today.status == 'sent_to_bot' %}status-sent
{% elif article_today.status == 'approved' %}status-approved
{% elif article_today.status == 'posted' %}status-posted
{% elif article_today.status == 'skipped' %}status-skipped
{% else %}status-pending{% endif %}">
{{ {'draft':'📝 Entwurf','sent_to_bot':'📱 Beim Bot','approved':'✅ Freigegeben','posted':'📤 Gepostet','skipped':'⏭️ Übersprungen','pending_review':'⏳ Offen'}.get(article_today.status, article_today.status) }}
</span>
{% else %}
<span id="status-badge" class="border text-xs px-3 py-1 rounded-full status-draft">📝 Neu</span>
{% endif %}
<button onclick="clearStudio()" class="text-slate-500 hover:text-white text-xs px-2 py-1 rounded hover:bg-slate-700 transition" title="Editor leeren">✕ Leeren</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Linke Spalte: Eingabe + Editor -->
<div class="space-y-4">
<!-- Quelle -->
<div>
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Quelle / Inhaltswunsch</label>
<div class="flex gap-2">
<input type="text" id="source-input"
class="flex-1"
placeholder="URL einfügen oder Thema beschreiben…"
value="{{ article_today.source_input if article_today and article_today.source_input else '' }}">
<!-- Favoriten-Dropdown -->
<select id="fav-select" class="w-40" onchange="useFavorite(this)">
<option value="">📌 Favoriten</option>
{% for f in favorites %}
<option value="{{ f.url }}">{{ f.label }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Tag + Prompt -->
<div class="flex gap-3">
<div class="flex-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Thema</label>
<select id="tag-select" class="w-full">
{% for t in tags %}
<option value="{{ t.name }}" {% if article_today and article_today.tag == t.name %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="flex-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Prompt</label>
<select id="prompt-select" class="w-full">
{% for p in prompts %}
<option value="{{ p.id }}" {% if p.is_default %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Generieren -->
<button onclick="generateArticle()" id="gen-btn" class="btn btn-primary w-full py-2.5">
⚡ Artikel generieren
</button>
<!-- Editor -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Artikel bearbeiten</label>
<span id="char-count" class="text-xs text-slate-500"></span>
</div>
<textarea id="article-editor" rows="14"
class="w-full px-3 py-2.5 text-sm"
placeholder="Hier erscheint der generierte Artikel — direkt bearbeitbar…"
oninput="updatePreview()">{{ article_today.content_final if article_today and article_today.content_final else '' }}</textarea>
</div>
<!-- Aktions-Buttons -->
<div class="flex gap-2 flex-wrap">
<button onclick="regenerate()" class="btn btn-ghost">🔄 Neu generieren</button>
<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 -->
<div class="space-y-4">
<!-- Telegram-Vorschau -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide">📱 Telegram-Vorschau</label>
<span id="tg-char" class="text-xs text-slate-500"></span>
</div>
<div class="tg-preview rounded-xl p-4 text-sm min-h-[200px]" id="tg-preview-box">
<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>
</div>
</div>
<!-- Posting-Timer kompakt -->
<div class="card p-4 border border-slate-600">
<div class="flex items-center justify-between mb-2">
<h3 class="text-white font-semibold text-sm">⏰ Posting</h3>
<div class="flex items-center gap-2">
<input type="time" id="post-time-input" value="{{ channel.post_time or '19:55' }}" class="w-28 text-xs py-1">
<span class="text-xs text-slate-500">🇩🇪</span>
<button onclick="savePostTime()" class="text-xs text-slate-500 hover:text-white">💾</button>
<span id="save-time-ok" class="text-green-400 text-xs hidden"></span>
</div>
</div>
<div class="text-xs text-slate-400">
<span class="text-white font-semibold" id="next-post-time">{{ channel.post_time or '19:55' }} Uhr 🇩🇪</span>
<span id="next-post-local" class="text-slate-500 ml-2"></span>
</div>
<div id="studio-status-line" class="mt-1 text-xs text-slate-500"></div>
</div>
</div>
</div>
</div>
<!-- ═══ REDAKTIONSPLAN ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Planungsliste: Nächste 7 Tage -->
<div class="card p-5 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<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-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 arts = plan_articles.get(d, []) %}
{% set is_today = (d == today) %}
<!-- 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>
{% 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 }}">
<!-- 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.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 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>
<!-- 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 %}
{% endfor %}
</div>
</div>
<!-- Monatskalender + Stats -->
<div class="space-y-4">
<!-- Mini-Monatskalender -->
<div class="card p-4">
<h3 class="text-white font-semibold mb-3 text-sm">📆 Monatsübersicht</h3>
<div class="grid grid-cols-7 gap-1 text-center text-xs mb-2">
<div class="text-slate-500 font-semibold">Mo</div>
<div class="text-slate-500 font-semibold">Di</div>
<div class="text-slate-500 font-semibold">Mi</div>
<div class="text-slate-500 font-semibold">Do</div>
<div class="text-slate-500 font-semibold">Fr</div>
<div class="text-slate-500 font-semibold">Sa</div>
<div class="text-slate-500 font-semibold">So</div>
</div>
<div id="month-grid" class="grid grid-cols-7 gap-1 text-center text-xs"></div>
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500">
<span><span class="inline-block w-2 h-2 rounded-full bg-blue-500 mr-1"></span>Gepostet</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Geplant</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-yellow-500 mr-1"></span>Entwurf</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-slate-600 mr-1"></span>Leer</span>
</div>
</div>
<!-- Stats -->
<div class="card p-4 border border-slate-700">
<h3 class="text-slate-300 font-semibold mb-2 text-sm">📊 {{ today[:7] }}</h3>
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<div class="text-2xl font-bold text-green-400">{{ stats.posted }}</div>
<div class="text-xs text-slate-400">Gepostet</div>
</div>
<div>
<div class="text-2xl font-bold text-slate-400">{{ stats.skipped }}</div>
<div class="text-xs text-slate-400">Skip</div>
</div>
<div>
<div class="text-2xl font-bold text-blue-400">{{ stats.avg_version }}×</div>
<div class="text-xs text-slate-400">Ø Regen.</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-slate-700">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400">💰 OpenRouter</span>
<button onclick="loadBalance()" class="text-xs text-slate-500 hover:text-white">🔄</button>
</div>
<div id="balance-inline" class="text-sm font-semibold text-green-400 mt-1">laden…</div>
</div>
</div>
<!-- Letzte Posts kompakt -->
<div class="card p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-slate-300 font-semibold text-sm">📋 Letzte Posts</h3>
<a href="/history" class="text-blue-400 text-xs hover:underline">Alle →</a>
</div>
{% for art in recent[:4] %}
<div class="flex items-center gap-2 py-1.5 border-b border-slate-700/30 last:border-0 cursor-pointer hover:bg-slate-700/30 rounded px-1 -mx-1"
onclick="loadDate('{{ art.date }}')">
<span class="text-xs text-slate-500 w-16 shrink-0">{{ art.date[5:] }}</span>
<span class="text-xs text-slate-400 flex-1 truncate">{{ art.content_final[:50] if art.content_final else '—' }}</span>
<span class="text-xs">{{ {'posted':'📤','approved':'✅','sent_to_bot':'📱','draft':'📝','skipped':'⏭️'}.get(art.status,'?') }}</span>
</div>
{% else %}
<div class="text-slate-500 text-xs py-2">Keine Artikel.</div>
{% endfor %}
</div>
</div>
</div>
</div>
<script>
const TODAY = "{{ today }}";
let selectedDate = TODAY;
let userTimezone = "{{ current_location.timezone if current_location else 'Europe/Berlin' }}";
let userFlag = "{{ current_location.flag if current_location else '🇩🇪' }}";
const BERLIN_TZ = "Europe/Berlin";
const MONTH_ARTICLES = {{ month_articles | tojson }};
function getBerlinTime(date) {
return new Date(date.toLocaleString('en-US', {timeZone: BERLIN_TZ}));
}
function formatTime(date, tz) {
return date.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit', timeZone: tz});
}
function formatTimeShort(date, tz) {
return date.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', timeZone: tz});
}
function updateClock() {
const now = new Date();
document.getElementById('live-clock-berlin').textContent = formatTime(now, BERLIN_TZ);
const isLocal = userTimezone !== BERLIN_TZ;
const localRow = document.getElementById('local-clock-row');
if (isLocal) {
localRow.style.display = 'flex';
document.getElementById('live-clock-local').textContent = formatTime(now, userTimezone);
document.getElementById('local-flag').textContent = userFlag;
} else {
localRow.style.display = 'none';
}
const postTime = document.getElementById('post-time-input').value || '19:55';
const [ph, pm] = postTime.split(':').map(Number);
const berlinNow = getBerlinTime(now);
const berlinPost = new Date(berlinNow);
berlinPost.setHours(ph, pm, 0, 0);
if (berlinPost <= berlinNow) berlinPost.setDate(berlinPost.getDate() + 1);
const diff = Math.floor((berlinPost - berlinNow) / 1000);
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
document.getElementById('countdown').textContent =
`Posting in ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
updateLocalPostTime();
}
function updateLocalPostTime() {
const el = document.getElementById('next-post-local');
if (userTimezone === BERLIN_TZ) {
el.textContent = '';
return;
}
const postTime = document.getElementById('post-time-input').value || '19:55';
const [ph, pm] = postTime.split(':').map(Number);
const now = new Date();
const berlinStr = now.toLocaleDateString('en-US', {timeZone: BERLIN_TZ});
const berlinDate = new Date(berlinStr);
berlinDate.setHours(ph, pm, 0, 0);
const utcOffset = berlinDate.getTime() - new Date(berlinDate.toLocaleString('en-US', {timeZone: BERLIN_TZ})).getTime();
const postUTC = new Date(berlinDate.getTime() + utcOffset);
const localTimeStr = postUTC.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', timeZone: userTimezone});
el.textContent = `= ${localTimeStr} Uhr ${userFlag}`;
}
setInterval(updateClock, 1000);
updateClock();
// ── Telegram-Vorschau rendern ──────────────────────────────────────────────
function renderTelegram(raw) {
if (!raw) return '<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>';
let html = raw
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/&lt;b&gt;([\s\S]*?)&lt;\/b&gt;/g, '<strong>$1</strong>')
.replace(/&lt;i&gt;([\s\S]*?)&lt;\/i&gt;/g, '<em class="text-slate-300">$1</em>')
.replace(/&lt;a href="(.*?)"&gt;(.*?)&lt;\/a&gt;/g, '<a href="$1" class="text-blue-400 underline">$2</a>')
.replace(/\n/g, '<br>');
return html;
}
function updatePreview() {
const text = document.getElementById('article-editor').value;
const len = text.length;
document.getElementById('tg-preview-box').innerHTML = renderTelegram(text);
document.getElementById('char-count').textContent = `${len} Zeichen`;
document.getElementById('tg-char').textContent = `${len}/4096 ${len > 4096 ? '⚠️' : '✓'}`;
}
// Init Preview wenn Artikel vorhanden
const initText = document.getElementById('article-editor').value;
if (initText) updatePreview();
// ── Favorit verwenden ──────────────────────────────────────────────────────
function useFavorite(sel) {
if (sel.value) {
document.getElementById('source-input').value = sel.value;
sel.value = '';
}
}
// ── Editor leeren ─────────────────────────────────────────────────────────
function clearStudio() {
selectedDate = TODAY;
document.getElementById('studio-date').textContent = TODAY;
document.getElementById('source-input').value = '';
document.getElementById('article-editor').value = '';
document.getElementById('tg-preview-box').innerHTML = '<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>';
document.getElementById('char-count').textContent = '';
document.getElementById('tg-char').textContent = '';
updateStatusBadge('draft', '📝 Neu');
document.querySelectorAll('[id^="plan-row-"]').forEach(el => {
el.classList.remove('ring-2', 'ring-yellow-500');
});
}
// ── Datum wechseln (Redaktionsplan klick) ─────────────────────────────────
async function loadDate(dateStr) {
selectedDate = dateStr;
document.getElementById('studio-date').textContent = dateStr;
document.querySelectorAll('[id^="plan-row-"]').forEach(el => {
el.classList.remove('ring-2', 'ring-yellow-500');
});
const row = document.getElementById('plan-row-' + dateStr);
if (row) row.classList.add('ring-2', 'ring-yellow-500');
try {
const r = await fetch('/api/article/' + dateStr);
if (r.ok) {
const art = await r.json();
document.getElementById('source-input').value = art.source_input || '';
document.getElementById('article-editor').value = art.content_final || '';
const statusMap = {draft:'📝 Entwurf',sent_to_bot:'📱 Beim Bot',approved:'✅ Freigegeben',posted:'📤 Gepostet',skipped:'⏭️ Übersprungen',pending_review:'⏳ Offen'};
updateStatusBadge(art.status, statusMap[art.status] || art.status);
updatePreview();
} else {
document.getElementById('source-input').value = '';
document.getElementById('article-editor').value = '';
updateStatusBadge('draft', '📝 Neu');
document.getElementById('tg-preview-box').innerHTML = '<span class="text-slate-500 italic">Kein Artikel für dieses Datum</span>';
document.getElementById('char-count').textContent = '';
document.getElementById('tg-char').textContent = '';
}
} catch(e) {
console.error('loadDate error:', e);
}
}
// ── Artikel generieren ─────────────────────────────────────────────────────
async function generateArticle() {
const source = document.getElementById('source-input').value.trim();
if (!source) { alert('Bitte Quelle oder Thema eingeben'); return; }
const tag = document.getElementById('tag-select').value;
const promptId = document.getElementById('prompt-select').value;
const btn = document.getElementById('gen-btn');
btn.innerHTML = '<span class="spinner">⚙️</span> Generiere…';
btn.disabled = true;
try {
const r = await fetch('/api/generate', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, tag, prompt_id: promptId, date: selectedDate})
});
const d = await r.json();
if (d.success) {
document.getElementById('article-editor').value = d.content;
updatePreview();
updateStatusBadge('draft', '📝 Entwurf');
} else {
alert('Fehler: ' + d.error);
}
} catch(e) {
alert('Verbindungsfehler: ' + e);
} finally {
btn.innerHTML = '⚡ Artikel generieren';
btn.disabled = false;
}
}
async function regenerate() { await generateArticle(); }
// ── 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, post_time: channel})
});
showToast('💾 Gespeichert (' + selectedDate + ')');
}
// ── Einplan-Panel ──────────────────────────────────────────────────────────
function togglePlanPanel() {
const panel = document.getElementById('plan-panel');
const content = document.getElementById('article-editor').value.trim();
if (!content) { showToast('⚠️ Bitte erst Artikel generieren oder eingeben.'); return; }
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;
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');
// 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');
// 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;
}
}
// ── Überspringen ───────────────────────────────────────────────────────────
async function skipToday() {
if (!confirm(selectedDate + ' überspringen?')) return;
await fetch('/api/article/' + selectedDate + '/skip', {method:'POST'});
updateStatusBadge('skipped', '⏭️ Übersprungen');
}
// ── Posting-Zeit speichern ─────────────────────────────────────────────────
async function savePostTime() {
const t = document.getElementById('post-time-input').value;
await fetch('/api/settings/post-time', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({post_time: t})
});
document.getElementById('next-post-time').textContent = t + ' Uhr';
const ok = document.getElementById('save-time-ok');
ok.classList.remove('hidden');
setTimeout(() => ok.classList.add('hidden'), 2000);
}
// ── Status-Badge aktualisieren ─────────────────────────────────────────────
function updateStatusBadge(status, label) {
const badge = document.getElementById('status-badge');
badge.textContent = label;
badge.className = 'border text-xs px-3 py-1 rounded-full 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 ─────────────────────────────────────────────────────────
function toggleLocationMenu() {
const menu = document.getElementById('location-menu');
menu.classList.toggle('hidden');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('#location-btn') && !e.target.closest('#location-menu')) {
document.getElementById('location-menu').classList.add('hidden');
}
});
async function setLocation(id, flag, name) {
const r = await fetch('/api/settings/location', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({location_id: id})
});
const d = await r.json();
if (d.success) {
document.getElementById('location-flag').textContent = flag;
document.getElementById('location-name').textContent = name;
document.getElementById('location-menu').classList.add('hidden');
userTimezone = d.location.timezone;
userFlag = flag;
updateClock();
showToast(`📍 ${flag} ${name} — Reminder: ${d.reminders_berlin.morning} & ${d.reminders_berlin.afternoon} Uhr (Berlin)`);
}
}
function showToast(msg) {
const t = document.createElement('div');
t.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-700 border border-slate-500 text-white text-sm px-5 py-3 rounded-xl shadow-xl z-50 transition-opacity';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 500); }, 4000);
}
// ── OpenRouter Balance ─────────────────────────────────────────────────────
async function loadBalance() {
const el = document.getElementById('balance-inline');
try {
const r = await fetch('/api/balance');
const d = await r.json();
if (d.remaining !== null && d.remaining !== undefined) {
el.textContent = '$' + d.remaining.toFixed(4);
el.className = d.remaining < 0.5
? 'text-sm font-semibold text-red-400 mt-1'
: 'text-sm font-semibold text-green-400 mt-1';
} else if (d.limit === null) {
el.textContent = '∞ Free Tier';
el.className = 'text-sm font-semibold text-blue-400 mt-1';
} else {
el.textContent = '—';
}
} catch { el.textContent = 'Fehler'; }
}
// Balance nur auf Knopfdruck laden, nicht automatisch beim Start
// ── Monatskalender rendern ────────────────────────────────────────────────
function renderMonthCalendar() {
const grid = document.getElementById('month-grid');
if (!grid) return;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = new Date(year, month, 1);
let startWeekday = firstDay.getDay() - 1;
if (startWeekday < 0) startWeekday = 6;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const todayDate = now.getDate();
let html = '';
for (let i = 0; i < startWeekday; i++) {
html += '<div></div>';
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const status = MONTH_ARTICLES[dateStr];
const isToday = d === todayDate;
let dotColor = 'bg-slate-700';
if (status === 'posted') dotColor = 'bg-blue-500';
else if (status === 'approved' || status === 'sent_to_bot') dotColor = 'bg-green-500';
else if (status === 'draft' || status === 'pending_review') dotColor = 'bg-yellow-500';
else if (status === 'skipped') dotColor = 'bg-slate-600';
html += `<div onclick="loadDate('${dateStr}')"
class="py-1 rounded cursor-pointer hover:bg-slate-600/50 transition
${isToday ? 'ring-1 ring-blue-400 font-bold text-blue-400' : 'text-slate-400'}">
${d}
<div class="w-1.5 h-1.5 rounded-full ${dotColor} mx-auto mt-0.5"></div>
</div>`;
}
grid.innerHTML = html;
}
renderMonthCalendar();
</script>
</body>
</html>