diff --git a/src/App.jsx b/src/App.jsx index b946904..21ccdc9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,8 @@ import ContentHub from './pages/ContentHub'; import ContentCreation from './pages/ContentCreation'; import AudioHub from './pages/AudioHub'; import WordGenerator from './pages/WordGenerator'; +import Publish from './pages/Publish'; +import Settings from './pages/Settings'; function RequireAuth({ children }) { const user = getUser(); @@ -27,6 +29,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 725c183..7e651c3 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,8 +1,18 @@ -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { getUser, logout } from '../lib/api'; +const NAV = [ + { label: 'Dashboard', path: '/', match: p => p === '/' }, + { label: 'Inhalte', path: '/content', match: p => p.startsWith('/content') }, + { label: 'Audio', path: '/audio', match: p => p.startsWith('/audio') }, + { label: 'Veröffentlichen',path: '/publish', match: p => p.startsWith('/publish') }, + { label: 'Datenbank', path: '/db', match: p => p.startsWith('/db') }, + { label: 'Einstellungen', path: '/settings',match: p => p.startsWith('/settings') }, +]; + export default function Layout({ children, back, fullHeight = false }) { const navigate = useNavigate(); + const location = useLocation(); const user = getUser(); function handleLogout() { @@ -11,30 +21,36 @@ export default function Layout({ children, back, fullHeight = false }) { } return ( -
-
-
+
+
+
{back && ( - + )} - 🐟 snakkimo CMT + +
- -
- {user?.email} -
diff --git a/src/lib/tables.js b/src/lib/tables.js index 5c7a5af..971555c 100644 --- a/src/lib/tables.js +++ b/src/lib/tables.js @@ -295,6 +295,25 @@ export const TABLES = { }, fetchRelated: [], }, + + 'tts-settings': { + label: 'TTS-Stimmen', + icon: '🗣️', + endpoint: '/tts-settings', + statusField: null, + primaryLabel: 'language', + columns: ['language', 'voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style', 'updated_at'], + linkedFields: {}, + editableFields: { + voice_id: { type: 'text' }, + model_id: { type: 'text' }, + speed: { type: 'number' }, + stability: { type: 'number' }, + similarity_boost: { type: 'number' }, + style: { type: 'number' }, + }, + fetchRelated: [], + }, }; export const STATUS_COLORS = { diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index a4f8f9f..1deeeed 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -26,6 +26,13 @@ const TILES = [ path: '/content/words', color: 'border-emerald-200 hover:border-emerald-400 hover:bg-emerald-50', }, + { + title: 'Veröffentlichen', + icon: '🚀', + description: 'Fast fertige Inhalte sehen und mit einem Klick live schalten.', + path: '/publish', + color: 'border-violet-200 hover:border-violet-400 hover:bg-violet-50', + }, { title: 'Datenbankverwaltung', icon: '🗄️', @@ -33,6 +40,13 @@ const TILES = [ path: '/db', color: 'border-indigo-200 hover:border-indigo-400 hover:bg-indigo-50', }, + { + title: 'Einstellungen', + icon: '⚙️', + description: 'TTS-Stimmen pro Sprache und weitere Konfiguration.', + path: '/settings', + color: 'border-slate-200 hover:border-slate-400 hover:bg-slate-100', + }, ]; // Pipeline-Stufen, die den Lebenszyklus eines Inhalts abbilden. diff --git a/src/pages/Publish.jsx b/src/pages/Publish.jsx new file mode 100644 index 0000000..f0ef4aa --- /dev/null +++ b/src/pages/Publish.jsx @@ -0,0 +1,113 @@ +import { useEffect, useState, useCallback } from 'react'; +import Layout from '../components/Layout'; +import { apiFetch } from '../lib/api'; +import { STATUS_COLORS } from '../lib/tables'; + +const LANGS = [ + { code: 'de', label: 'Deutsch', flag: '🇩🇪' }, + { code: 'en', label: 'English', flag: '🇬🇧' }, + { code: 'sv', label: 'Svenska', flag: '🇸🇪' }, +]; + +export default function Publish() { + const [lang, setLang] = useState('sv'); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [publishing, setPublishing] = useState(null); + const [msg, setMsg] = useState(null); + + const load = useCallback(async () => { + setLoading(true); setError(null); + try { + const { pairs } = await apiFetch(`/pairs/publishability?lang=${lang}`); + setRows(pairs); + } catch (e) { setError(e.message); } + finally { setLoading(false); } + }, [lang]); + + useEffect(() => { load(); }, [load]); + + async function publish(id) { + setPublishing(id); setError(null); setMsg(null); + try { + await apiFetch(`/pairs/${id}/publish?lang=${lang}`, { method: 'POST', body: '{}' }); + setMsg('Veröffentlicht ✓'); + await load(); + } catch (e) { setError(e.message); } + finally { setPublishing(null); setTimeout(() => setMsg(null), 2500); } + } + + const readyCount = rows.filter(r => r.ready).length; + + return ( + +
+

Veröffentlichen

+

+ Pairs sortiert nach „am wenigsten fehlt". Was komplett ist (Text, Bild, Audio in der + gewählten Sprache), kannst du mit einem Klick veröffentlichen — Frage & Sätze werden mit freigegeben. +

+ +
+ Sprache: +
+ {LANGS.map(l => ( + + ))} +
+ {!loading && {readyCount} bereit · {rows.length} offen} +
+ + {error &&
{error}
} + {msg &&
{msg}
} + + {loading ? ( +
{[1,2,3,4].map(i =>
)}
+ ) : rows.length === 0 ? ( +
+ Keine offenen Pairs (draft/reviewed) gefunden. +
+ ) : ( +
+ {rows.map(r => ( +
+
+
+ {r.status} + {r.answer_type} +
+

{r.preview || — kein Text —}

+ {r.missing.length > 0 && ( +
+ {r.missing.map((m, i) => ( + {m} + ))} +
+ )} +
+ {r.ready ? ( + + ) : ( + {r.missingCount} offen + )} +
+ ))} +
+ )} +
+ + ); +} diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx new file mode 100644 index 0000000..9748e08 --- /dev/null +++ b/src/pages/Settings.jsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import Layout from '../components/Layout'; +import { apiFetch } from '../lib/api'; + +const LANGS = [ + { code: 'de', label: 'Deutsch', flag: '🇩🇪' }, + { code: 'en', label: 'English', flag: '🇬🇧' }, + { code: 'sv', label: 'Svenska', flag: '🇸🇪' }, +]; + +function put(language, body) { + return apiFetch(`/tts-settings/${language}`, { method: 'PUT', body: JSON.stringify(body) }); +} + +export default function Settings() { + const [rows, setRows] = useState({}); // language → settings + const [saving, setSaving] = useState(null); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(null); + + async function load() { + try { + const list = await apiFetch('/tts-settings'); + const map = {}; + for (const s of list) map[s.language] = s; + setRows(map); + } catch (e) { setError(e.message); } + } + useEffect(() => { load(); }, []); + + function update(lang, patch) { + setRows(r => ({ ...r, [lang]: { ...(r[lang] || { language: lang }), ...patch } })); + } + + async function save(lang) { + setSaving(lang); setError(null); setMsg(null); + try { + const s = rows[lang] || {}; + if (!s.voice_id) { setError(`Voice-ID für ${lang} fehlt`); setSaving(null); return; } + await put(lang, { + voice_id: s.voice_id, + model_id: s.model_id || 'eleven_multilingual_v2', + speed: Number(s.speed ?? 1.0), + stability: Number(s.stability ?? 0.5), + similarity_boost: Number(s.similarity_boost ?? 0.75), + style: Number(s.style ?? 0.0), + }); + setMsg(`Stimme für ${lang.toUpperCase()} gespeichert.`); + await load(); + } catch (e) { setError(e.message); } + finally { setSaving(null); setTimeout(() => setMsg(null), 3000); } + } + + return ( + +
+

Einstellungen

+

Sprachausgabe (ElevenLabs) — eine Stimme pro Sprache, zentral konfiguriert.

+ + {error &&
{error}
} + {msg &&
{msg}
} + +
+ {LANGS.map(lang => { + const s = rows[lang.code] || {}; + return ( +
+
+

{lang.flag} {lang.label}

+ +
+
+ + + + +
+
+ ); + })} +
+

+ Diese Werte werden bei jeder Audio-Generierung verwendet. Änderungen wirken auf neu erzeugte Audios. +

+
+
+ ); +}