initial
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
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 {
|
||||
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>
|
||||
)}
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user