From 34b972ec2a5178b0b20ca733b8258bd949a49f92 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 15:28:03 +0200 Subject: [PATCH] refactor(desktop): Settings = tab pattern instead of scroll + observer The previous build used IntersectionObserver to highlight whichever section was in view. With nine sections of unequal heights and smooth- scroll firing observer callbacks mid-scroll, the active highlight drifted (clicking 'Konto' showed 'Soundboard' as active because the last section never crossed the observer's 20%-30% band). Switched to a tab pattern (macOS System Settings / Discord / GitHub style): the sidebar selects ONE section, only that section renders. `useState` is the single source of truth; no observer, no scrolling between sections, no anchor links to clash with HashRouter. Mobile fallback (` dropdown above the panel. Drops the now-unused `id` prop from Section and removes the IntersectionObserver effect. --- apps/desktop/src/pages/SettingsPage.tsx | 443 ++++++++++++------------ 1 file changed, 221 insertions(+), 222 deletions(-) diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx index c9e0fb1..dfa1c4b 100644 --- a/apps/desktop/src/pages/SettingsPage.tsx +++ b/apps/desktop/src/pages/SettingsPage.tsx @@ -111,69 +111,48 @@ export function SettingsPage() { void patchProfile({ locale }); } - // Single-page layout with a sticky sidebar of anchor links on wide screens. - // Each Section gets a stable `id` so links scroll to it; an IntersectionObserver - // highlights whichever section is currently in view (active link). - const sections = [ - { id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon }, - { id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon }, - { id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon }, + // Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE + // section and only that section renders. activeTab is the single source of + // truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link + // routing conflict with HashRouter. + type TabId = + | 'profile' | 'appearance' | 'privacy' | 'notifications' + | 'voice' | 'screen-share' | 'soundboard' | 'security' | 'account'; + + const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [ + { id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon }, + { id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon }, + { id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon }, { id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon }, - { id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon }, - { id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon }, - { id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon }, - { id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon }, - { id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon }, + { id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon }, + { id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon }, + { id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon }, + { id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon }, + { id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon }, ]; - const [activeId, setActiveId] = useState(sections[0]!.id); - useEffect(() => { - if (typeof IntersectionObserver === 'undefined') return; - const observer = new IntersectionObserver( - (entries) => { - const visible = entries - .filter((e) => e.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; - if (visible?.target.id) setActiveId(visible.target.id); - }, - { rootMargin: '-20% 0px -70% 0px', threshold: [0, 0.5, 1] }, - ); - sections.forEach((s) => { - const el = document.getElementById(s.id); - if (el) observer.observe(el); - }); - return () => observer.disconnect(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const [activeTab, setActiveTab] = useState('profile'); return (
- {/* Sidebar — sticky anchor nav, hidden on narrow screens */} + {/* Sidebar */} - {/* Content column */} -
- {/* Mobile-only header (sidebar replaces it on lg+) */} -
+ {/* Content panel — only the active tab renders */} +
+ {/* Mobile-only header + tab selector (sidebar is hidden below lg) */} +

{t('app:settings.title')}

-
- -
- - - - } label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono /> -
- -
- - -
- {SUPPORTED_LOCALES.map((locale) => { - const active = (i18n.resolvedLanguage ?? i18n.language) === locale; - return ( - - ); - })} -
-
- - - -
- -
- void patchProfile({ showReadReceipts: v })} - /> - void patchProfile({ allowDmsFromStrangers: v })} - /> -
- -
- - - - - - -
- -
- - - - - - - - - - -
- - - - - -
-
- - - -
- -
- -
- -
- -
- -
- {profile?.userId && } -
- -
-
+ +
+ {activeTab === 'profile' && ( +
+ + + + } label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono /> +
+ )} + + {activeTab === 'appearance' && ( +
+ + +
+ {SUPPORTED_LOCALES.map((locale) => { + const active = (i18n.resolvedLanguage ?? i18n.language) === locale; + return ( + + ); + })} +
+
+ + + +
+ )} + + {activeTab === 'privacy' && ( +
+ void patchProfile({ showReadReceipts: v })} + /> + void patchProfile({ allowDmsFromStrangers: v })} + /> +
+ )} + + {activeTab === 'notifications' && ( +
+ + + + + + +
+ )} + + {activeTab === 'voice' && ( +
+ + + + + + + + + + +
+ + + + + +
+
+ + + +
+ )} + + {activeTab === 'screen-share' && ( +
+ +
+ )} + + {activeTab === 'soundboard' && ( +
+ +
+ )} + + {activeTab === 'security' && ( +
+ {profile?.userId && } +
+ )} + + {activeTab === 'account' && ( +
+ +
+ )} +
@@ -1274,13 +1278,11 @@ function formatBitrate(kbps: number): string { } function Section({ - id, title, description, children, tone, }: { - id: string; title: string; description?: string; children: React.ReactNode; @@ -1288,11 +1290,8 @@ function Section({ }) { return (