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<TabId>` is the single source of truth; no observer, no scrolling between sections, no anchor links to clash with HashRouter. Mobile fallback (<lg) gets a `<select>` dropdown above the panel. Drops the now-unused `id` prop from Section and removes the IntersectionObserver effect.
This commit is contained in:
@@ -111,10 +111,15 @@ 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 = [
|
||||
// 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 },
|
||||
@@ -126,54 +131,28 @@ export function SettingsPage() {
|
||||
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
|
||||
];
|
||||
|
||||
const [activeId, setActiveId] = useState<string>(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<TabId>('profile');
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
{/* Sidebar — sticky anchor nav, hidden on narrow screens */}
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-8 space-y-1">
|
||||
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
<nav aria-label={t('app:settings.title')}>
|
||||
{sections.map(({ id, label, Icon }) => {
|
||||
const active = activeId === id;
|
||||
<nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const active = activeTab === id;
|
||||
return (
|
||||
// NOTE: must NOT be an `<a href="#id">` — the app uses
|
||||
// HashRouter where the URL hash IS the route. Anchor links
|
||||
// would navigate away from /settings instead of scrolling.
|
||||
// We scroll the section into view programmatically and
|
||||
// optimistically set activeId so the click feels instant.
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setActiveId(id);
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={'settings-panel-' + id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
@@ -194,17 +173,32 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content column */}
|
||||
<main className="space-y-8">
|
||||
{/* Mobile-only header (sidebar replaces it on lg+) */}
|
||||
<header className="lg:hidden">
|
||||
{/* Content panel — only the active tab renders */}
|
||||
<main className="min-w-0">
|
||||
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
|
||||
<div className="mb-6 space-y-3 lg:hidden">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
</header>
|
||||
<select
|
||||
value={activeTab}
|
||||
onChange={(e) => setActiveTab(e.target.value as TabId)}
|
||||
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-label={t('app:settings.title')}
|
||||
>
|
||||
{tabs.map(({ id, label }) => (
|
||||
<option key={id} value={id}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={'settings-panel-' + activeTab}
|
||||
role="tabpanel"
|
||||
aria-labelledby={'settings-tab-' + activeTab}
|
||||
>
|
||||
{activeTab === 'profile' && (
|
||||
<Section
|
||||
id="profile"
|
||||
title={t('app:settings.section_account')}
|
||||
description={t('app:settings.section_account_hint', {
|
||||
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
|
||||
@@ -215,9 +209,10 @@ export function SettingsPage() {
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<Section
|
||||
id="appearance"
|
||||
title={t('app:settings.section_appearance')}
|
||||
description={t('app:settings.section_appearance_hint', {
|
||||
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
|
||||
@@ -251,9 +246,10 @@ export function SettingsPage() {
|
||||
<AutoStartControls />
|
||||
</SubGroup>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<Section
|
||||
id="privacy"
|
||||
title={t('app:settings.section_privacy')}
|
||||
description={t('app:settings.section_privacy_hint', {
|
||||
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
|
||||
@@ -274,9 +270,10 @@ export function SettingsPage() {
|
||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<Section
|
||||
id="notifications"
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
description={t('app:settings.section_notifications_hint', {
|
||||
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
|
||||
@@ -289,9 +286,10 @@ export function SettingsPage() {
|
||||
<RingtoneSettings disabled={busy} />
|
||||
</SubSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'voice' && (
|
||||
<Section
|
||||
id="voice"
|
||||
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
|
||||
description={t('app:settings.section_voice_hint', {
|
||||
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
|
||||
@@ -319,9 +317,10 @@ export function SettingsPage() {
|
||||
<CallE2EEControls />
|
||||
</SubSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'screen-share' && (
|
||||
<Section
|
||||
id="screen-share"
|
||||
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
|
||||
description={t('app:settings.section_screen_share_hint', {
|
||||
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
|
||||
@@ -329,9 +328,10 @@ export function SettingsPage() {
|
||||
>
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'soundboard' && (
|
||||
<Section
|
||||
id="soundboard"
|
||||
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
|
||||
description={t('app:settings.section_soundboard_hint', {
|
||||
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
|
||||
@@ -339,9 +339,10 @@ export function SettingsPage() {
|
||||
>
|
||||
<SoundboardSettings />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Section
|
||||
id="security"
|
||||
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
|
||||
description={t('app:settings.section_security_hint', {
|
||||
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
|
||||
@@ -349,9 +350,10 @@ export function SettingsPage() {
|
||||
>
|
||||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'account' && (
|
||||
<Section
|
||||
id="account"
|
||||
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
|
||||
description={t('app:settings.section_account_mgmt_hint', {
|
||||
defaultValue: 'Abmelden oder Konto-Aktionen.',
|
||||
@@ -367,6 +369,8 @@ export function SettingsPage() {
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<section
|
||||
id={id}
|
||||
// scroll-mt offsets the anchor target so the section heading isn't flush
|
||||
// against the viewport top after a sidebar-link jump
|
||||
className={
|
||||
'scroll-mt-8 rounded-2xl border bg-surface-2 p-6 ' +
|
||||
'rounded-2xl border bg-surface-2 p-6 ' +
|
||||
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user