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.
166 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
};
|