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 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>
|
||||||
|
|||||||
@@ -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,11 +280,15 @@ 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'] },
|
||||||
|
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' },
|
voice_id: { type: 'text' },
|
||||||
model_id: { type: 'text' },
|
model_id: { type: 'text' },
|
||||||
audio_link: { type: 'text' },
|
audio_link: { type: 'text' },
|
||||||
@@ -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
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="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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
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