Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.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

166 lines
5.6 KiB
TypeScript

import { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales';
/**
* Language names come from `Intl.DisplayNames`, not from the catalog.
*
* The runtime already knows every language's name in every locale — "German" in English, "alemão" in
* Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for
* free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers.
*
* The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product
* choice, not something to enumerate from the runtime.
*/
const SPOKEN_LANGUAGE_TAGS = [
'en',
'pt',
'es',
'fr',
'de',
'it',
'nl',
'ru',
'zh',
'ja',
'ko',
'ar',
'hi',
'tr',
'pl',
'sv',
'da',
'no',
'fi',
'uk',
'cs',
'ro',
'el',
'he',
'th',
'vi',
'id',
'ms',
] as const;
/** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */
const useLanguageName = () => {
const { locale } = useTranslation();
return useMemo(() => {
const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' });
return (code: string) => {
const name = names.of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
}, [locale]);
};
export const Languages = () => {
const { settings, saveSettings } = useSettings();
const { t } = useTranslation();
const languageName = useLanguageName();
const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState('');
const save = (languages: typeof settings.languages) => {
saveSettings({ ...settings, languages });
};
const addSpoken = (code: string) => {
if (!code || spoken.includes(code)) return;
save({ ...settings.languages, spoken: [...spoken, code] });
setAddingLang('');
};
const removeSpoken = (code: string) => {
const next = spoken.filter((s) => s !== code);
const updates = { ...settings.languages, spoken: next };
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
// language never leaves it without a valid value.
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
save(updates);
};
const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
return (
<div className="grid gap-5">
{/* Spoken languages */}
<div className="grid gap-2">
<span className="text-sm font-medium text-duck-dark/70">{t('settings.profile.languages.spokenLabel')}</span>
<div className="flex flex-wrap gap-2">
{spoken.map((code) => (
<span
key={code}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
>
{languageName(code)}
{spoken.length > 1 && (
<button
onClick={() => removeSpoken(code)}
className="hover:text-red-500 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</span>
))}
</div>
{availableToAdd.length > 0 && (
<div className="flex items-center gap-2 mt-1">
<select
value={addingLang}
onChange={(ev) => addSpoken(ev.target.value)}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
>
<option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
</div>
)}
</div>
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select
value={defaultLang}
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{SHIPPED_LOCALES.map(({ code, label }) => (
<option key={code} value={code}>
{label}
</option>
))}
</select>
<span className="text-xs text-duck-dark/40">{t('settings.profile.languages.defaultHint')}</span>
</Label>
{/* Translate to */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.translateToLabel')}</span>
<select
value={translateTo}
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{SPOKEN_LANGUAGE_TAGS.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
<span className="text-xs text-duck-dark/40">{t('settings.profile.languages.translateToHint')}</span>
</Label>
</div>
);
};