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

@@ -6,6 +6,8 @@ import DatabaseAdmin from './pages/DatabaseAdmin';
import TableView from './pages/TableView'; import TableView from './pages/TableView';
import ContentHub from './pages/ContentHub'; import ContentHub from './pages/ContentHub';
import ContentCreation from './pages/ContentCreation'; import ContentCreation from './pages/ContentCreation';
import AudioHub from './pages/AudioHub';
import WordGenerator from './pages/WordGenerator';
function RequireAuth({ children }) { function RequireAuth({ children }) {
const user = getUser(); const user = getUser();
@@ -23,6 +25,8 @@ export default function App() {
<Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} /> <Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} />
<Route path="/content" element={<RequireAuth><ContentHub /></RequireAuth>} /> <Route path="/content" element={<RequireAuth><ContentHub /></RequireAuth>} />
<Route path="/content/creation" element={<RequireAuth><ContentCreation /></RequireAuth>} /> <Route path="/content/creation" element={<RequireAuth><ContentCreation /></RequireAuth>} />
<Route path="/audio" element={<RequireAuth><AudioHub /></RequireAuth>} />
<Route path="/content/words" element={<RequireAuth><WordGenerator /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -85,7 +85,7 @@ export const TABLES = {
editableFields: { editableFields: {
notes: { type: 'textarea' }, notes: { type: 'textarea' },
blocked_topic:{ type: 'text' }, blocked_topic:{ type: 'text' },
status: { type: 'select', options: ['draft', 'published', 'blocked'] }, status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
}, },
fetchRelated: [ fetchRelated: [
{ {
@@ -127,7 +127,7 @@ export const TABLES = {
answer_type: { type: 'select', options: ['yes_no', 'text', 'question', 'word'] }, answer_type: { type: 'select', options: ['yes_no', 'text', 'question', 'word'] },
difficulty_level: { type: 'number', min: 1, max: 50 }, difficulty_level: { type: 'number', min: 1, max: 50 },
blocked_topic: { type: 'text' }, blocked_topic: { type: 'text' },
status: { type: 'select', options: ['draft', 'published', 'blocked'] }, status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
}, },
fetchRelated: [], fetchRelated: [],
}, },
@@ -145,7 +145,7 @@ export const TABLES = {
sentence_en: { type: 'textarea' }, sentence_en: { type: 'textarea' },
sentence_sv: { type: 'textarea' }, sentence_sv: { type: 'textarea' },
blocked_topic:{ type: 'text' }, blocked_topic:{ type: 'text' },
status: { type: 'select', options: ['draft', 'published', 'blocked'] }, status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
}, },
fetchRelated: [], fetchRelated: [],
}, },
@@ -167,7 +167,7 @@ export const TABLES = {
negative_sentence_sv: { type: 'textarea' }, negative_sentence_sv: { type: 'textarea' },
answer: { type: 'boolean' }, answer: { type: 'boolean' },
blocked_topic: { type: 'text' }, blocked_topic: { type: 'text' },
status: { type: 'select', options: ['draft', 'published', 'blocked'] }, status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
}, },
fetchRelated: [], fetchRelated: [],
}, },
@@ -280,14 +280,18 @@ export const TABLES = {
endpoint: '/audios', endpoint: '/audios',
statusField: 'status', statusField: 'status',
primaryLabel: 'text', primaryLabel: 'text',
columns: ['text', 'voice_id', 'status', 'audio_link', 'created_at'], columns: ['text', 'language', 'source_table', 'source_field', 'status', 'audio_link', 'voice_id', 'created_at'],
linkedFields: {}, linkedFields: {},
editableFields: { editableFields: {
text: { type: 'textarea' }, text: { type: 'textarea' },
status: { type: 'select', options: ['generated', 'published', 'blocked'] }, status: { type: 'select', options: ['generated', 'published', 'blocked'] },
voice_id: { type: 'text' }, language: { type: 'select', options: ['de', 'en', 'sv'] },
model_id: { type: 'text' }, source_table: { type: 'select', options: ['words', 'questions', 'statements'] },
audio_link: { type: 'text' }, source_id: { type: 'text' },
source_field: { type: 'text' },
voice_id: { type: 'text' },
model_id: { type: 'text' },
audio_link: { type: 'text' },
}, },
fetchRelated: [], fetchRelated: [],
}, },
@@ -297,6 +301,7 @@ export const STATUS_COLORS = {
published: 'bg-violet-100 text-violet-800', published: 'bg-violet-100 text-violet-800',
blocked: 'bg-red-100 text-red-800', blocked: 'bg-red-100 text-red-800',
draft: 'bg-gray-100 text-gray-600', draft: 'bg-gray-100 text-gray-600',
reviewed: 'bg-teal-100 text-teal-800',
uploaded: 'bg-blue-100 text-blue-800', uploaded: 'bg-blue-100 text-blue-800',
requested: 'bg-yellow-100 text-yellow-800', requested: 'bg-yellow-100 text-yellow-800',
translated: 'bg-indigo-100 text-indigo-800', translated: 'bg-indigo-100 text-indigo-800',

155
src/pages/AudioHub.jsx Normal file
View File

@@ -0,0 +1,155 @@
import { useEffect, useState, useCallback } from 'react';
import Layout from '../components/Layout';
import { apiFetch, apiPost, apiPatch, fetchAll } from '../lib/api';
import { STATUS_COLORS } from '../lib/tables';
const SOURCE_LABELS = { words: 'Wörter', questions: 'Fragen', statements: 'Statements' };
const LANGS = [
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'sv', label: 'Svenska', flag: '🇸🇪' },
];
const SOURCES = ['words', 'questions', 'statements'];
function pct(have, total) {
if (!total) return 0;
return Math.round((have / total) * 100);
}
export default function AudioHub() {
const [coverage, setCoverage] = useState(null); // { 'words|de': {total, withAudio, missing} }
const [audios, setAudios] = useState([]);
const [busy, setBusy] = useState(null); // `${table}|${lang}` currently generating
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
const loadCoverage = useCallback(async () => {
try {
const { coverage } = await apiFetch('/audios/coverage');
const map = {};
for (const g of coverage) map[`${g.source_table}|${g.language}`] = g;
setCoverage(map);
} catch (e) { setError(e.message); }
}, []);
const loadAudios = useCallback(async () => {
try { setAudios(await fetchAll('/audios')); } catch { /* ignore */ }
}, []);
useEffect(() => { loadCoverage(); loadAudios(); }, [loadCoverage, loadAudios]);
async function generateMissing(table, lang) {
const key = `${table}|${lang}`;
setBusy(key); setError(null); setProgress('Generiere fehlende Audios …');
try {
const res = await apiPost('/audios/generate-batch', { source_table: table, language: lang });
setProgress(`Fertig: ${res.generated} erzeugt${res.failed ? `, ${res.failed} fehlgeschlagen` : ''}.`);
await loadCoverage();
await loadAudios();
} catch (e) {
setError(e.message);
setProgress(null);
} finally {
setBusy(null);
setTimeout(() => setProgress(null), 4000);
}
}
async function setStatus(id, status) {
await apiPatch('/audios', id, { status });
await loadAudios();
}
return (
<Layout back="/">
<div className="max-w-5xl mx-auto">
<h1 className="text-2xl font-bold text-slate-800 mb-2">Audio / TTS</h1>
<p className="text-slate-500 mb-4">
Hier siehst du, wie viele Sätze & Wörter noch <b>kein Audio</b> haben, und kannst die
Sprachausgabe (ElevenLabs) per Knopfdruck erzeugen.
</p>
<div className="bg-blue-50 border border-blue-200 text-blue-800 text-sm rounded-xl px-4 py-3 mb-6">
<b>So funktioniert's:</b> „Benötigt" zählt geprüfte/veröffentlichte Inhalte
(Wörter ab Status <i>generated</i>, Fragen/Statements ab <i>reviewed</i>) mit Text in der jeweiligen
Sprache. Erst wenn ein Pair für die Zielsprache komplett vertont ist, erscheint es in der App.
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl px-4 py-2 mb-4">{error}</div>
)}
{progress && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 text-sm rounded-xl px-4 py-2 mb-4">{progress}</div>
)}
{/* Coverage-Matrix */}
<div className="space-y-6 mb-10">
{SOURCES.map(table => (
<div key={table} className="bg-white rounded-2xl border border-slate-200 p-5">
<h2 className="font-semibold text-slate-700 mb-4">{SOURCE_LABELS[table]}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{LANGS.map(lang => {
const g = coverage?.[`${table}|${lang.code}`];
const total = g?.total ?? 0;
const have = g?.withAudio ?? 0;
const missing = g?.missing ?? 0;
const key = `${table}|${lang.code}`;
const p = pct(have, total);
return (
<div key={lang.code} className="border border-slate-100 rounded-xl p-3 bg-slate-50/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700">{lang.flag} {lang.label}</span>
<span className="text-xs text-slate-500">{coverage ? `${have}/${total}` : ''}</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden mb-2">
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${p}%` }} />
</div>
<div className="flex items-center justify-between">
<span className={`text-xs ${missing ? 'text-amber-600' : 'text-emerald-600'}`}>
{missing ? `${missing} fehlen` : 'vollständig'}
</span>
<button
disabled={!missing || busy === key}
onClick={() => generateMissing(table, lang.code)}
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors
${!missing
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: busy === key
? 'bg-purple-300 text-white cursor-wait'
: 'bg-purple-600 text-white hover:bg-purple-500'}`}
>
{busy === key ? 'Läuft ' : 'Generieren'}
</button>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
{/* Audio-Liste */}
<h2 className="font-semibold text-slate-700 mb-3">Erzeugte Audios ({audios.length})</h2>
<div className="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
{audios.length === 0 && (
<div className="p-5 text-sm text-slate-400">Noch keine Audios erzeugt.</div>
)}
{audios.slice(0, 100).map(a => (
<div key={a.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="text-xs uppercase font-semibold text-slate-400 w-8">{a.language}</span>
<span className="text-xs text-slate-400 w-24 truncate">{a.source_table}</span>
<span className="flex-1 text-sm text-slate-700 truncate">{a.text}</span>
{a.audio_link && <audio controls src={a.audio_link} className="h-8" />}
<span className={`${STATUS_COLORS[a.status] || ''} rounded-full px-2 py-0.5 text-xs font-medium`}>{a.status}</span>
{a.status !== 'published' && (
<button onClick={() => setStatus(a.id, 'published')}
className="text-xs text-violet-600 hover:text-violet-800">Freigeben</button>
)}
</div>
))}
</div>
</div>
</Layout>
);
}

View File

@@ -658,7 +658,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
<div className="border border-amber-200 rounded-xl p-3 bg-amber-50/20 space-y-3"> <div className="border border-amber-200 rounded-xl p-3 bg-amber-50/20 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wide shrink-0">Status</label> <label className="text-xs font-semibold text-slate-500 uppercase tracking-wide shrink-0">Status</label>
<StatusSelect value={pairStatus} options={['draft','published','blocked']} onChange={setPairStatus} saving={saving} /> <StatusSelect value={pairStatus} options={['draft','reviewed','published','blocked']} onChange={setPairStatus} saving={saving} />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Typ</label> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Typ</label>
@@ -832,7 +832,7 @@ function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAd
<span className="text-xs font-bold text-slate-400 shrink-0">#{i + 1}</span> <span className="text-xs font-bold text-slate-400 shrink-0">#{i + 1}</span>
<StatusSelect <StatusSelect
value={obj.status} value={obj.status}
options={['draft', 'published', 'blocked']} options={['draft', 'reviewed', 'published', 'blocked']}
onChange={s => onObjectStatusChange(obj.id, s)} onChange={s => onObjectStatusChange(obj.id, s)}
size="xs" size="xs"
/> />

View File

@@ -10,6 +10,22 @@ const TOOLS = [
path: '/content/creation', path: '/content/creation',
ready: true, ready: true,
}, },
{
key: 'words',
title: 'Wörter generieren',
icon: '🪄',
description: 'Neue Vokabeln zu einem Thema per KI erstellen, prüfen und übernehmen.',
path: '/content/words',
ready: true,
},
{
key: 'audio',
title: 'Audio / TTS',
icon: '🔊',
description: 'Sehen was noch kein Audio hat und Sprachausgabe generieren.',
path: '/audio',
ready: true,
},
{ {
key: 'publish', key: 'publish',
title: 'Veröffentlichen', title: 'Veröffentlichen',

View File

@@ -1,7 +1,31 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { fetchAll } from '../lib/api';
import { STATUS_COLORS } from '../lib/tables';
const TILES = [ 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', title: 'Datenbankverwaltung',
icon: '🗄️', icon: '🗄️',
@@ -9,37 +33,85 @@ const TILES = [
path: '/db', path: '/db',
color: 'border-indigo-200 hover:border-indigo-400 hover:bg-indigo-50', 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() { export default function Dashboard() {
const navigate = useNavigate(); 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 ( return (
<Layout> <Layout>
<h2 className="text-xl font-semibold text-slate-700 mb-6">Dashboard</h2> <h2 className="text-xl font-semibold text-slate-700 mb-2">Dashboard</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 max-w-3xl"> <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 => ( {TILES.map(tile => (
<div <div
key={tile.title} key={tile.title}
onClick={() => tile.path && navigate(tile.path)} onClick={() => navigate(tile.path)}
className={`bg-white rounded-2xl border-2 p-6 transition-all ${tile.color} ${tile.path ? 'cursor-pointer' : ''}`} className={`bg-white rounded-2xl border-2 p-6 transition-all cursor-pointer ${tile.color}`}
> >
<div className="text-4xl mb-3">{tile.icon}</div> <div className="text-4xl mb-3">{tile.icon}</div>
<h3 className="font-semibold text-slate-800 text-lg mb-1">{tile.title}</h3> <h3 className="font-semibold text-slate-800 text-lg mb-1">{tile.title}</h3>
<p className="text-slate-500 text-sm">{tile.description}</p> <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>
))} ))}
</div> </div>

173
src/pages/WordGenerator.jsx Normal file
View File

@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react';
import Layout from '../components/Layout';
import { apiPost, apiLink, fetchAll } from '../lib/api';
const DIFFICULTIES = [
{ value: '', label: 'beliebig' },
{ value: 'einfach', label: 'einfach' },
{ value: 'mittel', label: 'mittel' },
{ value: 'schwer', label: 'schwer' },
];
export default function WordGenerator() {
const [topic, setTopic] = useState('');
const [count, setCount] = useState(15);
const [difficulty, setDifficulty] = useState('');
const [categoryId, setCategoryId] = useState('');
const [categories, setCategories] = useState([]);
const [rows, setRows] = useState([]); // [{ titel_de, titel_en, titel_sv, include, saved, error }]
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [done, setDone] = useState(null);
useEffect(() => {
fetchAll('/categories').then(setCategories).catch(() => {});
}, []);
async function generate() {
if (!topic.trim()) return;
setLoading(true); setError(null); setDone(null); setRows([]);
try {
const { words } = await apiPost('/claude/generate-words', {
topic: topic.trim(), count: Number(count) || 15, difficulty: difficulty || undefined,
});
setRows((words || []).map(w => ({ ...w, include: true, saved: false, error: null })));
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}
function updateRow(i, patch) {
setRows(rs => rs.map((r, idx) => idx === i ? { ...r, ...patch } : r));
}
async function save() {
setSaving(true); setError(null); setDone(null);
let ok = 0, fail = 0;
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (!r.include || r.saved) continue;
try {
const word = await apiPost('/words', {
titel_de: r.titel_de || null,
titel_en: r.titel_en || null,
titel_sv: r.titel_sv || null,
status: 'translated', // Claude liefert alle Sprachen → bereit für Bildgenerierung
});
if (categoryId) {
await apiLink(`/words/${word.id}/categories/${categoryId}`).catch(() => {});
}
updateRow(i, { saved: true, error: null });
ok++;
} catch (e) {
updateRow(i, { error: e.message });
fail++;
}
}
setSaving(false);
setDone(`${ok} Wörter übernommen${fail ? `, ${fail} fehlgeschlagen` : ''} (Status: translated).`);
}
const includedCount = rows.filter(r => r.include && !r.saved).length;
return (
<Layout back="/content">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-slate-800 mb-2">Wörter generieren</h1>
<p className="text-slate-500 mb-4">
Lass dir per KI neue Vokabeln zu einem Thema erstellen, prüfe sie und übernimm sie in die Datenbank.
</p>
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-3 mb-6">
Übernommene Wörter erhalten den Status <b>translated</b> (alle Sprachen vorhanden) und werden so
automatisch von der Bildgenerierung aufgegriffen.
</div>
{/* Eingabe */}
<div className="bg-white rounded-2xl border border-slate-200 p-5 mb-6 space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Thema / Kategorie</label>
<input
value={topic}
onChange={e => setTopic(e.target.value)}
onKeyDown={e => e.key === 'Enter' && generate()}
placeholder='z.B. "Wörter zum Thema Küche" oder "Tiere im Wald"'
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div>
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Anzahl</label>
<input type="number" min={1} max={50} value={count}
onChange={e => setCount(e.target.value)}
className="w-24 border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Schwierigkeit</label>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
className="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-400">
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</div>
<div className="flex-1 min-w-[180px]">
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Kategorie verknüpfen (optional)</label>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-400">
<option value=""> keine </option>
{categories.map(c => <option key={c.id} value={c.id}>{c.titel_de || c.titel_en || c.id}</option>)}
</select>
</div>
</div>
<button
onClick={generate}
disabled={loading || !topic.trim()}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
${loading || !topic.trim() ? 'bg-slate-200 text-slate-400 cursor-not-allowed' : 'bg-emerald-600 text-white hover:bg-emerald-500'}`}
>
{loading ? 'Generiere …' : '🪄 Wörter generieren'}
</button>
</div>
{error && <div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl px-4 py-2 mb-4">{error}</div>}
{done && <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-2 mb-4">{done}</div>}
{/* Vorschau */}
{rows.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-slate-700">Vorschau ({rows.length})</h2>
<button
onClick={save}
disabled={saving || includedCount === 0}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
${saving || includedCount === 0 ? 'bg-slate-200 text-slate-400 cursor-not-allowed' : 'bg-indigo-600 text-white hover:bg-indigo-500'}`}
>
{saving ? 'Speichere …' : `${includedCount} übernehmen`}
</button>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[28px_1fr_1fr_1fr_70px] gap-2 text-xs font-semibold text-slate-400 uppercase tracking-wide px-1">
<span></span><span>Deutsch</span><span>English</span><span>Svenska</span><span></span>
</div>
{rows.map((r, i) => (
<div key={i} className={`grid grid-cols-[28px_1fr_1fr_1fr_70px] gap-2 items-center ${r.saved ? 'opacity-50' : ''}`}>
<input type="checkbox" checked={r.include} disabled={r.saved}
onChange={e => updateRow(i, { include: e.target.checked })} className="justify-self-center" />
<input value={r.titel_de} disabled={r.saved} onChange={e => updateRow(i, { titel_de: e.target.value })}
className="border border-slate-200 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-400" />
<input value={r.titel_en} disabled={r.saved} onChange={e => updateRow(i, { titel_en: e.target.value })}
className="border border-slate-200 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-400" />
<input value={r.titel_sv} disabled={r.saved} onChange={e => updateRow(i, { titel_sv: e.target.value })}
className="border border-slate-200 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-400" />
<span className="text-xs text-center">
{r.saved ? <span className="text-emerald-600"></span> : r.error ? <span className="text-red-500" title={r.error}>Fehler</span> : ''}
</span>
</div>
))}
</div>
</div>
)}
</div>
</Layout>
);
}