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:
@@ -6,6 +6,8 @@ import DatabaseAdmin from './pages/DatabaseAdmin';
|
||||
import TableView from './pages/TableView';
|
||||
import ContentHub from './pages/ContentHub';
|
||||
import ContentCreation from './pages/ContentCreation';
|
||||
import AudioHub from './pages/AudioHub';
|
||||
import WordGenerator from './pages/WordGenerator';
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const user = getUser();
|
||||
@@ -23,6 +25,8 @@ export default function App() {
|
||||
<Route path="/db/:tableKey" element={<RequireAuth><TableView /></RequireAuth>} />
|
||||
<Route path="/content" element={<RequireAuth><ContentHub /></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 />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const TABLES = {
|
||||
editableFields: {
|
||||
notes: { type: 'textarea' },
|
||||
blocked_topic:{ type: 'text' },
|
||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
||||
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||
},
|
||||
fetchRelated: [
|
||||
{
|
||||
@@ -127,7 +127,7 @@ export const TABLES = {
|
||||
answer_type: { type: 'select', options: ['yes_no', 'text', 'question', 'word'] },
|
||||
difficulty_level: { type: 'number', min: 1, max: 50 },
|
||||
blocked_topic: { type: 'text' },
|
||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
||||
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||
},
|
||||
fetchRelated: [],
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export const TABLES = {
|
||||
sentence_en: { type: 'textarea' },
|
||||
sentence_sv: { type: 'textarea' },
|
||||
blocked_topic:{ type: 'text' },
|
||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
||||
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||
},
|
||||
fetchRelated: [],
|
||||
},
|
||||
@@ -167,7 +167,7 @@ export const TABLES = {
|
||||
negative_sentence_sv: { type: 'textarea' },
|
||||
answer: { type: 'boolean' },
|
||||
blocked_topic: { type: 'text' },
|
||||
status: { type: 'select', options: ['draft', 'published', 'blocked'] },
|
||||
status: { type: 'select', options: ['draft', 'reviewed', 'published', 'blocked'] },
|
||||
},
|
||||
fetchRelated: [],
|
||||
},
|
||||
@@ -280,14 +280,18 @@ export const TABLES = {
|
||||
endpoint: '/audios',
|
||||
statusField: 'status',
|
||||
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: {},
|
||||
editableFields: {
|
||||
text: { type: 'textarea' },
|
||||
status: { type: 'select', options: ['generated', 'published', 'blocked'] },
|
||||
voice_id: { type: 'text' },
|
||||
model_id: { type: 'text' },
|
||||
audio_link: { type: 'text' },
|
||||
text: { type: 'textarea' },
|
||||
status: { type: 'select', options: ['generated', 'published', 'blocked'] },
|
||||
language: { type: 'select', options: ['de', 'en', 'sv'] },
|
||||
source_table: { type: 'select', options: ['words', 'questions', 'statements'] },
|
||||
source_id: { type: 'text' },
|
||||
source_field: { type: 'text' },
|
||||
voice_id: { type: 'text' },
|
||||
model_id: { type: 'text' },
|
||||
audio_link: { type: 'text' },
|
||||
},
|
||||
fetchRelated: [],
|
||||
},
|
||||
@@ -297,6 +301,7 @@ export const STATUS_COLORS = {
|
||||
published: 'bg-violet-100 text-violet-800',
|
||||
blocked: 'bg-red-100 text-red-800',
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
reviewed: 'bg-teal-100 text-teal-800',
|
||||
uploaded: 'bg-blue-100 text-blue-800',
|
||||
requested: 'bg-yellow-100 text-yellow-800',
|
||||
translated: 'bg-indigo-100 text-indigo-800',
|
||||
|
||||
155
src/pages/AudioHub.jsx
Normal file
155
src/pages/AudioHub.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<StatusSelect
|
||||
value={obj.status}
|
||||
options={['draft', 'published', 'blocked']}
|
||||
options={['draft', 'reviewed', 'published', 'blocked']}
|
||||
onChange={s => onObjectStatusChange(obj.id, s)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,22 @@ const TOOLS = [
|
||||
path: '/content/creation',
|
||||
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',
|
||||
title: 'Veröffentlichen',
|
||||
|
||||
@@ -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>
|
||||
|
||||
173
src/pages/WordGenerator.jsx
Normal file
173
src/pages/WordGenerator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user