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.
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Modal } from '../ui/Modal';
|
|
import { Avatar } from '../ui/Avatar';
|
|
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
|
import { api } from '../../api/client';
|
|
import type { InstanceInfoResponse } from '@backspace/shared';
|
|
import { useUIStore } from '../../stores/uiStore';
|
|
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';
|
|
import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
|
|
import { isElectron } from '../../platform/platform';
|
|
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
|
|
|
|
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance';
|
|
|
|
function SidebarSubLinks() {
|
|
const ctx = useSettingsSectionsContext();
|
|
if (!ctx || ctx.sections.length === 0) return null;
|
|
|
|
return (
|
|
<div className="overflow-hidden">
|
|
{ctx.sections.map((section) => (
|
|
<button
|
|
key={section.id}
|
|
onClick={() => ctx.scrollToSection(section.id)}
|
|
className={`w-full text-left pl-6 py-1 text-xs rounded-md transition-colors ${
|
|
ctx.activeSection === section.id
|
|
? 'text-txt-primary'
|
|
: 'text-txt-tertiary hover:text-txt-secondary'
|
|
}`}
|
|
aria-current={ctx.activeSection === section.id ? 'true' : undefined}
|
|
>
|
|
{section.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsScrollContainer({ children }: { children: React.ReactNode }) {
|
|
const ctx = useSettingsSectionsContext();
|
|
return (
|
|
<div ref={ctx?.scrollContainerRef} className="flex-1 min-w-0 overflow-y-auto scrollbar-thin py-6">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UserSettingsModal() {
|
|
const activeModal = useUIStore((s) => s.activeModal);
|
|
const modalData = useUIStore((s) => s.modalData);
|
|
const closeModal = useUIStore((s) => s.closeModal);
|
|
const isMobile = useUIStore((s) => s.isMobile);
|
|
const isAdmin = useAuthStore((s) => s.user?.isAdmin);
|
|
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
|
|
// so the source link reflects the version this instance is actually running.
|
|
const [instanceInfo, setInstanceInfo] = useState<InstanceInfoResponse | null>(null);
|
|
|
|
const isOpen = activeModal === 'userSettings';
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
let cancelled = false;
|
|
api.instance.info()
|
|
.then((info) => { if (!cancelled) setInstanceInfo(info); })
|
|
.catch(() => { /* Non-critical — link falls back to hidden if unreachable. */ });
|
|
return () => { cancelled = true; };
|
|
}, [isOpen]);
|
|
|
|
// Deep-linking: read modalData.tab when opening
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
const requested = modalData.tab as SettingsTab | undefined;
|
|
if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'language', 'instance'].includes(requested)) {
|
|
// Only allow instance tab for admins
|
|
if (requested === 'instance' && !isAdmin) {
|
|
setTab('account');
|
|
} else {
|
|
setTab(requested);
|
|
}
|
|
} else {
|
|
setTab('account');
|
|
}
|
|
// On mobile, if deep-linking to a tab, show content directly
|
|
setMobileView(requested ? 'content' : 'tabs');
|
|
}
|
|
}, [isOpen, modalData.tab, isAdmin]);
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
closeModal();
|
|
};
|
|
|
|
const tabClass = (t: SettingsTab) =>
|
|
`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
|
tab === t ? 'bg-interactive-selected text-txt-primary font-medium' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
}`;
|
|
|
|
const handleTabClick = (t: SettingsTab) => {
|
|
setTab(t);
|
|
if (isMobile) setMobileView('content');
|
|
};
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} onClose={closeModal} size="settings" mobileStyle="fullscreen">
|
|
<SettingsSectionsProvider>
|
|
<div className="flex h-full">
|
|
{/* Desktop Sidebar */}
|
|
<div className="hidden md:flex w-52 flex-shrink-0 flex-col p-4 gap-3">
|
|
{/* User card */}
|
|
<div className="glass-bubble rounded-lg p-3 flex items-center gap-3">
|
|
<Avatar
|
|
src={user?.avatar}
|
|
name={user?.displayName || user?.username || ''}
|
|
size={36}
|
|
userId={user?.id}
|
|
avatarColor={user?.avatarColor}
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-txt-primary truncate">{user?.displayName || user?.username}</div>
|
|
<div className="text-xs text-txt-tertiary truncate">@{user?.username}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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')}>{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')}>{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 && (
|
|
<>
|
|
<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">Administration</div>
|
|
<button onClick={() => handleTabClick('instance')} className={tabClass('instance')}>Instance</button>
|
|
{tab === 'instance' && <SidebarSubLinks />}
|
|
</>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full text-left px-3 py-2 rounded-md text-sm text-txt-danger hover:bg-accent-rose/10 transition-colors"
|
|
>
|
|
Log Out
|
|
</button>
|
|
|
|
{instanceInfo && (
|
|
<div className="px-3 pt-2">
|
|
<SourceCodeLink sourceCodeUrl={instanceInfo.sourceCodeUrl} version={instanceInfo.version} commit={instanceInfo.commit} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile: Tab list */}
|
|
{isMobile && mobileView === 'tabs' && (
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
{/* Mobile user card */}
|
|
<div className="glass-bubble rounded-lg p-3 flex items-center gap-3">
|
|
<Avatar
|
|
src={user?.avatar}
|
|
name={user?.displayName || user?.username || ''}
|
|
size={36}
|
|
userId={user?.id}
|
|
avatarColor={user?.avatarColor}
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-txt-primary truncate">{user?.displayName || user?.username}</div>
|
|
<div className="text-xs text-txt-tertiary truncate">@{user?.username}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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')}>{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')}>{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 && (
|
|
<>
|
|
<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">Administration</div>
|
|
<button onClick={() => handleTabClick('instance')} className={tabClass('instance')}>Instance</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full text-left px-3 py-2 rounded-md text-sm text-txt-danger hover:bg-accent-rose/10 transition-colors"
|
|
>
|
|
Log Out
|
|
</button>
|
|
|
|
{instanceInfo && (
|
|
<div className="px-3 pt-2">
|
|
<SourceCodeLink sourceCodeUrl={instanceInfo.sourceCodeUrl} version={instanceInfo.version} commit={instanceInfo.commit} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content area (desktop always, mobile only when viewing content) */}
|
|
{(!isMobile || mobileView === 'content') && (
|
|
<SettingsScrollContainer>
|
|
<div className="px-6 max-w-[640px] mx-auto">
|
|
{/* Mobile back button */}
|
|
{isMobile && (
|
|
<button
|
|
onClick={() => setMobileView('tabs')}
|
|
className="flex items-center gap-1.5 text-txt-tertiary hover:text-txt-secondary mb-4 text-sm"
|
|
aria-label="Back to settings menu"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
|
</svg>
|
|
Settings
|
|
</button>
|
|
)}
|
|
{tab === 'account' && <AccountPanel />}
|
|
{tab === 'voice' && <VoicePanel />}
|
|
{tab === 'privacy' && <PrivacyPanel />}
|
|
{tab === 'connections' && <ConnectionsPanel />}
|
|
{tab === 'keybinds' && <KeybindsPanel />}
|
|
{tab === 'language' && <LanguagePanel />}
|
|
{tab === 'desktop' && <DesktopPanel />}
|
|
{tab === 'instance' && isAdmin && <InstancePanel />}
|
|
</div>
|
|
</SettingsScrollContainer>
|
|
)}
|
|
</div>
|
|
</SettingsSectionsProvider>
|
|
</Modal>
|
|
);
|
|
}
|