|
|
|
@@ -8,6 +8,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
|
|
|
|
import { Avatar } from '../components/Avatar';
|
|
|
|
|
import {
|
|
|
|
|
AtIcon,
|
|
|
|
|
BellIcon,
|
|
|
|
|
LockIcon,
|
|
|
|
|
MicIcon,
|
|
|
|
|
MonitorShareIcon,
|
|
|
|
|
MusicIcon,
|
|
|
|
|
ShieldIcon,
|
|
|
|
|
SignOutIcon,
|
|
|
|
|
SunIcon,
|
|
|
|
|
UsersIcon,
|
|
|
|
|
} from '../components/icons';
|
|
|
|
|
import { MicTestSection } from '../components/MicTestSection';
|
|
|
|
|
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
|
|
|
|
import { RingtoneSettings } from '../components/RingtoneSettings';
|
|
|
|
@@ -99,140 +111,251 @@ 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 },
|
|
|
|
|
{ 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 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-full bg-surface-3 text-fg">
|
|
|
|
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
|
|
|
|
<header className="mb-2">
|
|
|
|
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
|
|
|
|
{t('app:settings.title')}
|
|
|
|
|
</h1>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* Account */}
|
|
|
|
|
<Section title={t('app:settings.section_account')}>
|
|
|
|
|
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
|
|
|
|
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
|
|
|
|
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
|
|
|
|
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Startup */}
|
|
|
|
|
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
|
|
|
|
<AutoStartControls />
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Appearance */}
|
|
|
|
|
<Section title={t('app:settings.section_appearance')}>
|
|
|
|
|
<ThemeRow />
|
|
|
|
|
<SettingRow label={t('app:settings.language')}>
|
|
|
|
|
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
|
|
|
|
{SUPPORTED_LOCALES.map((locale) => {
|
|
|
|
|
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={locale}
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => void handleLocaleChange(locale)}
|
|
|
|
|
className={
|
|
|
|
|
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
|
|
|
|
(active
|
|
|
|
|
? 'bg-accent text-accent-fg'
|
|
|
|
|
: 'text-fg-muted hover:text-fg')
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{LOCALE_LABELS[locale]}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<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 */}
|
|
|
|
|
<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;
|
|
|
|
|
return (
|
|
|
|
|
<a
|
|
|
|
|
key={id}
|
|
|
|
|
href={'#' + id}
|
|
|
|
|
className={
|
|
|
|
|
'flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
|
|
|
|
(active
|
|
|
|
|
? 'bg-accent/15 text-fg'
|
|
|
|
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Icon
|
|
|
|
|
className={
|
|
|
|
|
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<span className="truncate">{label}</span>
|
|
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
</SettingRow>
|
|
|
|
|
</Section>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* Privacy */}
|
|
|
|
|
<Section title={t('app:settings.section_privacy')}>
|
|
|
|
|
<Toggle
|
|
|
|
|
label={t('app:settings.show_read_receipts')}
|
|
|
|
|
hint={t('app:settings.show_read_receipts_hint')}
|
|
|
|
|
checked={profile?.showReadReceipts ?? true}
|
|
|
|
|
disabled={busy || !profile}
|
|
|
|
|
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
|
|
|
|
/>
|
|
|
|
|
<Toggle
|
|
|
|
|
label={t('app:settings.allow_dms_strangers')}
|
|
|
|
|
hint={t('app:settings.allow_dms_strangers_hint')}
|
|
|
|
|
checked={profile?.allowDmsFromStrangers ?? true}
|
|
|
|
|
disabled={busy || !profile}
|
|
|
|
|
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
|
|
|
|
/>
|
|
|
|
|
</Section>
|
|
|
|
|
{/* Content column */}
|
|
|
|
|
<main className="space-y-8">
|
|
|
|
|
{/* Mobile-only header (sidebar replaces it on lg+) */}
|
|
|
|
|
<header className="lg:hidden">
|
|
|
|
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
|
|
|
|
{t('app:settings.title')}
|
|
|
|
|
</h1>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* Notification sound (new messages) */}
|
|
|
|
|
<Section
|
|
|
|
|
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
|
|
|
|
>
|
|
|
|
|
<NotificationSoundSettings disabled={busy} />
|
|
|
|
|
</Section>
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
|
|
|
|
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{/* Ringtone (incoming custom) */}
|
|
|
|
|
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
|
|
|
|
<RingtoneSettings disabled={busy} />
|
|
|
|
|
</Section>
|
|
|
|
|
<Section
|
|
|
|
|
id="appearance"
|
|
|
|
|
title={t('app:settings.section_appearance')}
|
|
|
|
|
description={t('app:settings.section_appearance_hint', {
|
|
|
|
|
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<ThemeRow />
|
|
|
|
|
<SettingRow label={t('app:settings.language')}>
|
|
|
|
|
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
|
|
|
|
{SUPPORTED_LOCALES.map((locale) => {
|
|
|
|
|
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={locale}
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => void handleLocaleChange(locale)}
|
|
|
|
|
className={
|
|
|
|
|
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
|
|
|
|
(active
|
|
|
|
|
? 'bg-accent text-accent-fg'
|
|
|
|
|
: 'text-fg-muted hover:text-fg')
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{LOCALE_LABELS[locale]}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</SettingRow>
|
|
|
|
|
<SubGroup>
|
|
|
|
|
<AutoStartControls />
|
|
|
|
|
</SubGroup>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Soundboard */}
|
|
|
|
|
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
|
|
|
|
|
<SoundboardSettings />
|
|
|
|
|
</Section>
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<Toggle
|
|
|
|
|
label={t('app:settings.show_read_receipts')}
|
|
|
|
|
hint={t('app:settings.show_read_receipts_hint')}
|
|
|
|
|
checked={profile?.showReadReceipts ?? true}
|
|
|
|
|
disabled={busy || !profile}
|
|
|
|
|
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
|
|
|
|
/>
|
|
|
|
|
<Toggle
|
|
|
|
|
label={t('app:settings.allow_dms_strangers')}
|
|
|
|
|
hint={t('app:settings.allow_dms_strangers_hint')}
|
|
|
|
|
checked={profile?.allowDmsFromStrangers ?? true}
|
|
|
|
|
disabled={busy || !profile}
|
|
|
|
|
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
|
|
|
|
/>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
|
|
|
|
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
|
|
|
|
<AudioDeviceControls />
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<AudioQualityControls />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<PttControls />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<VoiceHotkeyControls kind="mute" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<VoiceHotkeyControls kind="deafen" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<VoiceHotkeyControls kind="hangup" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<VoiceHotkeyControls kind="screenShare" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<VoiceHotkeyControls kind="video" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 border-t border-line pt-3">
|
|
|
|
|
<CallE2EEControls />
|
|
|
|
|
</div>
|
|
|
|
|
</Section>
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
|
|
|
|
|
<NotificationSoundSettings disabled={busy} />
|
|
|
|
|
</SubSection>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
|
|
|
|
|
<RingtoneSettings disabled={busy} />
|
|
|
|
|
</SubSection>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Screen-share quality */}
|
|
|
|
|
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
|
|
|
|
|
<ScreenShareControls />
|
|
|
|
|
</Section>
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
|
|
|
|
|
<AudioDeviceControls />
|
|
|
|
|
</SubSection>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
|
|
|
|
<AudioQualityControls />
|
|
|
|
|
</SubSection>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
|
|
|
|
|
<PttControls />
|
|
|
|
|
</SubSection>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<VoiceHotkeyControls kind="mute" />
|
|
|
|
|
<VoiceHotkeyControls kind="deafen" />
|
|
|
|
|
<VoiceHotkeyControls kind="hangup" />
|
|
|
|
|
<VoiceHotkeyControls kind="screenShare" />
|
|
|
|
|
<VoiceHotkeyControls kind="video" />
|
|
|
|
|
</div>
|
|
|
|
|
</SubSection>
|
|
|
|
|
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
|
|
|
|
|
<CallE2EEControls />
|
|
|
|
|
</SubSection>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Security */}
|
|
|
|
|
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
|
|
|
|
|
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
|
|
|
|
</Section>
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<ScreenShareControls />
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Danger zone */}
|
|
|
|
|
<Section title={t('app:settings.danger_zone')}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void signOut()}
|
|
|
|
|
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
|
|
|
|
>
|
|
|
|
|
{t('app:settings.sign_out')}
|
|
|
|
|
</button>
|
|
|
|
|
</Section>
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<SoundboardSettings />
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
<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.',
|
|
|
|
|
})}
|
|
|
|
|
tone="danger"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void signOut()}
|
|
|
|
|
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
|
|
|
|
>
|
|
|
|
|
<SignOutIcon className="h-4 w-4" />
|
|
|
|
|
{t('app:settings.sign_out')}
|
|
|
|
|
</button>
|
|
|
|
|
</Section>
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
@@ -1138,21 +1261,81 @@ function formatBitrate(kbps: number): string {
|
|
|
|
|
return kbps + ' kbps';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
|
|
|
function Section({
|
|
|
|
|
id,
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
children,
|
|
|
|
|
tone,
|
|
|
|
|
}: {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
tone?: 'default' | 'danger';
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
|
|
|
|
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
|
|
|
|
{title}
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="space-y-3">{children}</div>
|
|
|
|
|
<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 ' +
|
|
|
|
|
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<header className="mb-5 border-b border-line pb-4">
|
|
|
|
|
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
|
|
|
|
|
{title}
|
|
|
|
|
</h2>
|
|
|
|
|
{description && (
|
|
|
|
|
<p className="mt-1 text-xs text-fg-muted">{description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</header>
|
|
|
|
|
<div className="space-y-4">{children}</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
|
|
|
// Sub-heading inside a Section — used to chunk dense sections like Voice into
|
|
|
|
|
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
|
|
|
|
|
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 rounded-xl border border-line/60 bg-surface-3/40 p-4">
|
|
|
|
|
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
|
|
|
|
{title}
|
|
|
|
|
</h3>
|
|
|
|
|
<div className="space-y-3">{children}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lighter wrapper for a single related extra control inside a Section that
|
|
|
|
|
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
|
|
|
|
|
// Appearance).
|
|
|
|
|
function SubGroup({ children }: { children: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3 border-t border-line pt-4">{children}</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Row({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
mono,
|
|
|
|
|
icon,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
mono?: boolean;
|
|
|
|
|
icon?: React.ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
|
|
|
<dt className="text-sm text-fg-muted">{label}</dt>
|
|
|
|
|
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
|
|
|
|
|
{icon}
|
|
|
|
|
{label}
|
|
|
|
|
</dt>
|
|
|
|
|
<dd
|
|
|
|
|
className={
|
|
|
|
|
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
|
|
|
|