Compare commits

...
2 Commits
Author SHA1 Message Date
devsyncwrld c022f2795f docs: record the i18n foundation and translation queue
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
2026-08-31 12:25:03 -03:00
devsyncwrld 688a1335cb feat(i18n): language foundation with en and pt-BR
Nothing in the project was translatable — every string sat inline in English.

en.ts is the source dictionary and its type is derived from it, so a typo or a
missing key fails typecheck instead of rendering the raw key at runtime.
pt-BR.ts is deliberately Partial: translation proceeds one system per update
and anything absent falls back to English, so a half-migrated interface is
never broken, only partly English.

Locale is persisted, guessed from the browser on first run, and kept in sync
with <html lang> through a subscription — persisted state rehydrates after
first paint, so a one-off assignment would miss it.

Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
2026-08-31 12:24:50 -03:00
10 changed files with 313 additions and 37 deletions
+15 -4
View File
@@ -15,6 +15,7 @@ código e os commits seguem em inglês, como o resto do repositório.
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor | | Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes | | Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor | | Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade | | Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
## Já existia no código (verificado, não construir de novo) ## Já existia no código (verificado, não construir de novo)
@@ -78,9 +79,20 @@ Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
commits seguem em inglês. commits seguem em inglês.
**Pré-requisito não óbvio:** o projeto não tem sistema de i18n algum — as A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
strings estão fixas em inglês dentro dos componentes. Traduzir "um sistema por chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
update" só é possível depois da fundação abaixo. fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
### Sistemas já traduzidos
- Configurações → navegação e aba Idioma
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
### Fila sugerida de tradução
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
modais de convite · configurações restantes · telas de erro
## Ideias novas — a avaliar ## Ideias novas — a avaliar
@@ -88,7 +100,6 @@ Ordenadas por relação valor/custo para um servidor de grupo fechado.
| Sistema | Tamanho | Por que faz sentido aqui | | Sistema | Tamanho | Por que faz sentido aqui |
|---|---|---| |---|---|---|
| **Fundação de i18n** | Média | Bloqueia a regra de idioma acima. Dicionário por idioma + hook de tradução + seletor nas configurações; migração componente a componente, um por update |
| **Fechar cadastro + convites** | Pequena | A instância está com `REGISTRATION_OPEN=true`: qualquer um cria conta. O `InviteModal` já existe — é trocar cadastro aberto por convite | | **Fechar cadastro + convites** | Pequena | A instância está com `REGISTRATION_OPEN=true`: qualquer um cria conta. O `InviteModal` já existe — é trocar cadastro aberto por convite |
| **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve | | **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve |
| **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal | | **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal |
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown';
import type { User } from '@backspace/shared'; import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { ProfileActivity } from '../ui/ProfileActivity'; import { ProfileActivity } from '../ui/ProfileActivity';
import { useT } from '../../i18n';
import { useActivityStore } from '../../stores/activityStore'; import { useActivityStore } from '../../stores/activityStore';
import { Username } from '../ui/Username'; import { Username } from '../ui/Username';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
@@ -66,6 +67,7 @@ export function UserProfileModal() {
const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest); const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest);
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const t = useT();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [userOrigin, setUserOrigin] = useState(''); const [userOrigin, setUserOrigin] = useState('');
const [activeTab, setActiveTab] = useState<Tab>('about'); const [activeTab, setActiveTab] = useState<Tab>('about');
@@ -352,7 +354,7 @@ export function UserProfileModal() {
{user.bio && ( {user.bio && (
<div> <div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary"> <span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
About Me {t('profile.aboutMe')}
</span> </span>
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline"> <div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
<ReactMarkdown <ReactMarkdown
@@ -376,7 +378,7 @@ export function UserProfileModal() {
{/* Member Since */} {/* Member Since */}
<div> <div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary"> <span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
Member Since {t('profile.memberSince')}
</span> </span>
<div className="text-[13px] text-txt-secondary mt-1"> <div className="text-[13px] text-txt-secondary mt-1">
{new Date(user.createdAt).toLocaleDateString(undefined, { {new Date(user.createdAt).toLocaleDateString(undefined, {
@@ -519,7 +521,7 @@ export function UserProfileModal() {
onClick={handleSendMessage} 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" 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')}
</button> </button>
{friendship.state === 'none' && ( {friendship.state === 'none' && (
@@ -9,6 +9,8 @@ import { useAuthStore } from '../../stores/authStore';
import { AccountPanel } from './settingsPanels/AccountPanel'; import { AccountPanel } from './settingsPanels/AccountPanel';
import { VoicePanel } from './settingsPanels/VoicePanel'; import { VoicePanel } from './settingsPanels/VoicePanel';
import { PrivacyPanel } from './settingsPanels/PrivacyPanel'; import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
import { LanguagePanel } from './settingsPanels/LanguagePanel';
import { useT } from '../../i18n';
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel'; import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
import { DesktopPanel } from './settingsPanels/DesktopPanel'; import { DesktopPanel } from './settingsPanels/DesktopPanel';
import { InstancePanel } from './settingsPanels/InstancePanel'; import { InstancePanel } from './settingsPanels/InstancePanel';
@@ -16,7 +18,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
import { isElectron } from '../../platform/platform'; import { isElectron } from '../../platform/platform';
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext'; 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() { function SidebarSubLinks() {
const ctx = useSettingsSectionsContext(); const ctx = useSettingsSectionsContext();
@@ -60,6 +62,7 @@ export function UserSettingsModal() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const t = useT();
const [tab, setTab] = useState<SettingsTab>('account'); const [tab, setTab] = useState<SettingsTab>('account');
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs'); const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
// AGPL § 13: home-instance source offer. Fetched from the public info endpoint // AGPL § 13: home-instance source offer. Fetched from the public info endpoint
@@ -81,7 +84,7 @@ export function UserSettingsModal() {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const requested = modalData.tab as SettingsTab | undefined; 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 // Only allow instance tab for admins
if (requested === 'instance' && !isAdmin) { if (requested === 'instance' && !isAdmin) {
setTab('account'); setTab('account');
@@ -135,14 +138,15 @@ export function UserSettingsModal() {
{/* Nav list */} {/* Nav list */}
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col"> <div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button> <button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice &amp; Video</button> <button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button> <button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
<div className="border-t border-white/[0.04] my-2 mx-2" /> <div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button> <button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button> <button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>} {isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isAdmin && ( {isAdmin && (
@@ -192,14 +196,15 @@ export function UserSettingsModal() {
<div className="glass-bubble rounded-lg p-2 space-y-0.5"> <div className="glass-bubble rounded-lg p-2 space-y-0.5">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button> <button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice &amp; Video</button> <button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button> <button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
<div className="border-t border-white/[0.04] my-2 mx-2" /> <div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div> <div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button> <button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button> <button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>} {isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isAdmin && ( {isAdmin && (
@@ -249,6 +254,7 @@ export function UserSettingsModal() {
{tab === 'privacy' && <PrivacyPanel />} {tab === 'privacy' && <PrivacyPanel />}
{tab === 'connections' && <ConnectionsPanel />} {tab === 'connections' && <ConnectionsPanel />}
{tab === 'keybinds' && <KeybindsPanel />} {tab === 'keybinds' && <KeybindsPanel />}
{tab === 'language' && <LanguagePanel />}
{tab === 'desktop' && <DesktopPanel />} {tab === 'desktop' && <DesktopPanel />}
{tab === 'instance' && isAdmin && <InstancePanel />} {tab === 'instance' && isAdmin && <InstancePanel />}
</div> </div>
@@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore';
import { AudioManager } from '../../../audio/AudioManager'; import { AudioManager } from '../../../audio/AudioManager';
import { useAudioDevices } from '../../../hooks/useAudioDevices'; import { useAudioDevices } from '../../../hooks/useAudioDevices';
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
import { useT } from '../../../i18n';
export function AudioInputSection() { export function AudioInputSection() {
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const setInputDevice = useVoiceStore((s) => s.setInputDevice); const setInputDevice = useVoiceStore((s) => s.setInputDevice);
const inputVolume = useVoiceStore((s) => s.inputVolume); const inputVolume = useVoiceStore((s) => s.inputVolume);
const setInputVolume = useVoiceStore((s) => s.setInputVolume); const setInputVolume = useVoiceStore((s) => s.setInputVolume);
const t = useT();
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices(); const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
const [listOpen, setListOpen] = useState(false); const [listOpen, setListOpen] = useState(false);
@@ -96,7 +98,7 @@ export function AudioInputSection() {
setMicTestError(''); setMicTestError('');
const ok = await am.startMicTest(); const ok = await am.startMicTest();
if (!ok) { if (!ok) {
setMicTestError('Could not open the microphone. Check the device and its permission.'); setMicTestError(t('settings.voice.micTest.failed'));
return; return;
} }
setMicTesting(true); setMicTesting(true);
@@ -125,7 +127,7 @@ export function AudioInputSection() {
if (permState === 'unknown') { if (permState === 'unknown') {
return ( return (
<SectionShell title="Input Device"> <SectionShell title={t('settings.voice.input.title')}>
<div className="text-sm text-txt-tertiary">Checking microphone access</div> <div className="text-sm text-txt-tertiary">Checking microphone access</div>
</SectionShell> </SectionShell>
); );
@@ -133,7 +135,7 @@ export function AudioInputSection() {
if (permState === 'denied') { if (permState === 'denied') {
return ( return (
<SectionShell title="Input Device"> <SectionShell title={t('settings.voice.input.title')}>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm text-txt-primary"> Microphone access denied</div> <div className="text-sm text-txt-primary"> Microphone access denied</div>
<div className="text-xs text-txt-tertiary"> <div className="text-xs text-txt-tertiary">
@@ -152,7 +154,7 @@ export function AudioInputSection() {
if (permState === 'prompt') { if (permState === 'prompt') {
return ( return (
<SectionShell title="Input Device"> <SectionShell title={t('settings.voice.input.title')}>
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs text-txt-tertiary"> <div className="text-xs text-txt-tertiary">
Microphone permission needed to list and choose an input device. 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)); const activeBars = Math.round(micLevel * micBars * (inputVolume / 100));
return ( return (
<SectionShell title="Input Device"> <SectionShell title={t('settings.voice.input.title')}>
<div className="space-y-3"> <div className="space-y-3">
<div ref={dropdownRef}> <div ref={dropdownRef}>
<button <button
@@ -223,7 +225,7 @@ export function AudioInputSection() {
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<div className="text-[13px] font-medium text-txt-primary">Input Volume</div> <div className="text-[13px] font-medium text-txt-primary">{t('settings.voice.input.volume')}</div>
<div className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div> <div className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div>
</div> </div>
<input <input
@@ -258,14 +260,14 @@ export function AudioInputSection() {
: 'bg-accent-primary text-white hover:brightness-110' : 'bg-accent-primary text-white hover:brightness-110'
}`} }`}
> >
{micTesting ? 'Stop Testing' : "Let's Check"} {micTesting ? t('settings.voice.micTest.stop') : t('settings.voice.micTest.start')}
</button> </button>
<span className="text-xs text-txt-tertiary"> <span className="text-xs text-txt-tertiary">
{micTesting {micTesting
? 'Playing your mic back to you — say something.' ? t('settings.voice.micTest.playing')
: isLiveKitConnected : isLiveKitConnected
? 'The level meter is live while you are in a call.' ? t('settings.voice.micTest.inCall')
: 'Test your mic without joining a call.'} : t('settings.voice.micTest.idle')}
</span> </span>
</div> </div>
{micTestError && ( {micTestError && (
@@ -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<Locale, TranslationKey> = {
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 (
<SectionShell title={t('settings.language.title')}>
<p className="text-[13px] text-txt-tertiary mb-3">
{t('settings.language.description')}
</p>
<div className="flex flex-col gap-1.5">
{LOCALES.map((option) => (
<button
key={option}
type="button"
onClick={() => setLocale(option)}
className={`flex items-center justify-between px-3 py-2 rounded-md text-[14px] text-left transition-colors ${
option === locale
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-secondary hover:bg-interactive-hover'
}`}
>
<span>{t(LOCALE_LABEL[option])}</span>
{option === locale && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</button>
))}
</div>
</SectionShell>
);
}
@@ -1,17 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { Activity } from '@backspace/shared'; import type { Activity } from '@backspace/shared';
import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
import { useT, type TranslationKey } from '../../i18n';
interface ProfileActivityProps { interface ProfileActivityProps {
activities: Activity[]; activities: Activity[];
} }
const VERB: Record<Activity['type'], string> = { const VERB_KEY: Record<Exclude<Activity['type'], 'custom'>, TranslationKey> = {
playing: 'Playing', playing: 'profile.activity.playing',
listening: 'Listening to', listening: 'profile.activity.listening',
watching: 'Watching', watching: 'profile.activity.watching',
streaming: 'Streaming', streaming: 'profile.activity.streaming',
custom: '',
}; };
function formatClock(ms: number): string { function formatClock(ms: number): string {
@@ -34,6 +34,7 @@ function formatClock(ms: number): string {
* producer would fill in. * producer would fill in.
*/ */
export function ProfileActivity({ activities }: ProfileActivityProps) { export function ProfileActivity({ activities }: ProfileActivityProps) {
const t = useT();
const primary = getPrimaryActivity(activities); const primary = getPrimaryActivity(activities);
const start = primary?.timestamps?.start; const start = primary?.timestamps?.start;
const end = primary?.timestamps?.end; const end = primary?.timestamps?.end;
@@ -60,7 +61,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
return ( return (
<div> <div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary"> <span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
{VERB[primary.type]} {primary.name} {t(VERB_KEY[primary.type])} {primary.name}
</span> </span>
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5"> <div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
{artSrc && ( {artSrc && (
@@ -96,7 +97,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
</div> </div>
) : start ? ( ) : start ? (
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums"> <div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
{formatClock(elapsed)} elapsed {t('profile.activity.elapsed', { time: formatClock(elapsed) })}
</div> </div>
) : null} ) : null}
</div> </div>
+36
View File
@@ -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);
}
});
});
+78
View File
@@ -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<Locale, Partial<Record<TranslationKey, string>>> = {
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<LocaleState>()(
persist(
(set) => ({
locale: detectLocale(),
setLocale: (locale) => set({ locale }),
}),
{ name: 'backspace-locale' },
),
);
// Keep <html lang> 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, string | number>,
): 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<string, string | number>) =>
translate(locale, key, params);
}
export type { TranslationKey };
+48
View File
@@ -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<TranslationKey, string>;
+44
View File
@@ -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<Dictionary> = {
// 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',
};