diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 1d75f413..bc0c03fb 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { ProfileActivity } from '../ui/ProfileActivity'; +import { useT } from '../../i18n'; import { useActivityStore } from '../../stores/activityStore'; import { Username } from '../ui/Username'; import { useUIStore } from '../../stores/uiStore'; @@ -66,6 +67,7 @@ export function UserProfileModal() { const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest); const currentUser = useAuthStore((s) => s.user); + const t = useT(); const [user, setUser] = useState(null); const [userOrigin, setUserOrigin] = useState(''); const [activeTab, setActiveTab] = useState('about'); @@ -352,7 +354,7 @@ export function UserProfileModal() { {user.bio && (
- About Me + {t('profile.aboutMe')}
- Member Since + {t('profile.memberSince')}
{new Date(user.createdAt).toLocaleDateString(undefined, { @@ -519,7 +521,7 @@ export function UserProfileModal() { onClick={handleSendMessage} className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors" > - Send Message + {t('profile.sendMessage')} {friendship.state === 'none' && ( diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index bf01f916..b896a155 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -9,6 +9,8 @@ import { useAuthStore } from '../../stores/authStore'; import { AccountPanel } from './settingsPanels/AccountPanel'; import { VoicePanel } from './settingsPanels/VoicePanel'; import { PrivacyPanel } from './settingsPanels/PrivacyPanel'; +import { LanguagePanel } from './settingsPanels/LanguagePanel'; +import { useT } from '../../i18n'; import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel'; import { DesktopPanel } from './settingsPanels/DesktopPanel'; import { InstancePanel } from './settingsPanels/InstancePanel'; @@ -16,7 +18,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel'; import { isElectron } from '../../platform/platform'; import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext'; -type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance'; +type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance'; function SidebarSubLinks() { const ctx = useSettingsSectionsContext(); @@ -60,6 +62,7 @@ export function UserSettingsModal() { const user = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); + const t = useT(); const [tab, setTab] = useState('account'); const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs'); // AGPL § 13: home-instance source offer. Fetched from the public info endpoint @@ -81,7 +84,7 @@ export function UserSettingsModal() { useEffect(() => { if (isOpen) { const requested = modalData.tab as SettingsTab | undefined; - if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'instance'].includes(requested)) { + if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'language', 'instance'].includes(requested)) { // Only allow instance tab for admins if (requested === 'instance' && !isAdmin) { setTab('account'); @@ -135,14 +138,15 @@ export function UserSettingsModal() { {/* Nav list */}
User Settings
- - - + + +
App Settings
- - + + + {isElectron() && } {isAdmin && ( @@ -192,14 +196,15 @@ export function UserSettingsModal() {
User Settings
- - - + + +
App Settings
- - + + + {isElectron() && } {isAdmin && ( @@ -249,6 +254,7 @@ export function UserSettingsModal() { {tab === 'privacy' && } {tab === 'connections' && } {tab === 'keybinds' && } + {tab === 'language' && } {tab === 'desktop' && } {tab === 'instance' && isAdmin && }
diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx index 7c6e106f..8c64d512 100644 --- a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore'; import { AudioManager } from '../../../audio/AudioManager'; import { useAudioDevices } from '../../../hooks/useAudioDevices'; import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; +import { useT } from '../../../i18n'; export function AudioInputSection() { const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const setInputDevice = useVoiceStore((s) => s.setInputDevice); const inputVolume = useVoiceStore((s) => s.inputVolume); const setInputVolume = useVoiceStore((s) => s.setInputVolume); + const t = useT(); const { permState, inputs, inputLabels, requestPermission } = useAudioDevices(); const [listOpen, setListOpen] = useState(false); @@ -96,7 +98,7 @@ export function AudioInputSection() { setMicTestError(''); const ok = await am.startMicTest(); if (!ok) { - setMicTestError('Could not open the microphone. Check the device and its permission.'); + setMicTestError(t('settings.voice.micTest.failed')); return; } setMicTesting(true); @@ -125,7 +127,7 @@ export function AudioInputSection() { if (permState === 'unknown') { return ( - +
Checking microphone access…
); @@ -133,7 +135,7 @@ export function AudioInputSection() { if (permState === 'denied') { return ( - +
⚠ Microphone access denied
@@ -152,7 +154,7 @@ export function AudioInputSection() { if (permState === 'prompt') { return ( - +
Microphone permission needed to list and choose an input device. @@ -186,7 +188,7 @@ export function AudioInputSection() { const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); return ( - +
{micTesting - ? 'Playing your mic back to you — say something.' + ? t('settings.voice.micTest.playing') : isLiveKitConnected - ? 'The level meter is live while you are in a call.' - : 'Test your mic without joining a call.'} + ? t('settings.voice.micTest.inCall') + : t('settings.voice.micTest.idle')}
{micTestError && ( diff --git a/packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx b/packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx new file mode 100644 index 00000000..4b15b41b --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx @@ -0,0 +1,48 @@ +import { SectionShell } from './_shared/SettingsPickerPrimitives'; +import { useLocaleStore, useT, LOCALES, type Locale } from '../../../i18n'; +import type { TranslationKey } from '../../../i18n'; + +const LOCALE_LABEL: Record = { + en: 'settings.language.en', + 'pt-BR': 'settings.language.ptBR', +}; + +/** + * Language picker. Each option is labelled in the active language rather than + * in its own — a reader who cannot find their way back out of a language they + * picked by mistake is the one failure this screen must not have. + */ +export function LanguagePanel() { + const t = useT(); + const locale = useLocaleStore((s) => s.locale); + const setLocale = useLocaleStore((s) => s.setLocale); + + return ( + +

+ {t('settings.language.description')} +

+
+ {LOCALES.map((option) => ( + + ))} +
+
+ ); +} diff --git a/packages/web/src/components/ui/ProfileActivity.tsx b/packages/web/src/components/ui/ProfileActivity.tsx index 0fac3121..1af571a5 100644 --- a/packages/web/src/components/ui/ProfileActivity.tsx +++ b/packages/web/src/components/ui/ProfileActivity.tsx @@ -1,17 +1,17 @@ import { useEffect, useState } from 'react'; import type { Activity } from '@backspace/shared'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; +import { useT, type TranslationKey } from '../../i18n'; interface ProfileActivityProps { activities: Activity[]; } -const VERB: Record = { - playing: 'Playing', - listening: 'Listening to', - watching: 'Watching', - streaming: 'Streaming', - custom: '', +const VERB_KEY: Record, TranslationKey> = { + playing: 'profile.activity.playing', + listening: 'profile.activity.listening', + watching: 'profile.activity.watching', + streaming: 'profile.activity.streaming', }; function formatClock(ms: number): string { @@ -34,6 +34,7 @@ function formatClock(ms: number): string { * producer would fill in. */ export function ProfileActivity({ activities }: ProfileActivityProps) { + const t = useT(); const primary = getPrimaryActivity(activities); const start = primary?.timestamps?.start; const end = primary?.timestamps?.end; @@ -60,7 +61,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) { return (
- {VERB[primary.type]} {primary.name} + {t(VERB_KEY[primary.type])} {primary.name}
{artSrc && ( @@ -96,7 +97,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
) : start ? (
- {formatClock(elapsed)} elapsed + {t('profile.activity.elapsed', { time: formatClock(elapsed) })}
) : null}
diff --git a/packages/web/src/i18n/i18n.test.ts b/packages/web/src/i18n/i18n.test.ts new file mode 100644 index 00000000..f02bcbec --- /dev/null +++ b/packages/web/src/i18n/i18n.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { translate } from './index'; +import { en } from './locales/en'; +import { ptBR } from './locales/pt-BR'; + +describe('translate', () => { + it('returns the translation for the active locale', () => { + expect(translate('pt-BR', 'settings.tab.account')).toBe('Conta'); + }); + + it('falls back to English for a key the locale has not translated yet', () => { + // The whole migration strategy depends on this: pt-BR is deliberately + // partial, and an untranslated screen must read in English rather than + // break. + const untranslated = (Object.keys(en) as (keyof typeof en)[]).find((k) => !(k in ptBR)); + if (!untranslated) return; // pt-BR fully caught up — nothing to assert + expect(translate('pt-BR', untranslated)).toBe(en[untranslated]); + }); + + it('substitutes named parameters', () => { + expect(translate('en', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 elapsed'); + expect(translate('pt-BR', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 decorrido'); + }); + + it('leaves a placeholder alone when no value is supplied', () => { + expect(translate('en', 'profile.activity.elapsed')).toBe('{time} elapsed'); + }); + + it('keeps every pt-BR key present in the source dictionary', () => { + // Guards against a key being renamed in en.ts while pt-BR keeps the old + // one, which would silently fall back forever. + for (const key of Object.keys(ptBR)) { + expect(en).toHaveProperty(key); + } + }); +}); diff --git a/packages/web/src/i18n/index.ts b/packages/web/src/i18n/index.ts new file mode 100644 index 00000000..f10ce960 --- /dev/null +++ b/packages/web/src/i18n/index.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { en, type TranslationKey } from './locales/en'; +import { ptBR } from './locales/pt-BR'; + +export const LOCALES = ['en', 'pt-BR'] as const; +export type Locale = (typeof LOCALES)[number]; + +const DICTIONARIES: Record>> = { + en, + 'pt-BR': ptBR, +}; + +/** + * First-run guess from the browser. Persisted afterwards, so an explicit + * choice always wins over the browser's setting on later visits. + */ +function detectLocale(): Locale { + if (typeof navigator === 'undefined') return 'en'; + return navigator.language?.toLowerCase().startsWith('pt') ? 'pt-BR' : 'en'; +} + +interface LocaleState { + locale: Locale; + setLocale: (locale: Locale) => void; +} + +export const useLocaleStore = create()( + persist( + (set) => ({ + locale: detectLocale(), + setLocale: (locale) => set({ locale }), + }), + { name: 'backspace-locale' }, + ), +); + +// Keep in sync: screen readers, spellcheck and hyphenation all read +// it, and persisted state rehydrates after the first paint — hence the +// subscription rather than a one-off assignment. +if (typeof document !== 'undefined') { + document.documentElement.lang = useLocaleStore.getState().locale; + useLocaleStore.subscribe((state) => { + document.documentElement.lang = state.locale; + }); +} + +/** + * Resolves a key, substituting `{name}` placeholders. + * + * Falls back to English, then to the key itself. The key is a deliberate last + * resort: it is ugly on screen, which makes a missing entry obvious in review + * instead of silently rendering an empty string. + */ +export function translate( + locale: Locale, + key: TranslationKey, + params?: Record, +): string { + const template = DICTIONARIES[locale]?.[key] ?? en[key] ?? key; + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); +} + +/** + * Subscribes the calling component to the active locale, so switching language + * re-renders it. Components that only need the string once (outside React) can + * call `translate` with `useLocaleStore.getState().locale` instead. + */ +export function useT() { + const locale = useLocaleStore((s) => s.locale); + return (key: TranslationKey, params?: Record) => + translate(locale, key, params); +} + +export type { TranslationKey }; diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts new file mode 100644 index 00000000..8e73ea43 --- /dev/null +++ b/packages/web/src/i18n/locales/en.ts @@ -0,0 +1,48 @@ +/** + * Source dictionary. Every key the app can translate is declared here, and its + * type is derived from this object — a typo or a missing key fails typecheck + * rather than silently rendering the raw key at runtime. + * + * Keys are flat and dot-namespaced by system (`settings.voice.*`), so a + * translation pass can take one system at a time. + */ +export const en = { + // Settings — navigation + 'settings.tab.account': 'Account', + 'settings.tab.voice': 'Voice & Video', + 'settings.tab.privacy': 'Privacy', + 'settings.tab.connections': 'Connections', + 'settings.tab.keybinds': 'Keybinds', + 'settings.tab.desktop': 'Desktop', + 'settings.tab.instance': 'Instance', + 'settings.tab.language': 'Language', + + // Settings — language + 'settings.language.title': 'Language', + 'settings.language.description': 'Choose the language for the interface. Anything not yet translated stays in English.', + 'settings.language.en': 'English', + 'settings.language.ptBR': 'Portuguese (Brazil)', + + // Settings — voice: input + 'settings.voice.input.title': 'Input Device', + 'settings.voice.input.volume': 'Input Volume', + 'settings.voice.micTest.start': "Let's Check", + 'settings.voice.micTest.stop': 'Stop Testing', + 'settings.voice.micTest.playing': 'Playing your mic back to you — say something.', + 'settings.voice.micTest.inCall': 'The level meter is live while you are in a call.', + 'settings.voice.micTest.idle': 'Test your mic without joining a call.', + 'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.', + + // Profile card + 'profile.aboutMe': 'About Me', + 'profile.memberSince': 'Member Since', + 'profile.sendMessage': 'Send Message', + 'profile.activity.playing': 'Playing', + 'profile.activity.listening': 'Listening to', + 'profile.activity.watching': 'Watching', + 'profile.activity.streaming': 'Streaming', + 'profile.activity.elapsed': '{time} elapsed', +} as const; + +export type TranslationKey = keyof typeof en; +export type Dictionary = Record; diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts new file mode 100644 index 00000000..80b2b409 --- /dev/null +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -0,0 +1,44 @@ +import type { Dictionary } from './en'; + +/** + * Partial on purpose. Translation happens one system per update, and anything + * absent here falls back to English — so a half-migrated interface is never + * broken, just partly in English. + */ +export const ptBR: Partial = { + // Configurações — navegação + 'settings.tab.account': 'Conta', + 'settings.tab.voice': 'Voz e Vídeo', + 'settings.tab.privacy': 'Privacidade', + 'settings.tab.connections': 'Conexões', + 'settings.tab.keybinds': 'Atalhos', + 'settings.tab.desktop': 'Desktop', + 'settings.tab.instance': 'Instância', + 'settings.tab.language': 'Idioma', + + // Configurações — idioma + 'settings.language.title': 'Idioma', + 'settings.language.description': 'Escolha o idioma da interface. O que ainda não foi traduzido continua em inglês.', + 'settings.language.en': 'Inglês', + 'settings.language.ptBR': 'Português (Brasil)', + + // Configurações — voz: entrada + 'settings.voice.input.title': 'Dispositivo de entrada', + 'settings.voice.input.volume': 'Volume de entrada', + 'settings.voice.micTest.start': 'Testar microfone', + 'settings.voice.micTest.stop': 'Parar teste', + 'settings.voice.micTest.playing': 'Devolvendo seu microfone para você — fale alguma coisa.', + 'settings.voice.micTest.inCall': 'O medidor fica ativo enquanto você está numa call.', + 'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.', + 'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.', + + // Cartão de perfil + 'profile.aboutMe': 'Sobre mim', + 'profile.memberSince': 'Membro desde', + 'profile.sendMessage': 'Enviar mensagem', + 'profile.activity.playing': 'Jogando', + 'profile.activity.listening': 'Ouvindo', + 'profile.activity.watching': 'Assistindo', + 'profile.activity.streaming': 'Transmitindo', + 'profile.activity.elapsed': '{time} decorrido', +};