feat: Dashboard-Pipeline, AudioHub, WordGenerator, reviewed-Status

- Dashboard: Pipeline-Übersicht (Counts pro Status) + Werkzeug-Kacheln
- AudioHub (/audio): Coverage-Matrix je Tabelle×Sprache, Generieren-Buttons, Player
- WordGenerator (/content/words): Thema→KI-Vorschau→Übernehmen als translated
- reviewed in STATUS_COLORS + Status-Optionen (objects/questions/statements/pairs)
- audios-Tabelle um source_*/language erweitert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:30:03 +02:00
parent 232ba1ece5
commit 465c6e4954
7 changed files with 454 additions and 29 deletions

View File

@@ -1,7 +1,31 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import { fetchAll } from '../lib/api';
import { STATUS_COLORS } from '../lib/tables';
const TILES = [
{
title: 'Content erstellen',
icon: '✏️',
description: 'Objekte markieren, Pairs & Sätze erstellen und prüfen.',
path: '/content',
color: 'border-amber-200 hover:border-amber-400 hover:bg-amber-50',
},
{
title: 'Audio / TTS',
icon: '🔊',
description: 'Sehen was noch kein Audio hat und Sprachausgabe generieren.',
path: '/audio',
color: 'border-purple-200 hover:border-purple-400 hover:bg-purple-50',
},
{
title: 'Wörter generieren',
icon: '🪄',
description: 'Neue Wörter zu einem Thema per KI erzeugen lassen.',
path: '/content/words',
color: 'border-emerald-200 hover:border-emerald-400 hover:bg-emerald-50',
},
{
title: 'Datenbankverwaltung',
icon: '🗄️',
@@ -9,37 +33,85 @@ const TILES = [
path: '/db',
color: 'border-indigo-200 hover:border-indigo-400 hover:bg-indigo-50',
},
{
title: 'Contentverwaltung',
icon: '✏️',
description: 'Inhalte erstellen, bearbeiten und veröffentlichen.',
path: null,
color: 'border-slate-200 hover:border-slate-300 bg-slate-50 opacity-60 cursor-not-allowed',
soon: true,
},
];
// Pipeline-Stufen, die den Lebenszyklus eines Inhalts abbilden.
const PIPELINES = [
{ key: 'pictures', label: 'Bilder', endpoint: '/pictures', stages: ['uploaded', 'published', 'blocked'] },
{ key: 'objects', label: 'Objekte', endpoint: '/objects', stages: ['draft', 'reviewed', 'published', 'blocked'] },
{ key: 'pairs', label: 'Pairs', endpoint: '/pairs', stages: ['draft', 'reviewed', 'published', 'blocked'] },
{ key: 'words', label: 'Wörter', endpoint: '/words', stages: ['requested', 'translated', 'generated', 'published', 'blocked'] },
{ key: 'audios', label: 'Audios', endpoint: '/audios', stages: ['generated', 'published', 'blocked'] },
];
function countByStatus(rows) {
const out = {};
for (const r of rows || []) out[r.status] = (out[r.status] || 0) + 1;
return out;
}
export default function Dashboard() {
const navigate = useNavigate();
const [counts, setCounts] = useState(null);
useEffect(() => {
let active = true;
(async () => {
const entries = await Promise.all(
PIPELINES.map(async p => {
try { return [p.key, countByStatus(await fetchAll(p.endpoint))]; }
catch { return [p.key, {}]; }
})
);
if (active) setCounts(Object.fromEntries(entries));
})();
return () => { active = false; };
}, []);
return (
<Layout>
<h2 className="text-xl font-semibold text-slate-700 mb-6">Dashboard</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 max-w-3xl">
<h2 className="text-xl font-semibold text-slate-700 mb-2">Dashboard</h2>
<p className="text-sm text-slate-500 mb-6 max-w-2xl">
Der Inhalts-Lebenszyklus: <b>draft</b> (erstellt) <b>reviewed</b> (im Tool geprüft) {' '}
<b>published</b> (fertig inkl. Audio, in der App sichtbar). Nur veröffentlichte Inhalte mit
Bild und Audio erscheinen für Lernende.
</p>
{/* Pipeline-Übersicht */}
<div className="bg-white rounded-2xl border border-slate-200 p-5 mb-8 max-w-4xl">
<h3 className="text-sm font-semibold text-slate-600 uppercase tracking-wide mb-4">Pipeline-Übersicht</h3>
<div className="space-y-3">
{PIPELINES.map(p => (
<div key={p.key} className="flex items-center gap-3">
<div className="w-20 shrink-0 text-sm font-medium text-slate-700">{p.label}</div>
<div className="flex flex-wrap gap-1.5">
{p.stages.map(stage => {
const n = counts?.[p.key]?.[stage] ?? null;
const cls = STATUS_COLORS[stage] || 'bg-gray-100 text-gray-600';
return (
<span key={stage} className={`${cls} rounded-full px-2.5 py-0.5 text-xs font-medium`}>
{stage}: {counts ? (n ?? 0) : '…'}
</span>
);
})}
</div>
</div>
))}
</div>
<p className="text-[11px] text-slate-400 mt-3">Zählung bis max. 500 pro Tabelle.</p>
</div>
{/* Werkzeuge */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 max-w-4xl">
{TILES.map(tile => (
<div
key={tile.title}
onClick={() => tile.path && navigate(tile.path)}
className={`bg-white rounded-2xl border-2 p-6 transition-all ${tile.color} ${tile.path ? 'cursor-pointer' : ''}`}
onClick={() => navigate(tile.path)}
className={`bg-white rounded-2xl border-2 p-6 transition-all cursor-pointer ${tile.color}`}
>
<div className="text-4xl mb-3">{tile.icon}</div>
<h3 className="font-semibold text-slate-800 text-lg mb-1">{tile.title}</h3>
<p className="text-slate-500 text-sm">{tile.description}</p>
{tile.soon && (
<span className="mt-3 inline-block text-xs bg-slate-200 text-slate-500 rounded-full px-2 py-0.5">
Demnächst
</span>
)}
</div>
))}
</div>