diff --git a/scripts/i18n/scan-hardcoded.ts b/scripts/i18n/scan-hardcoded.ts index 1420f063..8b09b8b3 100755 --- a/scripts/i18n/scan-hardcoded.ts +++ b/scripts/i18n/scan-hardcoded.ts @@ -81,6 +81,10 @@ function isHuman(value: string | undefined): value is string { if (/^[a-z0-9\-_./:]+$/.test(s)) return false; if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false; if (/^[A-Z0-9_]+$/.test(s)) return false; + // `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string + // pattern because it starts with a capital and interpolates; excluded here because an auth scheme + // followed by a credential is never something a person reads. + if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false; // Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's // `description: 'group-[.toast]:text-muted-foreground'`. // diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx index a1e10c7f..23b35d97 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/select'; import { useSettings } from 'state/useSettings'; import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels'; +import { useTranslation } from '@/lib/i18n'; function buildGroups(models: ModelOption[]) { const groups: Record = {}; @@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) { const NONE = '__none__'; export const AIModels = () => { + const { t } = useTranslation(); const { settings, saveSettings } = useSettings(); const piModels = useUserVisibleModels(); const [isSaving, setIsSaving] = useState(false); @@ -54,9 +56,9 @@ export const AIModels = () => { chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel }, tasks: { ...settings.tasks, defaultModel: taskModel }, }); - toast.success('AI model defaults saved'); + toast.success(t('settings.profile.models.aiDefaultsSaved')); } catch { - toast.error('Failed to save settings'); + toast.error(t('settings.profile.models.saveFailed')); } finally { setIsSaving(false); } @@ -86,25 +88,25 @@ export const AIModels = () => { return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx index 500d2026..9a0f8d2f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ApiKeys.tsx @@ -2,6 +2,8 @@ import type { FormEvent } from 'react'; import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useFormat } from 'i18n'; +import { useTranslation } from '@/lib/i18n'; import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Input } from '@/components/ui/input'; @@ -36,19 +38,22 @@ type Minted = { key: string; name: string }; const API_KEYS_QUERY_KEY = ['API_KEYS']; -const formatDate = (value: string | null) => - value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; - -const copy = async (text: string) => { - try { - await copyToClipboard(text); - toast.success('Key copied'); - } catch { - toast.error('Could not copy — select and copy manually'); - } -}; +// `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the +// browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7). export const ApiKeys = () => { + const { t } = useTranslation(); + const { date: formatDate } = useFormat(); + + const copy = async (text: string) => { + try { + await copyToClipboard(text); + toast.success(t('settings.profile.apiKeys.copied')); + } catch { + toast.error(t('settings.profile.apiKeys.copyFailed')); + } + }; + const client = useClient(); const queryClient = useQueryClient(); const [name, setName] = useState(''); @@ -74,7 +79,7 @@ export const ApiKeys = () => { setName(''); await refresh(); } catch (ex) { - toast.error(ex instanceof Error ? ex.message : 'Could not create the key'); + toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed')); } finally { setIsCreating(false); } @@ -85,17 +90,17 @@ export const ApiKeys = () => { await client.delete(`/api-keys/${id}/record`); await refresh(); } catch { - toast.error('Could not remove it'); + toast.error(t('settings.profile.apiKeys.forgetFailed')); } }; const revoke = async (id: number) => { try { await client.delete(`/api-keys/${id}`); - toast.success('Revoked — anything using that key is signed out'); + toast.success(t('settings.profile.apiKeys.revokeDone')); await refresh(); } catch { - toast.error('Could not revoke'); + toast.error(t('settings.profile.apiKeys.revokeFailed')); } }; @@ -116,7 +121,7 @@ export const ApiKeys = () => { if (isLoading) { return (
- Loading keys… + {t('settings.profile.apiKeys.loading')}
); } @@ -124,15 +129,13 @@ export const ApiKeys = () => { return (

- An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not - tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '} - Authorization: Bearer …. + {t('settings.profile.apiKeys.intro')} Authorization: Bearer ….

{minted && (
- Key for “{minted.name}” — shown once + {t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}

This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it, @@ -143,30 +146,35 @@ export const ApiKeys = () => { {minted.key} -

)}
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence that would send somebody looking for a bug in the list. */} - {allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'} + {allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
) : (
{keys.map((entry) => { const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now(); const dead = !!entry.revokedAt || expired; - const lastUsed = formatDate(entry.lastUsedAt); + const lastUsed = entry.lastUsedAt + ? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' }) + : null; return (
{
{entry.name} - {entry.revokedAt && revoked} - {!entry.revokedAt && expired && expired} + {entry.revokedAt && ( + {t('settings.profile.apiKeys.revoked')} + )} + {!entry.revokedAt && expired && ( + {t('settings.profile.apiKeys.expired')} + )}
{entry.prefix}… {' · '} {/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */} - {lastUsed ? `last used ${lastUsed}` : 'never used'} + {lastUsed + ? t('settings.profile.apiKeys.lastUsed', { date: lastUsed }) + : t('settings.profile.apiKeys.neverUsed')}
{dead ? ( @@ -212,7 +228,7 @@ export const ApiKeys = () => { variant="ghost" size="sm" onClick={() => void forget(entry.id)} - title="Remove this record" + title={t('settings.profile.apiKeys.forget')} className="hover:text-red-500" > @@ -222,7 +238,7 @@ export const ApiKeys = () => { variant="ghost" size="sm" onClick={() => void revoke(entry.id)} - title="Revoke" + title={t('settings.profile.apiKeys.revoke')} className="hover:text-red-500" > @@ -241,8 +257,8 @@ export const ApiKeys = () => { className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70" > {showRevoked - ? 'Hide revoked and expired' - : `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`} + ? t('settings.profile.apiKeys.hideDead') + : t('settings.profile.apiKeys.showDead', { count: deadKeys.length })} )}
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx index 5aaab053..369c87f9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { useDock } from 'officerdev'; import { usePermissions } from 'hooks/usePermissions'; import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock'; +import { useTranslation } from '@/lib/i18n'; type DockPillProps = { label: string; @@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => { }; export const DockSettings = () => { + const { t } = useTranslation(); // Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone // pin a tile that cannot appear, which reads as the setting being broken. const { plugins } = usePermissions(); @@ -193,10 +195,10 @@ export const DockSettings = () => { return (
- + {items.length === 0 && ( - Drag items here to show in dock + {t('settings.profile.dock.dragToShow')} )} {items.map((item) => ( @@ -216,9 +218,11 @@ export const DockSettings = () => { ))} - + {hiddenItems.length === 0 && ( - All items visible + + {t('settings.profile.dock.allVisible')} + )} {hiddenItems.map((item) => ( {
); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx index c9a1d5a2..13f54854 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx @@ -1,46 +1,67 @@ -import { useState } from 'react'; +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'; -const LANGUAGES = [ - { code: 'en', label: 'English' }, - { code: 'pt', label: 'Portuguese' }, - { code: 'es', label: 'Spanish' }, - { code: 'fr', label: 'French' }, - { code: 'de', label: 'German' }, - { code: 'it', label: 'Italian' }, - { code: 'nl', label: 'Dutch' }, - { code: 'ru', label: 'Russian' }, - { code: 'zh', label: 'Chinese' }, - { code: 'ja', label: 'Japanese' }, - { code: 'ko', label: 'Korean' }, - { code: 'ar', label: 'Arabic' }, - { code: 'hi', label: 'Hindi' }, - { code: 'tr', label: 'Turkish' }, - { code: 'pl', label: 'Polish' }, - { code: 'sv', label: 'Swedish' }, - { code: 'da', label: 'Danish' }, - { code: 'no', label: 'Norwegian' }, - { code: 'fi', label: 'Finnish' }, - { code: 'uk', label: 'Ukrainian' }, - { code: 'cs', label: 'Czech' }, - { code: 'ro', label: 'Romanian' }, - { code: 'el', label: 'Greek' }, - { code: 'he', label: 'Hebrew' }, - { code: 'th', label: 'Thai' }, - { code: 'vi', label: 'Vietnamese' }, - { code: 'id', label: 'Indonesian' }, - { code: 'ms', label: 'Malay' }, -]; +/** + * 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; -const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code; +/** `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(''); @@ -63,7 +84,7 @@ export const Languages = () => { save(updates); }; - const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code)); + const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code)); return (
@@ -76,7 +97,7 @@ export const Languages = () => { 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" > - {getLabel(code)} + {languageName(code)} {spoken.length > 1 && (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx index a063d529..2468ca79 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/VoicePreference.tsx @@ -12,6 +12,7 @@ import { } 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[] }; @@ -26,6 +27,7 @@ type TtsConfig = { const SERVER_DEFAULT = '__server_default__'; export const VoicePreference = () => { + const { t } = useTranslation(); const client = useClient(); const { settings, saveSettings } = useSettings(); const [voices, setVoices] = useState([]); @@ -128,13 +130,13 @@ export const VoicePreference = () => {