04d9909e92
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1727 lines
63 KiB
TypeScript
1727 lines
63 KiB
TypeScript
import { updateOwnProfile } from '@chat-app/shared/auth';
|
||
import {
|
||
changeLocale as changeLocaleI18n,
|
||
SUPPORTED_LOCALES,
|
||
type SupportedLocale,
|
||
} from '@chat-app/shared/i18n';
|
||
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';
|
||
import { DeviceListTab } from '../components/settings/DeviceListTab';
|
||
import { SecurityCenter } from '../components/SecurityCenter';
|
||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { useCall } from '../context/CallContext';
|
||
import { useTheme } from '../context/ThemeContext';
|
||
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
|
||
import {
|
||
AVATAR_TARGET_DIM,
|
||
deleteAvatarObject,
|
||
uploadAvatarBlob,
|
||
} from '../lib/avatarUpload';
|
||
import {
|
||
BANNER_MAX_INPUT_BYTES,
|
||
BANNER_TARGET_HEIGHT,
|
||
BANNER_TARGET_WIDTH,
|
||
deleteBannerObject,
|
||
uploadBannerBlob,
|
||
} from '../lib/bannerUpload';
|
||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||
import { Lightbox } from '../components/Lightbox';
|
||
import {
|
||
getPttSettings,
|
||
keyCodeToLabel,
|
||
type PttSettings,
|
||
subscribePttSettings,
|
||
updatePttSettings,
|
||
} from '../lib/pttSettings';
|
||
import {
|
||
getVoiceHotkeys,
|
||
subscribeVoiceHotkeys,
|
||
updateVoiceHotkey,
|
||
type VoiceHotkeyKind,
|
||
type VoiceHotkeys,
|
||
} from '../lib/voiceHotkeys';
|
||
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, 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 });
|
||
}
|
||
|
||
// 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' | 'devices' | '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: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
|
||
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
|
||
];
|
||
|
||
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 */}
|
||
<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')} role="tablist" aria-orientation="vertical">
|
||
{tabs.map(({ id, label, Icon }) => {
|
||
const active = activeTab === id;
|
||
return (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
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
|
||
? '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>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* 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>
|
||
<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
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'appearance' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'privacy' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'notifications' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'voice' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'screen-share' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'soundboard' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'security' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
|
||
{activeTab === 'devices' && (
|
||
<Section
|
||
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
|
||
description={t('app:settings.section_devices_hint', {
|
||
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
|
||
})}
|
||
>
|
||
<DeviceListTab />
|
||
</Section>
|
||
)}
|
||
|
||
{activeTab === 'account' && (
|
||
<Section
|
||
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>
|
||
)}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AutoStartControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const on = await isAutoStartEnabled();
|
||
if (!cancelled) setEnabled(on);
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
async function handleToggle(next: boolean) {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await setAutoStart(next);
|
||
setEnabled(next);
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'autostart failed');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Toggle
|
||
label={t('app:settings.autostart', {
|
||
defaultValue: 'Mit Windows starten',
|
||
})}
|
||
hint={t('app:settings.autostart_hint', {
|
||
defaultValue:
|
||
'ChatApp automatisch mitstarten wenn du dich am System anmeldest.',
|
||
})}
|
||
checked={enabled ?? false}
|
||
disabled={busy || enabled === null}
|
||
onChange={(v) => void handleToggle(v)}
|
||
/>
|
||
{error && <p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>}
|
||
</>
|
||
);
|
||
}
|
||
|
||
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-accent/40 ' +
|
||
(capturing
|
||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||
}
|
||
>
|
||
{capturing
|
||
? t('app:settings.ptt_press_key', { defaultValue: 'Taste drücken…' })
|
||
: ptt.keyLabel}
|
||
</button>
|
||
</SettingRow>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||
const { t } = useTranslation(['app']);
|
||
const [hotkeys, setHotkeys] = useState<VoiceHotkeys>(() => getVoiceHotkeys());
|
||
const [capturing, setCapturing] = useState(false);
|
||
|
||
useEffect(() => subscribeVoiceHotkeys(setHotkeys), []);
|
||
|
||
useEffect(() => {
|
||
if (!capturing) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
// Modifier-only presses shouldn't bind — wait for a real key to
|
||
// arrive. Escape aborts the capture.
|
||
if (e.code === 'Escape') {
|
||
e.preventDefault();
|
||
setCapturing(false);
|
||
return;
|
||
}
|
||
if (
|
||
e.code === 'ControlLeft' ||
|
||
e.code === 'ControlRight' ||
|
||
e.code === 'ShiftLeft' ||
|
||
e.code === 'ShiftRight' ||
|
||
e.code === 'AltLeft' ||
|
||
e.code === 'AltRight' ||
|
||
e.code === 'MetaLeft' ||
|
||
e.code === 'MetaRight'
|
||
) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
updateVoiceHotkey(kind, {
|
||
key: e.code,
|
||
ctrl: e.ctrlKey || e.metaKey,
|
||
shift: e.shiftKey,
|
||
alt: e.altKey,
|
||
});
|
||
setCapturing(false);
|
||
};
|
||
window.addEventListener('keydown', onKey, { capture: true });
|
||
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||
}, [capturing, kind]);
|
||
|
||
const binding = hotkeys[kind];
|
||
const labels: Record<VoiceHotkeyKind, { label: string; hint: string }> = {
|
||
mute: {
|
||
label: t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' }),
|
||
hint: t('app:settings.hotkey_mute_hint', {
|
||
defaultValue:
|
||
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||
}),
|
||
},
|
||
deafen: {
|
||
label: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' }),
|
||
hint: t('app:settings.hotkey_deafen_hint', {
|
||
defaultValue: 'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||
}),
|
||
},
|
||
hangup: {
|
||
label: t('app:settings.hotkey_hangup_enabled', { defaultValue: 'Auflegen-Hotkey' }),
|
||
hint: t('app:settings.hotkey_hangup_hint', {
|
||
defaultValue: 'Beendet den aktiven Anruf sofort.',
|
||
}),
|
||
},
|
||
screenShare: {
|
||
label: t('app:settings.hotkey_screenshare_enabled', {
|
||
defaultValue: 'Bildschirmfreigabe-Hotkey',
|
||
}),
|
||
hint: t('app:settings.hotkey_screenshare_hint', {
|
||
defaultValue: 'Startet oder stoppt die Bildschirmfreigabe.',
|
||
}),
|
||
},
|
||
video: {
|
||
label: t('app:settings.hotkey_video_enabled', { defaultValue: 'Kamera-Hotkey' }),
|
||
hint: t('app:settings.hotkey_video_hint', {
|
||
defaultValue: 'Schaltet die Kamera während eines Anrufs an oder aus.',
|
||
}),
|
||
},
|
||
};
|
||
const toggleLabel = labels[kind].label;
|
||
const toggleHint = labels[kind].hint;
|
||
|
||
return (
|
||
<>
|
||
<Toggle
|
||
label={toggleLabel}
|
||
hint={toggleHint}
|
||
checked={binding.enabled}
|
||
onChange={(v) => updateVoiceHotkey(kind, { enabled: v })}
|
||
/>
|
||
<SettingRow
|
||
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setCapturing((v) => !v)}
|
||
className={
|
||
'inline-flex min-w-[10rem] 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-accent/40 ' +
|
||
(capturing
|
||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||
}
|
||
>
|
||
{capturing
|
||
? t('app:settings.hotkey_press_combo', {
|
||
defaultValue: 'Kombination drücken…',
|
||
})
|
||
: binding.keyLabel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
|
||
disabled={!binding.enabled}
|
||
aria-pressed={binding.global}
|
||
title={
|
||
binding.global
|
||
? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
|
||
: 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
|
||
}
|
||
className={
|
||
'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||
(binding.global
|
||
? 'border-accent/40 bg-accent/15 text-accent'
|
||
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
||
}
|
||
>
|
||
<span aria-hidden>🌐</span>
|
||
<span>{binding.global ? 'Global' : 'Im Fenster'}</span>
|
||
</button>
|
||
</div>
|
||
</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-line bg-surface-2 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-accent/40 ' +
|
||
(active
|
||
? 'bg-accent text-accent-fg'
|
||
: 'text-fg-muted hover:text-fg')
|
||
}
|
||
>
|
||
{labels[q]}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</SettingRow>
|
||
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
|
||
<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-fg-muted">
|
||
{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>
|
||
|
||
<SettingRow
|
||
label={t('app:settings.noise_suppression', { defaultValue: 'Noise Suppression' })}
|
||
>
|
||
<InlineToggle
|
||
checked={cfg.noiseSuppression}
|
||
onChange={(v) => updateAudioSettings({ noiseSuppression: v })}
|
||
/>
|
||
</SettingRow>
|
||
<p className="text-xs text-fg-muted">
|
||
{t('app:settings.noise_suppression_hint', {
|
||
defaultValue:
|
||
'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.',
|
||
})}
|
||
</p>
|
||
|
||
<SettingRow
|
||
label={t('app:settings.video_blur', {
|
||
defaultValue: 'Video-Hintergrund unscharf',
|
||
})}
|
||
>
|
||
<InlineToggle
|
||
checked={cfg.videoBackgroundBlur}
|
||
onChange={(v) => updateAudioSettings({ videoBackgroundBlur: v })}
|
||
/>
|
||
</SettingRow>
|
||
<p className="text-xs text-fg-muted">
|
||
{t('app:settings.video_blur_hint', {
|
||
defaultValue:
|
||
'Blendet den Hintergrund hinter dir aus. Braucht etwas GPU-Leistung und lädt beim ersten Aktivieren ~1,5 MB Modell nach.',
|
||
})}
|
||
</p>
|
||
|
||
<SettingRow
|
||
label={t('app:settings.voice_threshold', {
|
||
defaultValue: 'Sprach-Erkennungs-Schwelle',
|
||
})}
|
||
>
|
||
<div className="flex w-48 items-center gap-2">
|
||
<input
|
||
type="range"
|
||
min={0.005}
|
||
max={0.1}
|
||
step={0.005}
|
||
value={cfg.voiceThreshold}
|
||
onChange={(e) =>
|
||
updateAudioSettings({ voiceThreshold: Number(e.target.value) })
|
||
}
|
||
className="flex-1 accent-accent"
|
||
/>
|
||
<span className="w-10 tabular-nums text-right text-[11px] text-fg-muted">
|
||
{(cfg.voiceThreshold * 100).toFixed(1)}
|
||
</span>
|
||
</div>
|
||
</SettingRow>
|
||
<p className="text-xs text-fg-muted">
|
||
{t('app:settings.voice_threshold_hint', {
|
||
defaultValue:
|
||
'Wann der grüne Sprech-Ring aufleuchtet. Niedriger = empfindlicher (leise Stimme erfassen), höher = tolerant gegen Raumlärm.',
|
||
})}
|
||
</p>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function InlineToggle({
|
||
checked,
|
||
onChange,
|
||
}: {
|
||
checked: boolean;
|
||
onChange: (next: boolean) => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={
|
||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||
(checked ? 'bg-accent' : 'bg-surface')
|
||
}
|
||
>
|
||
<span
|
||
className={
|
||
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition ' +
|
||
(checked ? 'translate-x-5' : '')
|
||
}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
interface AvatarControlsProps {
|
||
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
||
busy: boolean;
|
||
}
|
||
|
||
function DisplayNameControls({ patchProfile, busy }: AvatarControlsProps) {
|
||
const { t } = useTranslation(['app', 'auth']);
|
||
const { profile } = useAuth();
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
function startEdit() {
|
||
setDraft(profile?.displayName ?? '');
|
||
setError(null);
|
||
setEditing(true);
|
||
// Focus on next tick so the input has mounted.
|
||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||
}
|
||
|
||
function cancel() {
|
||
setEditing(false);
|
||
setDraft('');
|
||
setError(null);
|
||
}
|
||
|
||
async function save() {
|
||
const trimmed = draft.trim();
|
||
if (trimmed.length === 0) {
|
||
setError(
|
||
t('app:settings.display_name_required', {
|
||
defaultValue: 'Anzeigename darf nicht leer sein.',
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
if (trimmed === profile?.displayName) {
|
||
cancel();
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await patchProfile({ displayName: trimmed });
|
||
setEditing(false);
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'save failed');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
if (!editing) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-4">
|
||
<dt className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</dt>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<dd className="max-w-[40ch] truncate text-right text-sm text-fg" title={profile?.displayName ?? ''}>
|
||
{profile?.displayName ?? '—'}
|
||
</dd>
|
||
<button
|
||
type="button"
|
||
onClick={startEdit}
|
||
disabled={busy || !profile}
|
||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2.5 py-1 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||
>
|
||
{t('app:settings.edit', { defaultValue: 'Bearbeiten' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</span>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
void save();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
cancel();
|
||
}
|
||
}}
|
||
maxLength={64}
|
||
disabled={saving}
|
||
className="flex-1 min-w-[12rem] rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-sm text-fg outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60 dark:bg-[#313338]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void save()}
|
||
disabled={saving || busy}
|
||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{saving
|
||
? t('app:settings.display_name_saving', { defaultValue: 'Speichere…' })
|
||
: t('app:settings.save', { defaultValue: 'Speichern' })}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={cancel}
|
||
disabled={saving}
|
||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||
>
|
||
{t('app:settings.cancel', { defaultValue: 'Abbrechen' })}
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p className="text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Default banner gradient when the user hasn't uploaded their own. Sits on
|
||
// the same accent + surface tokens as the rest of the app so it never clashes
|
||
// with theme changes. Used both here in settings and in UserProfilePopover.
|
||
export const DEFAULT_BANNER_CLASS =
|
||
'bg-gradient-to-br from-accent/40 via-accent/15 to-surface-3';
|
||
|
||
function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||
const { t } = useTranslation(['app']);
|
||
const { profile } = useAuth();
|
||
|
||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [avatarBusy, setAvatarBusy] = useState(false);
|
||
const [bannerBusy, setBannerBusy] = useState(false);
|
||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||
const [bannerError, setBannerError] = useState<string | null>(null);
|
||
// Crop-dialog plumbing. The picked File lives here until the user
|
||
// confirms a crop or cancels; on confirm we hand the resulting Blob to
|
||
// the matching upload helper. Keeping `kind` separate from `file` lets
|
||
// the same dialog component drive both flows with different aspect
|
||
// ratios.
|
||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
||
// Lightbox toggle for the avatar live-preview. Clicking the in-page
|
||
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
|
||
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||
|
||
const userId = profile?.userId;
|
||
const avatarUrl = profile?.avatarUrl ?? null;
|
||
const bannerUrl = profile?.bannerUrl ?? null;
|
||
|
||
// Avatar pick → open crop dialog. Legacy `uploadAvatar` (center-crop) is
|
||
// kept around for callers that bypass the picker, but the SettingsPage
|
||
// path always goes through the crop flow now so the user controls the
|
||
// framing.
|
||
function openAvatarCrop(file: File) {
|
||
if (!userId) return;
|
||
if (!file.type.startsWith('image/')) {
|
||
setAvatarError('only image files are accepted');
|
||
return;
|
||
}
|
||
setAvatarError(null);
|
||
setCropFile(file);
|
||
setCropKind('avatar');
|
||
}
|
||
|
||
async function handleAvatarCropConfirm(blob: Blob) {
|
||
if (!userId) return;
|
||
setAvatarBusy(true);
|
||
setAvatarError(null);
|
||
try {
|
||
const newUrl = await uploadAvatarBlob(userId, blob);
|
||
const oldUrl = avatarUrl;
|
||
await patchProfile({ avatarUrl: newUrl });
|
||
if (oldUrl) {
|
||
void deleteAvatarObject(oldUrl).catch(() => undefined);
|
||
}
|
||
closeCropDialog();
|
||
} catch (err: unknown) {
|
||
setAvatarError(err instanceof Error ? err.message : 'upload failed');
|
||
} finally {
|
||
setAvatarBusy(false);
|
||
}
|
||
}
|
||
|
||
function closeCropDialog() {
|
||
setCropFile(null);
|
||
setCropKind(null);
|
||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||
if (bannerInputRef.current) bannerInputRef.current.value = '';
|
||
}
|
||
|
||
async function handleAvatarRemove() {
|
||
if (!userId || !avatarUrl) return;
|
||
setAvatarError(null);
|
||
setAvatarBusy(true);
|
||
try {
|
||
await patchProfile({ avatarUrl: null });
|
||
void deleteAvatarObject(avatarUrl).catch(() => undefined);
|
||
} catch (err: unknown) {
|
||
setAvatarError(err instanceof Error ? err.message : 'remove failed');
|
||
} finally {
|
||
setAvatarBusy(false);
|
||
}
|
||
}
|
||
|
||
function openBannerCrop(file: File) {
|
||
if (!userId) return;
|
||
if (!file.type.startsWith('image/')) {
|
||
setBannerError('only image files are accepted');
|
||
return;
|
||
}
|
||
if (file.size > BANNER_MAX_INPUT_BYTES) {
|
||
setBannerError('image must be 8 MB or smaller');
|
||
return;
|
||
}
|
||
setBannerError(null);
|
||
setCropFile(file);
|
||
setCropKind('banner');
|
||
}
|
||
|
||
async function handleBannerCropConfirm(blob: Blob) {
|
||
if (!userId) return;
|
||
setBannerBusy(true);
|
||
setBannerError(null);
|
||
try {
|
||
const newUrl = await uploadBannerBlob(userId, blob);
|
||
const oldUrl = bannerUrl;
|
||
await patchProfile({ bannerUrl: newUrl });
|
||
if (oldUrl) {
|
||
void deleteBannerObject(oldUrl).catch(() => undefined);
|
||
}
|
||
closeCropDialog();
|
||
} catch (err: unknown) {
|
||
setBannerError(err instanceof Error ? err.message : 'upload failed');
|
||
} finally {
|
||
setBannerBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleBannerRemove() {
|
||
if (!userId || !bannerUrl) return;
|
||
setBannerError(null);
|
||
setBannerBusy(true);
|
||
try {
|
||
await patchProfile({ bannerUrl: null });
|
||
void deleteBannerObject(bannerUrl).catch(() => undefined);
|
||
} catch (err: unknown) {
|
||
setBannerError(err instanceof Error ? err.message : 'remove failed');
|
||
} finally {
|
||
setBannerBusy(false);
|
||
}
|
||
}
|
||
|
||
const displayName = profile?.displayName ?? profile?.username;
|
||
const lockedAll = busy || avatarBusy || bannerBusy;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Live preview — banner with avatar overlapping bottom-left, mirrors
|
||
how the profile shows up in UserProfilePopover. The avatar row is
|
||
explicitly stacked above the banner via `relative z-10`; without
|
||
it, browsers can paint the negatively-margin'd avatar behind the
|
||
banner's background image when the parent doesn't establish a
|
||
stacking context. */}
|
||
<div className="relative overflow-hidden rounded-xl border border-line bg-surface-3">
|
||
<div
|
||
className={
|
||
'relative z-0 aspect-[3/1] w-full bg-cover bg-center ' +
|
||
(bannerUrl ? '' : DEFAULT_BANNER_CLASS)
|
||
}
|
||
style={bannerUrl ? { backgroundImage: 'url("' + bannerUrl + '")' } : undefined}
|
||
/>
|
||
<div
|
||
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
||
style={{ marginTop: '-2rem' }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (avatarUrl) setAvatarPreviewOpen(true);
|
||
}}
|
||
// Disabled when there's no uploaded avatar — clicking the
|
||
// generated-initial placeholder would open an empty lightbox.
|
||
disabled={!avatarUrl}
|
||
aria-label={
|
||
avatarUrl
|
||
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
|
||
: undefined
|
||
}
|
||
// appearance-none + reset border/bg/padding so the native
|
||
// button chrome (outset border, button-face background, 1px
|
||
// padding) doesn't draw a box around the avatar circle.
|
||
className={
|
||
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
|
||
}
|
||
>
|
||
<Avatar
|
||
url={avatarUrl}
|
||
displayName={displayName}
|
||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||
/>
|
||
</button>
|
||
<div className="min-w-0 flex-1 pb-1">
|
||
<div className="truncate text-sm font-semibold text-fg">
|
||
{displayName ?? '—'}
|
||
</div>
|
||
{profile?.username && (
|
||
<div className="truncate text-xs text-fg-muted">@{profile.username}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Banner controls */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-medium text-fg">
|
||
{t('app:settings.banner', { defaultValue: 'Banner' })}
|
||
</div>
|
||
<div className="text-xs text-fg-muted">
|
||
{t('app:settings.banner_hint', {
|
||
defaultValue: '3:1 Format, max 8 MB. Standard ist ein Farbverlauf.',
|
||
})}
|
||
</div>
|
||
{bannerError && (
|
||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{bannerError}</div>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={bannerInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) openBannerCrop(f);
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => bannerInputRef.current?.click()}
|
||
disabled={lockedAll}
|
||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{bannerBusy
|
||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||
: bannerUrl
|
||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||
</button>
|
||
{bannerUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleBannerRemove()}
|
||
disabled={lockedAll}
|
||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||
>
|
||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Avatar controls */}
|
||
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-medium text-fg">
|
||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||
</div>
|
||
<div className="text-xs text-fg-muted">
|
||
{t('app:settings.avatar_hint', {
|
||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||
})}
|
||
</div>
|
||
{avatarError && (
|
||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{avatarError}</div>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={avatarInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) openAvatarCrop(f);
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => avatarInputRef.current?.click()}
|
||
disabled={lockedAll}
|
||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{avatarBusy
|
||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||
: avatarUrl
|
||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||
</button>
|
||
{avatarUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleAvatarRemove()}
|
||
disabled={lockedAll}
|
||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||
>
|
||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<ImageCropDialog
|
||
open={cropFile !== null && cropKind !== null}
|
||
file={cropFile}
|
||
aspect={cropKind === 'banner' ? 3 : 1}
|
||
outputWidth={cropKind === 'banner' ? BANNER_TARGET_WIDTH : AVATAR_TARGET_DIM}
|
||
outputHeight={cropKind === 'banner' ? BANNER_TARGET_HEIGHT : AVATAR_TARGET_DIM}
|
||
title={
|
||
cropKind === 'banner'
|
||
? t('app:settings.crop_banner_title', { defaultValue: 'Banner zuschneiden' })
|
||
: t('app:settings.crop_avatar_title', { defaultValue: 'Profilbild zuschneiden' })
|
||
}
|
||
onConfirm={(blob) => {
|
||
if (cropKind === 'banner') void handleBannerCropConfirm(blob);
|
||
else if (cropKind === 'avatar') void handleAvatarCropConfirm(blob);
|
||
}}
|
||
onClose={closeCropDialog}
|
||
/>
|
||
{avatarPreviewOpen && avatarUrl && (
|
||
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
|
||
)}
|
||
</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-line bg-surface-3 px-3 py-1.5 text-xs text-fg focus:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
>
|
||
{PRESET_ORDER.map((p) => (
|
||
<option key={p} value={p}>
|
||
{getPresetParams(p).label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
|
||
<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-fg-muted">
|
||
{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-[0.1em] text-fg-muted">{label}</div>
|
||
<div className="mt-0.5 font-mono text-fg">{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,
|
||
description,
|
||
children,
|
||
tone,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
children: React.ReactNode;
|
||
tone?: 'default' | 'danger';
|
||
}) {
|
||
return (
|
||
<section
|
||
className={
|
||
'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>
|
||
);
|
||
}
|
||
|
||
// Sub-heading inside a Section — used to chunk dense sections like Voice into
|
||
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
|
||
// No border: `--color-line` is already a semi-transparent token, and applying
|
||
// the `/60` opacity modifier brightens it (Tailwind overrides the original
|
||
// alpha) which made the sub-cards look harsher than the outer Section. Plain
|
||
// background tint + caps heading carry the grouping signal on their own.
|
||
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="space-y-2 rounded-xl bg-surface-3/50 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="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 ' +
|
||
(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-fg">{label}</span>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Theme picker row inside the Appearance section. Same pill-segmented style
|
||
// as the language selector so the two siblings read as one control surface.
|
||
// The toggle was previously a rail icon in the sidebar; moved here so it
|
||
// sits with the other appearance preferences.
|
||
function ThemeRow() {
|
||
const { t } = useTranslation(['app']);
|
||
const { theme, setTheme } = useTheme();
|
||
const options: Array<{ value: 'light' | 'dark'; label: string }> = [
|
||
{
|
||
value: 'light',
|
||
label: t('app:theme.light', { defaultValue: 'Light' }),
|
||
},
|
||
{
|
||
value: 'dark',
|
||
label: t('app:theme.dark', { defaultValue: 'Dark' }),
|
||
},
|
||
];
|
||
return (
|
||
<SettingRow label={t('app:settings.theme', { defaultValue: 'Design' })}>
|
||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||
{options.map((o) => {
|
||
const active = theme === o.value;
|
||
return (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => setTheme(o.value)}
|
||
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')
|
||
}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</SettingRow>
|
||
);
|
||
}
|
||
|
||
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-fg">{label}</span>
|
||
{hint && <span className="mt-1 block text-xs text-fg-muted">{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-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Audio device selection — persisted input + output deviceIds, hot-swap on
|
||
// active calls. Output swap uses HTMLMediaElement.setSinkId on our attached
|
||
// <audio> elements (LiveKit's switchActiveDevice only tracks elements it
|
||
// attached itself).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function AudioDeviceControls() {
|
||
const { t } = useTranslation(['app']);
|
||
const { setAudioInputDevice, setAudioOutputDevice, setVideoInputDevice } = useCall();
|
||
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
||
const [inputId, setInputId] = useState<string | null>(
|
||
() => getAudioSettings().inputDeviceId,
|
||
);
|
||
const [outputId, setOutputId] = useState<string | null>(
|
||
() => getAudioSettings().outputDeviceId,
|
||
);
|
||
const [cameraId, setCameraId] = useState<string | null>(
|
||
() => getAudioSettings().videoInputDeviceId,
|
||
);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
|
||
'unknown',
|
||
);
|
||
|
||
const refresh = useCallback(async () => {
|
||
try {
|
||
const list = await navigator.mediaDevices.enumerateDevices();
|
||
setInputs(list.filter((d) => d.kind === 'audioinput'));
|
||
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
|
||
setCameras(list.filter((d) => d.kind === 'videoinput'));
|
||
// If labels are empty, permission hasn't been granted yet — browsers
|
||
// mask device names until a getUserMedia call succeeds at least once.
|
||
const hasLabels = list.some(
|
||
(d) => (d.kind === 'audioinput' || d.kind === 'audiooutput') && d.label.length > 0,
|
||
);
|
||
setPermission(hasLabels ? 'granted' : 'unknown');
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'enumerateDevices failed');
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
const onChange = () => void refresh();
|
||
try {
|
||
navigator.mediaDevices.addEventListener('devicechange', onChange);
|
||
} catch {
|
||
/* some browsers omit devicechange */
|
||
}
|
||
const unsubSettings = subscribeAudioSettings((s) => {
|
||
setInputId(s.inputDeviceId);
|
||
setOutputId(s.outputDeviceId);
|
||
setCameraId(s.videoInputDeviceId);
|
||
});
|
||
return () => {
|
||
try {
|
||
navigator.mediaDevices.removeEventListener('devicechange', onChange);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
unsubSettings();
|
||
};
|
||
}, [refresh]);
|
||
|
||
const requestPermission = useCallback(async () => {
|
||
setError(null);
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
stream.getTracks().forEach((t) => t.stop());
|
||
setPermission('granted');
|
||
await refresh();
|
||
} catch (err: unknown) {
|
||
setPermission('denied');
|
||
setError(err instanceof Error ? err.message : 'permission denied');
|
||
}
|
||
}, [refresh]);
|
||
|
||
const handleInput = useCallback(
|
||
async (id: string) => {
|
||
const next = id === '' ? null : id;
|
||
setInputId(next);
|
||
await setAudioInputDevice(next);
|
||
},
|
||
[setAudioInputDevice],
|
||
);
|
||
|
||
const handleOutput = useCallback(
|
||
async (id: string) => {
|
||
const next = id === '' ? null : id;
|
||
setOutputId(next);
|
||
await setAudioOutputDevice(next);
|
||
},
|
||
[setAudioOutputDevice],
|
||
);
|
||
|
||
const handleCamera = useCallback(
|
||
async (id: string) => {
|
||
const next = id === '' ? null : id;
|
||
setCameraId(next);
|
||
await setVideoInputDevice(next);
|
||
},
|
||
[setVideoInputDevice],
|
||
);
|
||
|
||
const outputSupported =
|
||
typeof HTMLAudioElement !== 'undefined' &&
|
||
typeof HTMLAudioElement.prototype.setSinkId === 'function';
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<div className="text-sm font-semibold text-fg">
|
||
{t('app:settings.mic_title', { defaultValue: 'Mikrofon' })}
|
||
</div>
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
{t('app:settings.mic_hint', {
|
||
defaultValue: 'Eingabegerät. Bei aktivem Anruf wird live umgeschaltet.',
|
||
})}
|
||
</p>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<select
|
||
value={inputId ?? ''}
|
||
onChange={(e) => void handleInput(e.target.value)}
|
||
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||
>
|
||
<option value="">
|
||
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||
</option>
|
||
{inputs.map((d) => (
|
||
<option key={d.deviceId} value={d.deviceId}>
|
||
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
onClick={() => void refresh()}
|
||
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs font-medium text-fg transition hover:bg-surface-3"
|
||
>
|
||
{t('app:settings.mic_refresh', { defaultValue: 'Neu laden' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-sm font-semibold text-fg">
|
||
{t('app:settings.speaker_title', { defaultValue: 'Ausgabegerät' })}
|
||
</div>
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
{t('app:settings.speaker_hint', {
|
||
defaultValue:
|
||
'Lautsprecher oder Kopfhörer. Wird auf alle aktiven Audio-Streams angewendet.',
|
||
})}
|
||
</p>
|
||
{!outputSupported && (
|
||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
|
||
{t('app:settings.speaker_unsupported', {
|
||
defaultValue: 'Browser unterstützt setSinkId nicht — Ausgabe folgt System-Default.',
|
||
})}
|
||
</p>
|
||
)}
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<select
|
||
value={outputId ?? ''}
|
||
disabled={!outputSupported}
|
||
onChange={(e) => void handleOutput(e.target.value)}
|
||
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-50"
|
||
>
|
||
<option value="">
|
||
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||
</option>
|
||
{outputs.map((d) => (
|
||
<option key={d.deviceId} value={d.deviceId}>
|
||
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-sm font-semibold text-fg">
|
||
{t('app:settings.camera_title', { defaultValue: 'Kamera' })}
|
||
</div>
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
{t('app:settings.camera_hint', {
|
||
defaultValue:
|
||
'Bevorzugte Kamera. Bei aktivem Anruf wird live umgeschaltet.',
|
||
})}
|
||
</p>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<select
|
||
value={cameraId ?? ''}
|
||
onChange={(e) => void handleCamera(e.target.value)}
|
||
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||
>
|
||
<option value="">
|
||
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||
</option>
|
||
{cameras.map((d) => (
|
||
<option key={d.deviceId} value={d.deviceId}>
|
||
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-line pt-3">
|
||
<MicTestSection />
|
||
</div>
|
||
|
||
{permission !== 'granted' && (
|
||
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||
<span>
|
||
{t('app:settings.mic_permission_hint', {
|
||
defaultValue:
|
||
'Ohne Mikrofon-Freigabe erscheinen Gerätenamen nicht. Einmal erlauben und Liste lädt neu.',
|
||
})}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => void requestPermission()}
|
||
className="shrink-0 cursor-pointer rounded-md bg-amber-600 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||
>
|
||
{t('app:settings.mic_grant', { defaultValue: 'Freigeben' })}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|