Compare commits
2
Commits
89e13441c8
...
c022f2795f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c022f2795f | ||
|
|
688a1335cb |
+15
-4
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## 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
|
||||
commits seguem em inglês.
|
||||
|
||||
**Pré-requisito não óbvio:** o projeto não tem sistema de i18n algum — as
|
||||
strings estão fixas em inglês dentro dos componentes. Traduzir "um sistema por
|
||||
update" só é possível depois da fundação abaixo.
|
||||
A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
|
||||
chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
|
||||
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
|
||||
|
||||
@@ -88,7 +100,6 @@ Ordenadas por relação valor/custo para um servidor de grupo fechado.
|
||||
|
||||
| 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 |
|
||||
| **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 |
|
||||
|
||||
@@ -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<User | null>(null);
|
||||
const [userOrigin, setUserOrigin] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('about');
|
||||
@@ -352,7 +354,7 @@ export function UserProfileModal() {
|
||||
{user.bio && (
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
About Me
|
||||
{t('profile.aboutMe')}
|
||||
</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">
|
||||
<ReactMarkdown
|
||||
@@ -376,7 +378,7 @@ export function UserProfileModal() {
|
||||
{/* Member Since */}
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
Member Since
|
||||
{t('profile.memberSince')}
|
||||
</span>
|
||||
<div className="text-[13px] text-txt-secondary mt-1">
|
||||
{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')}
|
||||
</button>
|
||||
|
||||
{friendship.state === 'none' && (
|
||||
|
||||
@@ -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<SettingsTab>('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 */}
|
||||
<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>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice & Video</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</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="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('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
|
||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</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>}
|
||||
|
||||
{isAdmin && (
|
||||
@@ -192,14 +196,15 @@ export function UserSettingsModal() {
|
||||
|
||||
<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>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice & Video</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</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="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('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
|
||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</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>}
|
||||
|
||||
{isAdmin && (
|
||||
@@ -249,6 +254,7 @@ export function UserSettingsModal() {
|
||||
{tab === 'privacy' && <PrivacyPanel />}
|
||||
{tab === 'connections' && <ConnectionsPanel />}
|
||||
{tab === 'keybinds' && <KeybindsPanel />}
|
||||
{tab === 'language' && <LanguagePanel />}
|
||||
{tab === 'desktop' && <DesktopPanel />}
|
||||
{tab === 'instance' && isAdmin && <InstancePanel />}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="text-sm text-txt-tertiary">Checking microphone access…</div>
|
||||
</SectionShell>
|
||||
);
|
||||
@@ -133,7 +135,7 @@ export function AudioInputSection() {
|
||||
|
||||
if (permState === 'denied') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-txt-primary">⚠ Microphone access denied</div>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
@@ -152,7 +154,7 @@ export function AudioInputSection() {
|
||||
|
||||
if (permState === 'prompt') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
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 (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="space-y-3">
|
||||
<div ref={dropdownRef}>
|
||||
<button
|
||||
@@ -223,7 +225,7 @@ export function AudioInputSection() {
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
@@ -258,14 +260,14 @@ export function AudioInputSection() {
|
||||
: '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>
|
||||
<span className="text-xs text-txt-tertiary">
|
||||
{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')}
|
||||
</span>
|
||||
</div>
|
||||
{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 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<Activity['type'], string> = {
|
||||
playing: 'Playing',
|
||||
listening: 'Listening to',
|
||||
watching: 'Watching',
|
||||
streaming: 'Streaming',
|
||||
custom: '',
|
||||
const VERB_KEY: Record<Exclude<Activity['type'], 'custom'>, 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 (
|
||||
<div>
|
||||
<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>
|
||||
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
||||
{artSrc && (
|
||||
@@ -96,7 +97,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||
</div>
|
||||
) : start ? (
|
||||
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
||||
{formatClock(elapsed)} elapsed
|
||||
{t('profile.activity.elapsed', { time: formatClock(elapsed) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user