0d94b684bf
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
615 lines
22 KiB
TypeScript
615 lines
22 KiB
TypeScript
import { updateOwnProfile } from '@chat-app/shared/auth';
|
||
import {
|
||
changeLocale as changeLocaleI18n,
|
||
SUPPORTED_LOCALES,
|
||
type SupportedLocale,
|
||
} from '@chat-app/shared/i18n';
|
||
import { useEffect, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
import { LockIcon } from '../components/icons';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
||
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
|
||
import { devLocalSecretStore } from '../lib/secretStore';
|
||
import {
|
||
getPttSettings,
|
||
keyCodeToLabel,
|
||
type PttSettings,
|
||
subscribePttSettings,
|
||
updatePttSettings,
|
||
} from '../lib/pttSettings';
|
||
import {
|
||
AUDIO_QUALITY_ORDER,
|
||
type AudioQuality,
|
||
type AudioSettings,
|
||
getAudioQualityParams,
|
||
getAudioSettings,
|
||
subscribeAudioSettings,
|
||
updateAudioSettings,
|
||
} from '../lib/audioSettings';
|
||
import {
|
||
type CallE2EESettings,
|
||
getCallE2EESettings,
|
||
isE2EESupported,
|
||
subscribeCallE2EESettings,
|
||
updateCallE2EESettings,
|
||
} from '../lib/callE2EE';
|
||
import {
|
||
getPresetParams,
|
||
getScreenShareSettings,
|
||
PRESET_ORDER,
|
||
type ScreenSharePreset,
|
||
type ScreenShareSettings,
|
||
subscribeScreenShareSettings,
|
||
updateScreenShareSettings,
|
||
} from '../lib/screenShareSettings';
|
||
import { supabase } from '../lib/supabase';
|
||
|
||
const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||
en: 'English',
|
||
de: 'Deutsch',
|
||
};
|
||
|
||
export function SettingsPage() {
|
||
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
|
||
const { profile, device, refreshProfile, signOut } = useAuth();
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
|
||
setBusy(true);
|
||
try {
|
||
await updateOwnProfile(supabase, patch);
|
||
await refreshProfile();
|
||
} catch (err: unknown) {
|
||
console.error('updateProfile failed', err);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleLocaleChange(locale: SupportedLocale) {
|
||
await changeLocaleI18n(locale);
|
||
void patchProfile({ locale });
|
||
}
|
||
|
||
return (
|
||
<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-white">
|
||
{t('app:settings.title')}
|
||
</h1>
|
||
</header>
|
||
|
||
{/* Account */}
|
||
<Section title={t('app:settings.section_account')}>
|
||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||
</Section>
|
||
|
||
{/* Appearance */}
|
||
<Section title={t('app:settings.section_appearance')}>
|
||
<SettingRow label={t('app:settings.language')}>
|
||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 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-brand-400/40 ' +
|
||
(active
|
||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||
: 'text-neutral-400 hover:text-neutral-200')
|
||
}
|
||
>
|
||
{LOCALE_LABELS[locale]}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</SettingRow>
|
||
</Section>
|
||
|
||
{/* 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>
|
||
|
||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||
<AudioQualityControls />
|
||
<div className="mt-3 border-t border-white/5 pt-3">
|
||
<PttControls />
|
||
</div>
|
||
<div className="mt-3 border-t border-white/5 pt-3">
|
||
<CallE2EEControls />
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Screen-share quality */}
|
||
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
|
||
<ScreenShareControls />
|
||
</Section>
|
||
|
||
{/* Devices */}
|
||
<Section title={t('app:settings.section_devices')}>
|
||
{device && (
|
||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
|
||
<LockIcon className="h-4 w-4" />
|
||
{t('app:settings.this_device')}
|
||
</div>
|
||
<dl className="mt-3 space-y-1.5 text-xs">
|
||
<Row label={t('auth:signed_in.display_name')} value={device.name} />
|
||
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
|
||
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
|
||
</dl>
|
||
</div>
|
||
)}
|
||
<DeviceKeyBackupControls />
|
||
</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/30 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50"
|
||
>
|
||
{t('app:settings.sign_out')}
|
||
</button>
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PttControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||
const [capturing, setCapturing] = useState(false);
|
||
|
||
useEffect(() => {
|
||
return subscribePttSettings(setPtt);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!capturing) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
e.preventDefault();
|
||
if (e.code === 'Escape') {
|
||
setCapturing(false);
|
||
return;
|
||
}
|
||
updatePttSettings({ key: e.code, keyLabel: keyCodeToLabel(e.code) });
|
||
setCapturing(false);
|
||
};
|
||
window.addEventListener('keydown', onKey, { capture: true });
|
||
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||
}, [capturing]);
|
||
|
||
return (
|
||
<>
|
||
<Toggle
|
||
label={t('app:settings.ptt_enabled', { defaultValue: 'Push-to-Talk' })}
|
||
hint={t('app:settings.ptt_enabled_hint', {
|
||
defaultValue:
|
||
'Mic bleibt stumm bis die Taste gedrückt wird. Sonst overrides der normale Mute-Button.',
|
||
})}
|
||
checked={ptt.enabled}
|
||
onChange={(v) => updatePttSettings({ enabled: v })}
|
||
/>
|
||
<SettingRow
|
||
label={t('app:settings.ptt_key', { defaultValue: 'Hotkey' })}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCapturing((v) => !v)}
|
||
className={
|
||
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||
(capturing
|
||
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
|
||
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
|
||
}
|
||
>
|
||
{capturing
|
||
? t('app:settings.ptt_press_key', { defaultValue: 'Taste drücken…' })
|
||
: ptt.keyLabel}
|
||
</button>
|
||
</SettingRow>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CallE2EEControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
||
const [supported] = useState<boolean>(() => isE2EESupported());
|
||
|
||
useEffect(() => subscribeCallE2EESettings(setCfg), []);
|
||
|
||
return (
|
||
<>
|
||
<Toggle
|
||
label={t('app:settings.e2ee_calls', { defaultValue: 'Ende-zu-Ende-Verschlüsselung (Calls)' })}
|
||
hint={
|
||
supported
|
||
? t('app:settings.e2ee_calls_hint', {
|
||
defaultValue:
|
||
'Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.',
|
||
})
|
||
: t('app:settings.e2ee_calls_unsupported', {
|
||
defaultValue:
|
||
'Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.',
|
||
})
|
||
}
|
||
checked={cfg.enabled && supported}
|
||
disabled={!supported}
|
||
onChange={(v) => updateCallE2EESettings({ enabled: v })}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AudioQualityControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const [cfg, setCfg] = useState<AudioSettings>(() => getAudioSettings());
|
||
|
||
useEffect(() => subscribeAudioSettings(setCfg), []);
|
||
|
||
const params = getAudioQualityParams(cfg.quality);
|
||
const labels: Record<AudioQuality, string> = {
|
||
voice: t('app:settings.audio_voice', { defaultValue: 'Sprache (Empfohlen)' }),
|
||
hifi: t('app:settings.audio_hifi', { defaultValue: 'HiFi / Musik' }),
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
||
{AUDIO_QUALITY_ORDER.map((q) => {
|
||
const active = cfg.quality === q;
|
||
return (
|
||
<button
|
||
key={q}
|
||
type="button"
|
||
onClick={() => updateAudioSettings({ quality: q })}
|
||
className={
|
||
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||
(active
|
||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||
: 'text-neutral-400 hover:text-neutral-200')
|
||
}
|
||
>
|
||
{labels[q]}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</SettingRow>
|
||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
|
||
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
|
||
<Stat label="Sample" value={params.sampleRateHz / 1000 + ' kHz'} />
|
||
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-neutral-500">
|
||
{cfg.quality === 'hifi'
|
||
? t('app:settings.audio_hifi_hint', {
|
||
defaultValue:
|
||
'Stereo 510 kbps Opus ohne Noise-Suppression/Echo-Cancellation — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.',
|
||
})
|
||
: t('app:settings.audio_voice_hint', {
|
||
defaultValue:
|
||
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
|
||
})}
|
||
</p>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DeviceKeyBackupControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const { profile, device } = useAuth();
|
||
const [busy, setBusy] = useState(false);
|
||
const [backupOut, setBackupOut] = useState<string | null>(null);
|
||
const [exportPass, setExportPass] = useState('');
|
||
const [importPass, setImportPass] = useState('');
|
||
const [importBlob, setImportBlob] = useState('');
|
||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||
|
||
const canRun = !!profile?.userId && !!device?.id;
|
||
|
||
async function handleExport() {
|
||
if (!canRun) return;
|
||
setMsg(null);
|
||
setBusy(true);
|
||
try {
|
||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||
if (!priv) throw new Error('No device key on this install');
|
||
const out = await exportDeviceKey(priv, exportPass);
|
||
setBackupOut(out);
|
||
setExportPass('');
|
||
setMsg({
|
||
kind: 'ok',
|
||
text: t('app:settings.backup_export_ok', {
|
||
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
|
||
}),
|
||
});
|
||
} catch (err: unknown) {
|
||
setMsg({
|
||
kind: 'err',
|
||
text: err instanceof Error ? err.message : 'export failed',
|
||
});
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleImport() {
|
||
if (!canRun) return;
|
||
setMsg(null);
|
||
setBusy(true);
|
||
try {
|
||
const priv = await importDeviceKey(importBlob.trim(), importPass);
|
||
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
|
||
setImportBlob('');
|
||
setImportPass('');
|
||
setMsg({
|
||
kind: 'ok',
|
||
text: t('app:settings.backup_import_ok', {
|
||
defaultValue:
|
||
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
|
||
}),
|
||
});
|
||
} catch (err: unknown) {
|
||
setMsg({
|
||
kind: 'err',
|
||
text: err instanceof Error ? err.message : 'import failed',
|
||
});
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="mt-4 rounded-xl border border-white/10 bg-ink-900/40 p-4">
|
||
<div className="text-sm font-semibold text-white">
|
||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||
</div>
|
||
<p className="mt-1 text-xs text-neutral-400">
|
||
{t('app:settings.device_key_backup_hint', {
|
||
defaultValue:
|
||
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
|
||
})}
|
||
</p>
|
||
|
||
<div className="mt-4 space-y-2">
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="password"
|
||
value={exportPass}
|
||
onChange={(e) => setExportPass(e.target.value)}
|
||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
|
||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={busy || exportPass.length < 8 || !canRun}
|
||
onClick={() => void handleExport()}
|
||
className="cursor-pointer rounded-lg bg-brand-500/80 px-4 text-sm font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
||
</button>
|
||
</div>
|
||
{backupOut && (
|
||
<textarea
|
||
readOnly
|
||
value={backupOut}
|
||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||
rows={3}
|
||
className="w-full rounded-lg border border-emerald-500/30 bg-ink-950/60 p-2 font-mono text-[10px] text-emerald-200"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-2 border-t border-white/5 pt-4">
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||
{t('app:settings.backup_import', { defaultValue: 'Import' })}
|
||
</div>
|
||
<textarea
|
||
value={importBlob}
|
||
onChange={(e) => setImportBlob(e.target.value)}
|
||
rows={3}
|
||
placeholder={t('app:settings.backup_blob_placeholder', {
|
||
defaultValue: 'chatapp-backup-v1.…',
|
||
})}
|
||
className="w-full rounded-lg border border-white/10 bg-ink-800 p-2 font-mono text-[11px] text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="password"
|
||
value={importPass}
|
||
onChange={(e) => setImportPass(e.target.value)}
|
||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={busy || !importBlob || !importPass || !canRun}
|
||
onClick={() => void handleImport()}
|
||
className="cursor-pointer rounded-lg bg-emerald-500/80 px-4 text-sm font-semibold text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{msg && (
|
||
<p
|
||
className={
|
||
'mt-3 text-xs ' +
|
||
(msg.kind === 'ok' ? 'text-emerald-300' : 'text-rose-300')
|
||
}
|
||
>
|
||
{msg.text}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ScreenShareControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
|
||
|
||
useEffect(() => subscribeScreenShareSettings(setCfg), []);
|
||
|
||
const params = getPresetParams(cfg.preset);
|
||
|
||
return (
|
||
<>
|
||
<SettingRow label={t('app:settings.screen_share_quality', { defaultValue: 'Qualität' })}>
|
||
<select
|
||
value={cfg.preset}
|
||
onChange={(e) =>
|
||
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
|
||
}
|
||
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||
>
|
||
{PRESET_ORDER.map((p) => (
|
||
<option key={p} value={p}>
|
||
{getPresetParams(p).label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
|
||
<Stat
|
||
label="Resolution"
|
||
value={params.dims ? params.dims.width + '×' + params.dims.height : 'Auto'}
|
||
/>
|
||
<Stat label="Framerate" value={params.framerate + ' fps'} />
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-neutral-500">
|
||
{t('app:settings.screen_share_hint', {
|
||
defaultValue:
|
||
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
|
||
})}
|
||
</p>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Stat({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
|
||
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatBitrate(kbps: number): string {
|
||
if (kbps >= 1000) {
|
||
return (kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1) + ' Mbps';
|
||
}
|
||
return kbps + ' kbps';
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||
{title}
|
||
</h2>
|
||
<div className="space-y-3">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-4">
|
||
<dt className="text-sm text-neutral-500">{label}</dt>
|
||
<dd
|
||
className={
|
||
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' +
|
||
(mono ? 'font-mono text-xs' : '')
|
||
}
|
||
title={value}
|
||
>
|
||
{value}
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className="text-sm text-neutral-200">{label}</span>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toggle({
|
||
label,
|
||
hint,
|
||
checked,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
checked: boolean;
|
||
disabled?: boolean;
|
||
onChange: (next: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-sm text-neutral-200">{label}</span>
|
||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||
</span>
|
||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange(e.target.checked)}
|
||
className="peer sr-only"
|
||
/>
|
||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|