Menüs, Dialoge, Schaltflächen, Platzhalter, Hinweise und die
Fehlermeldungen der API standen in Ersatzschreibweise (oe/ae/ue). Sie
erscheinen als Toast oder Beschriftung direkt in der Oberfläche und
gehören damit richtig geschrieben; sechs sichtbare Texte mit ss statt ß
sind gleich mit erledigt ("Schließen", "Straße", "zu groß").
Unangetastet bleibt bewusst:
- Kommentare im Quelltext - die sind durchgängig ASCII, und der Diff wäre
dreimal so groß ohne Nutzen für den Benutzer
- Protokollzeilen (console.*) - die landen im journal, nicht in der App
- Datenbankwerte und interne Kennungen: 'uebernommen', 'empfaenger',
'vorschlaege', 'laeuft-bereits' stecken in Enums, SQL und
Zustandsvergleichen. Ein Umlaut darin hätte still falsche Ergebnisse
geliefert
- server/src/mail/extract.js - die Muster decken absichtlich beide
Schreibweisen ab: /gr(ü|ue)(ß|ss)e/
- scanner/utils/crypto.js - der Ersatzschlüssel darf sich nicht ändern,
sonst sind gespeicherte Portal-Passwörter nicht mehr lesbar
Geprüft mit einem Rundgang durch die Ansichten und Dialoge: sichtbarer
Text plus title/aria-label/placeholder enthalten keine
Ersatzschreibweisen mehr.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
7.8 KiB
TypeScript
244 lines
7.8 KiB
TypeScript
import React from 'react';
|
|
import { useRecorder } from '../../lib/dictation';
|
|
import { useWhisper } from '../../lib/whisper';
|
|
import { useNavigation } from '../../lib/navigation';
|
|
import { useToast } from '../Toast';
|
|
import { inputClass } from '../leads/leadUi';
|
|
|
|
const MIC_ICON =
|
|
'M19 11a7 7 0 01-14 0m7 7v3m0-3a7 7 0 007-7m-7 7a7 7 0 01-7-7m7-9a3 3 0 013 3v6a3 3 0 01-6 0V5a3 3 0 013-3z';
|
|
const STOP_ICON = 'M9 9h6v6H9z';
|
|
|
|
function formatSeconds(total: number): string {
|
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
interface DictateButtonProps {
|
|
/** Erhaelt den erkannten Text; der Aufrufer entscheidet, ob er anhaengt. */
|
|
onText: (text: string) => void;
|
|
/** Kurzer Hinweis, was diktiert wird - landet im Titel des Buttons. */
|
|
label: string;
|
|
compact?: boolean;
|
|
/**
|
|
* Wird vor dem Wechsel in die Whisper-Einstellungen aufgerufen - ein offener
|
|
* Dialog muss sich sonst nicht schliessen und liegt ueber der Ansicht.
|
|
*/
|
|
onBeforeNavigate?: () => void;
|
|
}
|
|
|
|
export const DictateButton: React.FC<DictateButtonProps> = ({
|
|
onText,
|
|
label,
|
|
compact,
|
|
onBeforeNavigate,
|
|
}) => {
|
|
const { status, isSupported } = useWhisper();
|
|
const { goTo } = useNavigation();
|
|
const { addToast } = useToast();
|
|
const { state, seconds, start, stop, cancel } = useRecorder(onText, (message) =>
|
|
addToast(message, 'error')
|
|
);
|
|
|
|
// Ohne Mikrofon oder ohne HTTPS hat ein Diktat-Button keinen Sinn.
|
|
if (!isSupported) return null;
|
|
|
|
// Modell noch nicht geladen: statt eines toten Buttons der Weg dorthin.
|
|
if (status !== 'ready') {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onBeforeNavigate?.();
|
|
goTo('Einstellungen', { tab: 'whisper' });
|
|
}}
|
|
title="Das Spracherkennungsmodell ist noch nicht geladen - hier einrichten"
|
|
className="flex items-center gap-1.5 rounded-lg border border-dashed border-ink-300 px-2 py-1 text-[11px] font-bold text-ink-400 transition hover:border-brand-400 hover:text-brand-700"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={MIC_ICON} />
|
|
</svg>
|
|
Diktat einrichten
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const isRecording = state === 'recording';
|
|
const isBusy = state === 'transcribing';
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={isRecording ? stop : start}
|
|
disabled={isBusy}
|
|
title={
|
|
isRecording
|
|
? 'Aufnahme beenden und erkennen'
|
|
: isBusy
|
|
? 'Erkennung läuft...'
|
|
: `${label} diktieren`
|
|
}
|
|
aria-label={isRecording ? 'Aufnahme beenden' : `${label} diktieren`}
|
|
className={`flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[11px] font-bold transition disabled:opacity-60 ${
|
|
isRecording
|
|
? 'border-red-300 bg-red-50 text-red-700'
|
|
: 'border-ink-200 text-ink-500 hover:border-brand-300 hover:text-brand-700'
|
|
}`}
|
|
>
|
|
{isBusy ? (
|
|
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
|
) : (
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d={isRecording ? STOP_ICON : MIC_ICON}
|
|
/>
|
|
</svg>
|
|
)}
|
|
{isRecording ? (
|
|
<span className="tabular-nums">{formatSeconds(seconds)}</span>
|
|
) : isBusy ? (
|
|
'Erkenne...'
|
|
) : compact ? (
|
|
'Diktat'
|
|
) : (
|
|
'Diktieren'
|
|
)}
|
|
</button>
|
|
|
|
{isRecording && (
|
|
<button
|
|
type="button"
|
|
onClick={cancel}
|
|
title="Aufnahme verwerfen"
|
|
aria-label="Aufnahme verwerfen"
|
|
className="rounded-lg p-1 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface DictationHintProps {
|
|
/** Wird vor dem Wechsel aufgerufen, z. B. um den Dialog zu schliessen. */
|
|
onBeforeNavigate?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Hinweisbox fuer Formulare: erklaert, warum kein Mikrofon zu sehen ist, und
|
|
* fuehrt direkt zu den Whisper-Einstellungen. Zeigt sich nur, solange das
|
|
* Modell nicht bereit ist.
|
|
*/
|
|
export const DictationHint: React.FC<DictationHintProps> = ({ onBeforeNavigate }) => {
|
|
const { status, isSupported, isCached, progress, progressLabel, phase } = useWhisper();
|
|
const { goTo } = useNavigation();
|
|
|
|
if (!isSupported || status === 'ready') return null;
|
|
|
|
const isLoading = status === 'loading';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 sm:flex-row sm:items-center">
|
|
<svg
|
|
className="h-5 w-5 shrink-0 text-ink-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d={MIC_ICON} />
|
|
</svg>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-ink-800">
|
|
{isLoading
|
|
? 'Spracherkennung wird geladen...'
|
|
: status === 'error'
|
|
? 'Die Spracherkennung konnte nicht geladen werden.'
|
|
: 'Diktieren ist möglich, das Modell fehlt aber noch.'}
|
|
</p>
|
|
<p className="text-xs text-ink-500">
|
|
{isLoading
|
|
? phase === 'init'
|
|
? 'Download fertig, Modell wird initialisiert...'
|
|
: progressLabel || 'Bitte kurz warten.'
|
|
: isCached
|
|
? 'Das Modell liegt bereits im Browser - es muss nur noch aktiviert werden.'
|
|
: 'Das Modell wird einmalig in diesen Browser geladen und läuft danach lokal auf diesem Gerät.'}
|
|
</p>
|
|
{isLoading && (
|
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink-200">
|
|
<div
|
|
className="h-full rounded-full bg-brand-500 transition-all"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!isLoading && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onBeforeNavigate?.();
|
|
goTo('Einstellungen', { tab: 'whisper' });
|
|
}}
|
|
className="shrink-0 rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white transition hover:bg-brand-600"
|
|
>
|
|
Zu Whisper
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface DictateTextareaProps {
|
|
id?: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
label: string;
|
|
placeholder?: string;
|
|
rows?: number;
|
|
onBeforeNavigate?: () => void;
|
|
}
|
|
|
|
/** Textfeld mit Diktat-Button darueber; erkannter Text wird angehaengt. */
|
|
export const DictateTextarea: React.FC<DictateTextareaProps> = ({
|
|
id,
|
|
value,
|
|
onChange,
|
|
label,
|
|
placeholder,
|
|
rows = 4,
|
|
onBeforeNavigate,
|
|
}) => (
|
|
<div>
|
|
<div className="mb-1.5 flex items-center justify-between gap-3">
|
|
<label
|
|
htmlFor={id}
|
|
className="block text-[11px] font-extrabold uppercase tracking-wider text-ink-500"
|
|
>
|
|
{label}
|
|
</label>
|
|
<DictateButton
|
|
label={label}
|
|
onBeforeNavigate={onBeforeNavigate}
|
|
onText={(text) => onChange(value ? `${value.trimEnd()} ${text}` : text)}
|
|
/>
|
|
</div>
|
|
<textarea
|
|
id={id}
|
|
rows={rows}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
className={`${inputClass} resize-y`}
|
|
/>
|
|
</div>
|
|
);
|