Quellcode des ICON CRM (5)
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { aiApi, type AiConfig } from '../../lib/api';
|
||||
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
/*
|
||||
* Reiter "KI" in den Einstellungen (nur Administratoren).
|
||||
*
|
||||
* Der Schlüssel lag bisher ausschließlich in server/.env. Das hieß: ein
|
||||
* abgelaufener Schlüssel ist ein Serverbesuch. Er steht jetzt in der Datenbank
|
||||
* und lässt sich hier wechseln - die .env bleibt als Startwert und als Rückfall.
|
||||
*
|
||||
* Zwei Dinge sind hier nicht Bequemlichkeit, sondern Bedingung:
|
||||
*
|
||||
* * Der Schlüssel kommt **nie** zurück zum Browser. Angezeigt wird nur eine
|
||||
* verkürzte Form; das Eingabefeld ist leer und schreibt nur.
|
||||
* * Ein Schlüssel gilt erst als richtig, wenn ein Aufruf damit geklappt hat.
|
||||
* Deshalb der Test - sonst merkt man den Tippfehler erst, wenn mitten in
|
||||
* der Arbeit ein Lebenslauf nicht ausgelesen wird.
|
||||
*/
|
||||
|
||||
export const AiSettings: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const [config, setConfig] = useState<AiConfig | null>(null);
|
||||
const [key, setKey] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [askRemove, setAskRemove] = useState(false);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const data = await aiApi.config();
|
||||
setConfig(data);
|
||||
setModel(data.model);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const save = async (changes: { key?: string; model?: string }) => {
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await aiApi.saveConfig(changes);
|
||||
setConfig(data);
|
||||
setModel(data.model);
|
||||
setKey('');
|
||||
toast('Gespeichert.', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await aiApi.test();
|
||||
setResult({ ok: true, text: `Antwort in ${(data.ms / 1000).toFixed(1)} s von ${data.model}.` });
|
||||
} catch (err) {
|
||||
setResult({ ok: false, text: err instanceof Error ? err.message : 'Der Test ist fehlgeschlagen.' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
|
||||
|
||||
const sourceLabel =
|
||||
config.source === 'db'
|
||||
? 'hier hinterlegt'
|
||||
: config.source === 'env'
|
||||
? 'aus server/.env'
|
||||
: 'keiner hinterlegt';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="text-base font-semibold text-ink-900">OpenAI</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
Der Schlüssel treibt drei Dinge an: das Auslesen der Lebensläufe, die Schreibhilfe in den
|
||||
Ausschreibungen und die erweiterte Suche. Er bleibt auf dem Server – der Browser spricht
|
||||
nie direkt mit OpenAI. Fehlt er, läuft das CRM normal weiter und blendet die KI-Knöpfe
|
||||
aus.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex h-2.5 w-2.5 shrink-0 rounded-full ${
|
||||
config.configured ? 'bg-emerald-500' : 'bg-ink-300'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium text-ink-800">
|
||||
{config.configured ? 'Schlüssel hinterlegt' : 'Kein Schlüssel hinterlegt'}
|
||||
</span>
|
||||
{config.masked && (
|
||||
<code className="rounded bg-surface px-2 py-0.5 text-xs text-ink-600">{config.masked}</code>
|
||||
)}
|
||||
<span className="text-xs text-ink-400">({sourceLabel})</span>
|
||||
<span className="ml-auto text-xs text-ink-400">Modell {config.model}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-ink-900">Schlüssel setzen</h3>
|
||||
|
||||
<Field
|
||||
label="Neuer Schlüssel"
|
||||
hint="Wird beim Speichern übernommen und danach nicht mehr angezeigt. Ein bestehender Schlüssel wird ersetzt."
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
placeholder="sk-proj-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={busy || !key.trim()}
|
||||
onClick={() => void save({ key: key.trim() })}
|
||||
>
|
||||
Schlüssel speichern
|
||||
</PrimaryButton>
|
||||
{config.source === 'db' && (
|
||||
<SecondaryButton type="button" disabled={busy} onClick={() => setAskRemove(true)}>
|
||||
Schlüssel entfernen
|
||||
</SecondaryButton>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-ink-900">Modell</h3>
|
||||
|
||||
<Field
|
||||
label="Modellname"
|
||||
hint={`Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`}
|
||||
>
|
||||
<input
|
||||
value={model}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
placeholder={config.defaultModel}
|
||||
spellCheck={false}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={busy || model.trim() === config.model}
|
||||
onClick={() => void save({ model: model.trim() })}
|
||||
>
|
||||
Modell speichern
|
||||
</PrimaryButton>
|
||||
{model.trim() !== config.defaultModel && (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void save({ model: config.defaultModel })}
|
||||
>
|
||||
Zurück zur Vorgabe
|
||||
</SecondaryButton>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-sm font-semibold text-ink-900">Verbindung prüfen</h3>
|
||||
<p className="mt-1 text-sm text-ink-500">
|
||||
Ein kurzer Aufruf an das Modell. Er sagt, ob Schlüssel, Modellname und Kontingent
|
||||
zusammenpassen.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<PrimaryButton type="button" disabled={testing || !config.configured} onClick={() => void runTest()}>
|
||||
{testing ? 'Prüft …' : 'Test starten'}
|
||||
</PrimaryButton>
|
||||
{!config.configured && (
|
||||
<span className="text-xs font-medium text-ink-400">Erst einen Schlüssel hinterlegen.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={`mt-4 rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||
result.ok
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{result.text}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={askRemove}
|
||||
title="Schlüssel entfernen?"
|
||||
message={
|
||||
config.envAvailable
|
||||
? 'Der hier hinterlegte Schlüssel wird gelöscht. Danach gilt wieder der Schlüssel aus server/.env.'
|
||||
: 'Der Schlüssel wird gelöscht. Danach sind CV-Auslesung, Schreibhilfe und erweiterte Suche ohne Funktion, bis ein neuer hinterlegt ist.'
|
||||
}
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={async () => {
|
||||
setAskRemove(false);
|
||||
await save({ key: '' });
|
||||
}}
|
||||
onCancel={() => setAskRemove(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { optionsApi, type OptionField } from '../../lib/api';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { Pill, PrimaryButton, inputClass } from '../ui/form';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
/*
|
||||
* Stammdaten: die gepflegten Auswahllisten.
|
||||
*
|
||||
* Branche und Kontaktart waren Freitext bzw. fest im Quelltext. Beides wächst
|
||||
* im Betrieb - eine neue Branche, eine neue Art von Kontakt - und soll ohne
|
||||
* Codeänderung gehen.
|
||||
*
|
||||
* Zwei Eigenschaften, die man beim Pflegen kennen sollte und die deshalb in der
|
||||
* Oberfläche stehen:
|
||||
*
|
||||
* * Umbenennen zieht den Bestand mit. Sonst trüge ein Unternehmen eine
|
||||
* Branche, die seine Auswahlliste nicht mehr kennt.
|
||||
* * Entfernen geht nur, solange ein Wert nirgends benutzt wird. Deshalb steht
|
||||
* hinter jedem Eintrag, wie oft er vorkommt.
|
||||
*/
|
||||
export const MasterData: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const { reload: reloadOptions } = useOptions();
|
||||
const [fields, setFields] = useState<OptionField[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [editing, setEditing] = useState<{ id: number; value: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState<{ id: number; value: string } | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { fields: rows } = await optionsApi.list();
|
||||
setFields(rows);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const after = async () => {
|
||||
await load();
|
||||
// Die Formulare der Anwendung ziehen ihre Auswahl aus demselben Zustand.
|
||||
await reloadOptions();
|
||||
};
|
||||
|
||||
const add = async (field: string) => {
|
||||
const value = (drafts[field] ?? '').trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
await optionsApi.create(field, value);
|
||||
setDrafts((current) => ({ ...current, [field]: '' }));
|
||||
await after();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const { renamed } = await optionsApi.rename(editing.id, editing.value.trim());
|
||||
toast(renamed > 0 ? `Umbenannt – ${renamed} Datensatz/Datensätze mitgezogen.` : 'Umbenannt.', 'success');
|
||||
setEditing(null);
|
||||
await after();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Umbenennen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await optionsApi.remove(deleting.id);
|
||||
await after();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<section key={field.field} className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="mb-1 text-sm font-semibold text-ink-900">{field.label}</h2>
|
||||
<p className="mb-3 text-xs text-ink-400">
|
||||
Speist die Auswahlfelder in den Formularen. Umbenennen zieht den Bestand mit.
|
||||
</p>
|
||||
|
||||
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
|
||||
{field.values.length === 0 && (
|
||||
<li className="px-3 py-6 text-center text-sm text-ink-400">Noch kein Eintrag.</li>
|
||||
)}
|
||||
{field.values.map((option) => (
|
||||
<li key={option.id} className="flex items-center gap-2 px-3 py-2">
|
||||
{editing?.id === option.id ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editing.value}
|
||||
onChange={(event) => setEditing({ ...editing, value: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void rename();
|
||||
if (event.key === 'Escape') setEditing(null);
|
||||
}}
|
||||
className={`${inputClass} py-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={rename}
|
||||
className="rounded-lg px-2 py-1 text-sm font-medium text-brand-700 hover:bg-brand-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-lg px-2 py-1 text-sm text-ink-500 hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink-900">{option.value}</span>
|
||||
<Pill tone={option.used > 0 ? 'brand' : 'ink'}>{option.used}×</Pill>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing({ id: option.id, value: option.value })}
|
||||
className="rounded-lg px-2 py-1 text-sm text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||
>
|
||||
Umbenennen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={option.used > 0}
|
||||
title={
|
||||
option.used > 0
|
||||
? `Wird noch ${option.used}× verwendet und kann nicht entfernt werden.`
|
||||
: 'Entfernen'
|
||||
}
|
||||
onClick={() => setDeleting({ id: option.id, value: option.value })}
|
||||
className="rounded-lg p-1 text-ink-400 hover:bg-ink-100 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={drafts[field.field] ?? ''}
|
||||
onChange={(event) =>
|
||||
setDrafts((current) => ({ ...current, [field.field]: event.target.value }))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void add(field.field);
|
||||
}}
|
||||
placeholder="Neuer Eintrag …"
|
||||
className={inputClass}
|
||||
/>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => add(field.field)}
|
||||
disabled={!(drafts[field.field] ?? '').trim()}
|
||||
>
|
||||
Hinzufügen
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Eintrag entfernen"
|
||||
message={`„${deleting?.value}" wird aus der Liste entfernt.`}
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={remove}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UserManagement } from './UserManagement';
|
||||
import { MasterData } from './MasterData';
|
||||
import { WhisperSettings } from './WhisperSettings';
|
||||
import { AiSettings } from './AiSettings';
|
||||
import type { SettingsTab } from '../../lib/navigation';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
|
||||
import { authApi } from '../../lib/api';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { Field, PrimaryButton, inputClass, labelClass } from '../ui/form';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
interface SettingsProps {
|
||||
/** Reiter, der beim Oeffnen gezeigt werden soll - sonst der uebliche. */
|
||||
initialTab?: SettingsTab | null;
|
||||
/** Meldet zurueck, dass der Wunsch erfuellt ist. */
|
||||
onTabConsumed?: () => void;
|
||||
}
|
||||
|
||||
export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed }) => {
|
||||
const { user, isAdmin, updateUser } = useAuth();
|
||||
const { theme, setTheme, mode, setMode, followsSystem, resetMode } = useTheme();
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<SettingsTab>(initialTab ?? (isAdmin ? 'benutzer' : 'konto'));
|
||||
|
||||
/*
|
||||
* Ein von aussen gewuenschter Reiter gewinnt - aber nur einmal. Bliebe der
|
||||
* Wunsch stehen, koennte man den Reiter danach nicht mehr wechseln.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!initialTab) return;
|
||||
setTab(initialTab);
|
||||
onTabConsumed?.();
|
||||
}, [initialTab, onTabConsumed]);
|
||||
|
||||
const [color, setColor] = useState(user?.color ?? '#c1862f');
|
||||
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const saveProfile = async () => {
|
||||
try {
|
||||
const { user: updated } = await authApi.updateMe({ color, avatar });
|
||||
updateUser(updated);
|
||||
toast('Konto gespeichert.', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
await authApi.changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
toast('Passwort geändert.', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<nav className="flex gap-1 border-b border-ink-200">
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('benutzer')}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||
tab === 'benutzer' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
|
||||
}`}
|
||||
>
|
||||
Nutzerverwaltung
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('stammdaten')}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||
tab === 'stammdaten' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
|
||||
}`}
|
||||
>
|
||||
Stammdaten
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('ki')}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||
tab === 'ki' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
|
||||
}`}
|
||||
>
|
||||
KI
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('konto')}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||
tab === 'konto' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
|
||||
}`}
|
||||
>
|
||||
Mein Konto
|
||||
</button>
|
||||
{/* Diktat richtet jeder auf seinem eigenen Geraet ein - keine Rolle noetig. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('diktat')}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||
tab === 'diktat' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
|
||||
}`}
|
||||
>
|
||||
Diktat
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'ki' && isAdmin && <AiSettings />}
|
||||
|
||||
{tab === 'diktat' && <WhisperSettings />}
|
||||
|
||||
{tab === 'benutzer' && isAdmin && <UserManagement />}
|
||||
|
||||
{tab === 'stammdaten' && isAdmin && <MasterData />}
|
||||
|
||||
{tab === 'konto' && (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-ink-900">Profil</h2>
|
||||
<AvatarPicker name={user?.name ?? '?'} value={avatar} onChange={setAvatar} />
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass}>Persönliche Farbe</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
className="h-9 w-16 cursor-pointer rounded border border-ink-200 bg-surface"
|
||||
/>
|
||||
<span className="text-sm text-ink-500">{color}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Trägt Initialen-Avatare und die Punkte in der Historie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PrimaryButton type="button" onClick={saveProfile} className="mt-4">
|
||||
Speichern
|
||||
</PrimaryButton>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-ink-900">Darstellung</h2>
|
||||
|
||||
<label className={labelClass}>Modus</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('light')}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm ${
|
||||
!followsSystem && mode === 'light'
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-ink-200 text-ink-600'
|
||||
}`}
|
||||
>
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('dark')}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm ${
|
||||
!followsSystem && mode === 'dark'
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-ink-200 text-ink-600'
|
||||
}`}
|
||||
>
|
||||
Dunkel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetMode}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm ${
|
||||
followsSystem ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-600'
|
||||
}`}
|
||||
>
|
||||
Dem System folgen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className={`${labelClass} mt-4`}>Design</label>
|
||||
<ul className="space-y-2">
|
||||
{THEME_PALETTES.map((value) => (
|
||||
<li key={value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
|
||||
theme === value
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-ink-200 text-ink-600 hover:bg-ink-50'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{THEME_PALETTE_LABELS[value].name}</span>
|
||||
<span className="ml-2 text-xs text-ink-400">{THEME_PALETTE_LABELS[value].hint}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-ink-400">
|
||||
Dasselbe steht oben rechts im Kopf – dort auch der Umschalter zwischen hell und dunkel.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold text-ink-900">Passwort ändern</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Aktuelles Passwort">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Neues Passwort" hint="Mindestens 8 Zeichen.">
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={changePassword}
|
||||
disabled={!currentPassword || newPassword.length < 8}
|
||||
className="mt-4"
|
||||
>
|
||||
Passwort ändern
|
||||
</PrimaryButton>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { AppUser } from '../../types';
|
||||
import { userColor } from '../../types';
|
||||
import { usersApi } from '../../lib/api';
|
||||
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { Field, Pill, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const { user: me } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<AppUser | null | undefined>(undefined);
|
||||
const [deleting, setDeleting] = useState<AppUser | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { users: rows } = await usersApi.list();
|
||||
setUsers(rows);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await usersApi.remove(deleting.id);
|
||||
toast('Benutzer gelöscht.', 'success');
|
||||
void load();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-ink-400">{users.length} Benutzer</p>
|
||||
<PrimaryButton type="button" onClick={() => setEditing(null)}>
|
||||
Neuer Benutzer
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-xs uppercase tracking-wide text-ink-500">
|
||||
<th className="px-4 py-2.5 font-semibold">Name</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Benutzername</th>
|
||||
<th className="px-4 py-2.5 font-semibold">E-Mail</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Rolle</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Status</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-ink-50">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar name={user.name} src={user.avatar} size={28} color={userColor(user)} />
|
||||
<span className="font-medium text-ink-900">{user.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">{user.username}</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">{user.email || '–'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Pill tone={user.role === 'admin' ? 'brand' : 'ink'}>
|
||||
{user.role === 'admin' ? 'Administrator' : 'Benutzer'}
|
||||
</Pill>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Pill tone={user.is_active ? 'emerald' : 'red'}>
|
||||
{user.is_active ? 'aktiv' : 'deaktiviert'}
|
||||
</Pill>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(user)}
|
||||
className="rounded-lg px-2 py-1 text-sm font-medium text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{user.id !== me?.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleting(user)}
|
||||
className="ml-1 rounded-lg px-2 py-1 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing !== undefined && (
|
||||
<UserModal
|
||||
user={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={() => {
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Benutzer löschen"
|
||||
message={`${deleting?.name} wird gelöscht. Historieneinträge bleiben erhalten, verlieren aber die Zuordnung.`}
|
||||
onConfirm={remove}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserModal: React.FC<{
|
||||
user: AppUser | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}> = ({ user, onClose, onSaved }) => {
|
||||
const { toast } = useToast();
|
||||
const [form, setForm] = useState({
|
||||
username: user?.username ?? '',
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: user?.role ?? 'user',
|
||||
password: '',
|
||||
is_active: user?.is_active ?? true,
|
||||
color: user?.color ?? '#c1862f',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (user) {
|
||||
const body: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
is_active: form.is_active,
|
||||
color: form.color,
|
||||
};
|
||||
// Ein leeres Feld heisst "Passwort nicht anfassen" - nicht "leeres
|
||||
// Passwort setzen".
|
||||
if (form.password) body.password = form.password;
|
||||
await usersApi.update(user.id, body);
|
||||
} else {
|
||||
await usersApi.create(form);
|
||||
}
|
||||
toast('Gespeichert.', 'success');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={user ? `Benutzer ${user.name}` : 'Neuer Benutzer'}
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
Abbrechen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={save} disabled={saving}>
|
||||
{saving ? 'Speichern …' : 'Speichern'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Benutzername *">
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(event) => set('username', event.target.value)}
|
||||
disabled={Boolean(user)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Name *">
|
||||
<input value={form.name} onChange={(event) => set('name', event.target.value)} className={inputClass} />
|
||||
</Field>
|
||||
<Field label="E-Mail">
|
||||
<input
|
||||
type="email"
|
||||
value={form.email ?? ''}
|
||||
onChange={(event) => set('email', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Rolle">
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(event) => set('role', event.target.value as 'admin' | 'user')}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label={user ? 'Neues Passwort' : 'Passwort *'}
|
||||
hint={user ? 'Leer lassen, um das Passwort nicht zu ändern.' : 'Mindestens 8 Zeichen.'}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => set('password', event.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Farbe">
|
||||
<input
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(event) => set('color', event.target.value)}
|
||||
className="h-[38px] w-full cursor-pointer rounded-lg border border-ink-200 bg-surface"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<label className="mt-4 flex items-center gap-2 text-sm text-ink-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(event) => set('is_active', event.target.checked)}
|
||||
className="h-4 w-4 accent-[var(--color-brand-600)]"
|
||||
/>
|
||||
Konto ist aktiv
|
||||
</label>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { labelClass } from '../ui/form';
|
||||
import { useToast } from '../Toast';
|
||||
import { useRecorder } from '../../lib/dictation';
|
||||
import { WHISPER_MODEL, useWhisper } from '../../lib/whisper';
|
||||
import { fetchModelSize, formatSize } from '../../lib/whisperModels';
|
||||
|
||||
/*
|
||||
* Reiter "Diktat" in den Einstellungen - für jede Rolle sichtbar, denn jeder
|
||||
* richtet das Diktat auf seinem eigenen Gerät ein.
|
||||
*
|
||||
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
|
||||
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
|
||||
* gebraucht wird - einmal herunterladen, je Sitzung einmal aktivieren. Und der
|
||||
* Weg, es wieder loszuwerden.
|
||||
*/
|
||||
|
||||
export const WhisperSettings: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
load,
|
||||
clearCache,
|
||||
isSupported,
|
||||
} = useWhisper();
|
||||
|
||||
const [testText, setTestText] = useState('');
|
||||
const [askClear, setAskClear] = useState(false);
|
||||
/** Downloadgröße, direkt bei Hugging Face erfragt. */
|
||||
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchModelSize(WHISPER_MODEL)
|
||||
.then((size) => {
|
||||
if (!cancelled && size) setSizeLabel(formatSize(size.totalBytes));
|
||||
})
|
||||
.catch(() => {
|
||||
// Ohne Angabe läuft es genauso - nur ohne Zahl im Text.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const recorder = useRecorder(
|
||||
(text) => {
|
||||
setTestText(text);
|
||||
toast('Erkennung abgeschlossen.', 'success');
|
||||
},
|
||||
(message) => toast(message, 'error')
|
||||
);
|
||||
|
||||
const handleLoad = async () => {
|
||||
try {
|
||||
await load();
|
||||
toast('Diktat ist bereit.', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = status === 'loading';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="text-base font-semibold text-ink-900">Diktat mit Whisper</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
Die Spracherkennung läuft vollständig in diesem Browser. Aufnahmen verlassen das Gerät
|
||||
nicht und werden nirgends gespeichert – weder auf dem ICON-Server noch bei einem
|
||||
Dienstleister.
|
||||
</p>
|
||||
<p className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
Der Download fällt einmal je Browser an; das Modell bleibt danach hier liegen. Aktiviert
|
||||
wird es bei jeder Anmeldung mit einem Klick – das geschieht bewusst nicht von selbst:
|
||||
das Einrichten der Erkennung beansprucht für einige Sekunden die Grafikkarte, und das
|
||||
soll niemanden mitten in der Arbeit treffen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Zustand und die beiden Aktionen */}
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-ink-900">
|
||||
{status === 'ready'
|
||||
? 'Diktat ist bereit'
|
||||
: isLoading
|
||||
? phase === 'init'
|
||||
? 'Wird eingerichtet …'
|
||||
: 'Wird geladen …'
|
||||
: status === 'error'
|
||||
? 'Laden fehlgeschlagen'
|
||||
: 'Diktat noch nicht eingerichtet'}
|
||||
</h3>
|
||||
<p className="text-sm text-ink-500">
|
||||
{status === 'ready'
|
||||
? 'Die Mikrofon-Knöpfe in den Formularen sind aktiv.'
|
||||
: isLoading
|
||||
? phase === 'init'
|
||||
? 'Gleich fertig.'
|
||||
: progressLabel || 'Download startet …'
|
||||
: isCached
|
||||
? 'Das Modell liegt in diesem Browser – ein Klick aktiviert es für diese Sitzung.'
|
||||
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status !== 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLoad()}
|
||||
disabled={isLoading || !isSupported}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading
|
||||
? 'Lädt …'
|
||||
: isCached
|
||||
? 'Diktat aktivieren'
|
||||
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
|
||||
</button>
|
||||
)}
|
||||
{(isCached || status === 'ready') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAskClear(true)}
|
||||
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:border-red-300 hover:text-red-600"
|
||||
>
|
||||
Sprachmodell löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<p className="mt-4 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-600">
|
||||
Während das Modell eingerichtet wird, kann die Oberfläche kurz stocken. Das legt sich,
|
||||
sobald oben „Diktat ist bereit" steht.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-ink-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-600 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
|
||||
{!isSupported && (
|
||||
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
|
||||
Auf diesem Gerät ist kein Diktat möglich – es fehlt ein Mikrofon oder eine sichere
|
||||
Verbindung. Die Mikrofon-Knöpfe bleiben ausgeblendet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isSupported && !capabilities.webgpu && (
|
||||
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
|
||||
Dieses Gerät hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
|
||||
Erkennung dauert aber deutlich länger.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'ready' && !isCached && (
|
||||
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
|
||||
Dieser Browser hat das Modell nicht behalten – beim nächsten Start muss es erneut
|
||||
geladen werden. Ursache ist meist ein privates Fenster oder eine
|
||||
Speicherbeschränkung.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Probe */}
|
||||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-base font-semibold text-ink-900">Probe sprechen</h3>
|
||||
<p className="mt-1 text-sm text-ink-500">
|
||||
Kurz etwas diktieren, um zu prüfen, ob alles sitzt.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={recorder.state === 'recording' ? recorder.stop : () => void recorder.start()}
|
||||
disabled={status !== 'ready' || recorder.state === 'transcribing'}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50 ${
|
||||
recorder.state === 'recording'
|
||||
? 'border border-red-300 bg-red-50 text-red-700'
|
||||
: 'bg-brand-600 text-white hover:bg-brand-700'
|
||||
}`}
|
||||
>
|
||||
{recorder.state === 'recording'
|
||||
? `Aufnahme beenden (${recorder.seconds} s)`
|
||||
: recorder.state === 'transcribing'
|
||||
? 'Erkenne …'
|
||||
: 'Aufnahme starten'}
|
||||
</button>
|
||||
{recorder.state === 'recording' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={recorder.cancel}
|
||||
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
)}
|
||||
{status !== 'ready' && (
|
||||
<span className="text-xs font-medium text-ink-400">Erst das Sprachmodell laden.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass}>Erkannter Text</label>
|
||||
<p className="min-h-16 whitespace-pre-wrap rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-700">
|
||||
{testText || <span className="text-ink-400">Noch keine Probe.</span>}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={askClear}
|
||||
title="Sprachmodell löschen?"
|
||||
message="Das Modell wird aus diesem Browser entfernt. Für ein weiteres Diktat muss es erneut geladen werden."
|
||||
confirmLabel="Löschen"
|
||||
onConfirm={async () => {
|
||||
await clearCache();
|
||||
setAskClear(false);
|
||||
toast('Sprachmodell entfernt.', 'success');
|
||||
}}
|
||||
onCancel={() => setAskClear(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user