diff --git a/src/App.jsx b/src/App.jsx index c6d0669..b946904 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/lib/tables.js b/src/lib/tables.js index 69a6e0a..5c7a5af 100644 --- a/src/lib/tables.js +++ b/src/lib/tables.js @@ -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', diff --git a/src/pages/AudioHub.jsx b/src/pages/AudioHub.jsx new file mode 100644 index 0000000..1db8b39 --- /dev/null +++ b/src/pages/AudioHub.jsx @@ -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 ( + +
+

Audio / TTS

+

+ Hier siehst du, wie viele Sätze & Wörter noch kein Audio haben, und kannst die + Sprachausgabe (ElevenLabs) per Knopfdruck erzeugen. +

+ +
+ So funktioniert's: „Benötigt" zählt geprüfte/veröffentlichte Inhalte + (Wörter ab Status generated, Fragen/Statements ab reviewed) mit Text in der jeweiligen + Sprache. Erst wenn ein Pair für die Zielsprache komplett vertont ist, erscheint es in der App. +
+ + {error && ( +
{error}
+ )} + {progress && ( +
{progress}
+ )} + + {/* Coverage-Matrix */} +
+ {SOURCES.map(table => ( +
+

{SOURCE_LABELS[table]}

+
+ {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 ( +
+
+ {lang.flag} {lang.label} + {coverage ? `${have}/${total}` : '…'} +
+
+
+
+
+ + {missing ? `${missing} fehlen` : 'vollständig'} + + +
+
+ ); + })} +
+
+ ))} +
+ + {/* Audio-Liste */} +

Erzeugte Audios ({audios.length})

+
+ {audios.length === 0 && ( +
Noch keine Audios erzeugt.
+ )} + {audios.slice(0, 100).map(a => ( +
+ {a.language} + {a.source_table} + {a.text} + {a.audio_link &&
+ ))} +
+
+ + ); +} diff --git a/src/pages/ContentCreation.jsx b/src/pages/ContentCreation.jsx index 797c101..aab8249 100644 --- a/src/pages/ContentCreation.jsx +++ b/src/pages/ContentCreation.jsx @@ -658,7 +658,7 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel, onDeleted }) {
- +
@@ -832,7 +832,7 @@ function ObjectListPanel({ objects, loadingObjects, mode, selectedObjectId, onAd #{i + 1} onObjectStatusChange(obj.id, s)} size="xs" /> diff --git a/src/pages/ContentHub.jsx b/src/pages/ContentHub.jsx index 9929403..731fdb7 100644 --- a/src/pages/ContentHub.jsx +++ b/src/pages/ContentHub.jsx @@ -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', diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index c529e2f..a4f8f9f 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 ( -

Dashboard

-
+

Dashboard

+

+ Der Inhalts-Lebenszyklus: draft (erstellt) → reviewed (im Tool geprüft) →{' '} + published (fertig inkl. Audio, in der App sichtbar). Nur veröffentlichte Inhalte mit + Bild und Audio erscheinen für Lernende. +

+ + {/* Pipeline-Übersicht */} +
+

Pipeline-Übersicht

+
+ {PIPELINES.map(p => ( +
+
{p.label}
+
+ {p.stages.map(stage => { + const n = counts?.[p.key]?.[stage] ?? null; + const cls = STATUS_COLORS[stage] || 'bg-gray-100 text-gray-600'; + return ( + + {stage}: {counts ? (n ?? 0) : '…'} + + ); + })} +
+
+ ))} +
+

Zählung bis max. 500 pro Tabelle.

+
+ + {/* Werkzeuge */} +
{TILES.map(tile => (
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}`} >
{tile.icon}

{tile.title}

{tile.description}

- {tile.soon && ( - - Demnächst - - )}
))}
diff --git a/src/pages/WordGenerator.jsx b/src/pages/WordGenerator.jsx new file mode 100644 index 0000000..345313e --- /dev/null +++ b/src/pages/WordGenerator.jsx @@ -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 ( + +
+

Wörter generieren

+

+ Lass dir per KI neue Vokabeln zu einem Thema erstellen, prüfe sie und übernimm sie in die Datenbank. +

+
+ Übernommene Wörter erhalten den Status translated (alle Sprachen vorhanden) und werden so + automatisch von der Bildgenerierung aufgegriffen. +
+ + {/* Eingabe */} +
+
+ + 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" + /> +
+
+
+ + 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" /> +
+
+ + +
+
+ + +
+
+ +
+ + {error &&
{error}
} + {done &&
{done}
} + + {/* Vorschau */} + {rows.length > 0 && ( +
+
+

Vorschau ({rows.length})

+ +
+
+
+ DeutschEnglishSvenska +
+ {rows.map((r, i) => ( +
+ updateRow(i, { include: e.target.checked })} className="justify-self-center" /> + 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" /> + 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" /> + 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" /> + + {r.saved ? : r.error ? Fehler : ''} + +
+ ))} +
+
+ )} +
+
+ ); +}