Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx
T
pastilhas ad8ed88519 profile settings keyed (425 -> 352)
ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.

Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.

ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00

196 lines
6.9 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
type VoiceGroup = { label: string; voices: string[] };
type TtsConfig = {
provider: string;
url: string;
apiKey?: string;
model: string;
voice: string;
};
const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => {
const { t } = useTranslation();
const client = useClient();
const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]);
const [groups, setGroups] = useState<VoiceGroup[]>([]);
const [loading, setLoading] = useState(false);
const [serverConfig, setServerConfig] = useState<TtsConfig | null>(null);
const [listening, setListening] = useState<'idle' | 'loading' | 'playing'>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
const fetchConfig = async () => {
try {
const config = await client.get<TtsConfig | null>('/server-settings/tts');
setServerConfig(config);
return config;
} catch {
return null;
}
};
const fetchVoices = async (config: TtsConfig) => {
setLoading(true);
try {
const res = await client.post<{ voices?: string[]; groups?: VoiceGroup[] }>('/server-settings/tts/voices', {
provider: config.provider,
url: config.url,
apiKey: config.apiKey || undefined,
model: config.model || undefined,
});
setVoices(res.voices ?? []);
setGroups(res.groups ?? []);
} catch {
setVoices([]);
setGroups([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchConfig().then((config) => {
if (config) fetchVoices(config);
});
}, []);
const handleChange = (value: string) => {
const voice = value === SERVER_DEFAULT ? null : value;
saveSettings({ ...settings, tts: { voice } });
};
const handleRefresh = () => {
if (serverConfig) fetchVoices(serverConfig);
};
const handleListen = async () => {
if (listening === 'loading') return;
if (listening === 'playing') {
audioRef.current?.pause();
audioRef.current = null;
setListening('idle');
return;
}
if (!serverConfig) return;
setListening('loading');
try {
const effectiveVoice = settings.tts.voice ?? serverConfig.voice;
const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` },
body: JSON.stringify({ ...serverConfig, voice: effectiveVoice }),
});
if (!res.ok || res.headers.get('content-type')?.includes('json')) {
setListening('idle');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
audio.onerror = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
await audio.play();
setListening('playing');
} catch {
setListening('idle');
}
};
const selectedValue = settings.tts.voice ?? SERVER_DEFAULT;
const prettify = (v: string) => v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase());
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title={t('settings.profile.voice.refresh')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
<div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
</SelectItem>
{groups.length > 0
? groups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
{g.label}
</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>
{prettify(v)}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
<button
type="button"
onClick={handleListen}
disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
>
{listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
) : listening === 'playing' ? (
<Square className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
) : (
<Play className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
</Label>
</div>
);
};