Files
perinco-CRM/src/components/ui/DictationField.tsx
T
Sascha WolfandClaude Opus 5 8b7ce448a6 fix(ui): echte Umlaute in allen sichtbaren Texten
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>
2026-08-11 09:37:24 +02:00

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>
);