feat: Veröffentlichen-Seite, Einstellungen (TTS-Stimmen), klarere Navigation
- Navigation: Dashboard/Inhalte/Audio/Veröffentlichen/Datenbank/Einstellungen mit Active-State - Veröffentlichen (/publish): Pairs sortiert nach 'am wenigsten fehlt', 1-Klick-Publish je Sprache - Einstellungen (/settings): TTS-Stimme + Parameter pro Sprache bearbeiten - tts-settings in DB-Admin; Dashboard-Kacheln ergänzt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ import ContentHub from './pages/ContentHub';
|
|||||||
import ContentCreation from './pages/ContentCreation';
|
import ContentCreation from './pages/ContentCreation';
|
||||||
import AudioHub from './pages/AudioHub';
|
import AudioHub from './pages/AudioHub';
|
||||||
import WordGenerator from './pages/WordGenerator';
|
import WordGenerator from './pages/WordGenerator';
|
||||||
|
import Publish from './pages/Publish';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
@@ -27,6 +29,8 @@ export default function App() {
|
|||||||
<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="/audio" element={<RequireAuth><AudioHub /></RequireAuth>} />
|
||||||
<Route path="/content/words" element={<RequireAuth><WordGenerator /></RequireAuth>} />
|
<Route path="/content/words" element={<RequireAuth><WordGenerator /></RequireAuth>} />
|
||||||
|
<Route path="/publish" element={<RequireAuth><Publish /></RequireAuth>} />
|
||||||
|
<Route path="/settings" element={<RequireAuth><Settings /></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { getUser, logout } from '../lib/api';
|
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 }) {
|
export default function Layout({ children, back, fullHeight = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
@@ -11,30 +21,36 @@ export default function Layout({ children, back, fullHeight = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fullHeight ? 'h-screen flex flex-col bg-slate-100 overflow-hidden' : 'min-h-screen bg-slate-100'}>
|
<div className={fullHeight ? 'h-screen flex flex-col bg-slate-50 overflow-hidden' : 'min-h-screen bg-slate-50'}>
|
||||||
<header className="bg-indigo-700 text-white px-6 py-3 flex items-center justify-between shadow flex-shrink-0">
|
<header className="bg-white border-b border-slate-200 px-6 py-2.5 flex items-center justify-between flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
{back && (
|
{back && (
|
||||||
<button
|
<button onClick={() => navigate(back)} className="text-slate-400 hover:text-slate-700 text-sm">←</button>
|
||||||
onClick={() => navigate(back)}
|
|
||||||
className="text-indigo-200 hover:text-white text-sm mr-1"
|
|
||||||
>
|
|
||||||
← Zurück
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<span className="text-xl font-bold tracking-tight">🐟 snakkimo CMT</span>
|
<button onClick={() => navigate('/')} className="text-lg font-bold tracking-tight text-indigo-700 shrink-0">
|
||||||
|
🐟 snakkimo
|
||||||
|
</button>
|
||||||
|
<nav className="hidden md:flex items-center gap-1 text-sm ml-2">
|
||||||
|
{NAV.map(item => {
|
||||||
|
const active = item.match(location.pathname);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||||
|
active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<button onClick={() => navigate('/')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Dashboard</button>
|
<span className="text-slate-400 hidden sm:inline">{user?.email}</span>
|
||||||
<button onClick={() => navigate('/db')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Datenbank</button>
|
<button onClick={handleLogout}
|
||||||
<button onClick={() => navigate('/content')} className="text-indigo-200 hover:text-white px-3 py-1 rounded-lg transition-colors">Content</button>
|
className="text-slate-500 hover:text-red-600 px-2 py-1 rounded-lg transition-colors">
|
||||||
</nav>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<span className="text-indigo-200">{user?.email}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 px-3 py-1 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -295,6 +295,25 @@ export const TABLES = {
|
|||||||
},
|
},
|
||||||
fetchRelated: [],
|
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 = {
|
export const STATUS_COLORS = {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const TILES = [
|
|||||||
path: '/content/words',
|
path: '/content/words',
|
||||||
color: 'border-emerald-200 hover:border-emerald-400 hover:bg-emerald-50',
|
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',
|
title: 'Datenbankverwaltung',
|
||||||
icon: '🗄️',
|
icon: '🗄️',
|
||||||
@@ -33,6 +40,13 @@ 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: '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.
|
// Pipeline-Stufen, die den Lebenszyklus eines Inhalts abbilden.
|
||||||
|
|||||||
113
src/pages/Publish.jsx
Normal file
113
src/pages/Publish.jsx
Normal file
@@ -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 (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-1">Veröffentlichen</h1>
|
||||||
|
<p className="text-slate-500 mb-4">
|
||||||
|
Pairs sortiert nach <b>„am wenigsten fehlt"</b>. Was komplett ist (Text, Bild, Audio in der
|
||||||
|
gewählten Sprache), kannst du mit einem Klick veröffentlichen — Frage & Sätze werden mit freigegeben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-sm font-medium text-slate-600">Sprache:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{LANGS.map(l => (
|
||||||
|
<button key={l.code} onClick={() => setLang(l.code)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
lang === l.code ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}>
|
||||||
|
{l.flag} {l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!loading && <span className="text-sm text-slate-400 ml-auto">{readyCount} bereit · {rows.length} offen</span>}
|
||||||
|
</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>}
|
||||||
|
{msg && <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-2 mb-4">{msg}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2 animate-pulse">{[1,2,3,4].map(i => <div key={i} className="h-16 bg-slate-100 rounded-xl" />)}</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-8 text-center text-slate-400">
|
||||||
|
Keine offenen Pairs (draft/reviewed) gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map(r => (
|
||||||
|
<div key={r.id} className={`bg-white rounded-xl border p-3 flex items-center gap-3 ${
|
||||||
|
r.ready ? 'border-emerald-300' : 'border-slate-200'
|
||||||
|
}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`${STATUS_COLORS[r.status] || ''} rounded-full px-2 py-0.5 text-xs font-medium`}>{r.status}</span>
|
||||||
|
<span className="text-xs text-slate-400">{r.answer_type}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-700 truncate">{r.preview || <span className="text-slate-300">— kein Text —</span>}</p>
|
||||||
|
{r.missing.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{r.missing.map((m, i) => (
|
||||||
|
<span key={i} className="text-[11px] bg-amber-50 text-amber-700 border border-amber-200 rounded-full px-2 py-0.5">{m}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.ready ? (
|
||||||
|
<button onClick={() => publish(r.id)} disabled={publishing === r.id}
|
||||||
|
className={`shrink-0 text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||||
|
publishing === r.id ? 'bg-violet-300 text-white' : 'bg-violet-600 text-white hover:bg-violet-500'
|
||||||
|
}`}>
|
||||||
|
{publishing === r.id ? '…' : 'Veröffentlichen'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-xs text-slate-400 w-20 text-right">{r.missingCount} offen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/pages/Settings.jsx
Normal file
115
src/pages/Settings.jsx
Normal file
@@ -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 (
|
||||||
|
<Layout back="/">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-1">Einstellungen</h1>
|
||||||
|
<p className="text-slate-500 mb-6">Sprachausgabe (ElevenLabs) — eine Stimme pro Sprache, zentral konfiguriert.</p>
|
||||||
|
|
||||||
|
{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>}
|
||||||
|
{msg && <div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm rounded-xl px-4 py-2 mb-4">{msg}</div>}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
|
||||||
|
{LANGS.map(lang => {
|
||||||
|
const s = rows[lang.code] || {};
|
||||||
|
return (
|
||||||
|
<div key={lang.code} className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-semibold text-slate-700">{lang.flag} {lang.label}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => save(lang.code)}
|
||||||
|
disabled={saving === lang.code}
|
||||||
|
className={`text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||||
|
saving === lang.code ? 'bg-indigo-300 text-white' : 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saving === lang.code ? 'Speichere …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Voice-ID</span>
|
||||||
|
<input value={s.voice_id || ''} onChange={e => update(lang.code, { voice_id: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Geschwindigkeit</span>
|
||||||
|
<input type="number" step="0.05" min="0.5" max="1.5" value={s.speed ?? 1.0}
|
||||||
|
onChange={e => update(lang.code, { speed: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Stabilität</span>
|
||||||
|
<input type="number" step="0.05" min="0" max="1" value={s.stability ?? 0.5}
|
||||||
|
onChange={e => update(lang.code, { stability: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Similarity Boost</span>
|
||||||
|
<input type="number" step="0.05" min="0" max="1" value={s.similarity_boost ?? 0.75}
|
||||||
|
onChange={e => update(lang.code, { similarity_boost: e.target.value })}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-3">
|
||||||
|
Diese Werte werden bei jeder Audio-Generierung verwendet. Änderungen wirken auf neu erzeugte Audios.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user