a04ecf7a19
- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
592 lines
24 KiB
React
592 lines
24 KiB
React
// Freunde / Admin / Settings pages — Clean Rail aesthetic
|
||
|
||
// ===================================================================
|
||
// SHARED PRIMITIVES
|
||
// ===================================================================
|
||
|
||
const PageHeader = ({ title, subtitle, actions }) => (
|
||
<div className="page-header">
|
||
<div>
|
||
<h1 className="page-title">{title}</h1>
|
||
{subtitle && <p className="page-subtitle">{subtitle}</p>}
|
||
</div>
|
||
{actions && <div className="page-header-actions">{actions}</div>}
|
||
</div>
|
||
);
|
||
|
||
const Section = ({ title, children, action }) => (
|
||
<section className="page-section">
|
||
<div className="page-section-header">
|
||
<h2 className="page-section-title">{title}</h2>
|
||
{action}
|
||
</div>
|
||
<div className="page-section-body">{children}</div>
|
||
</section>
|
||
);
|
||
|
||
const Toggle = ({ checked, onChange, label, hint, disabled }) => (
|
||
<label className={`toggle-row ${disabled ? 'disabled' : ''}`}>
|
||
<div className="toggle-copy">
|
||
<div className="toggle-label">{label}</div>
|
||
{hint && <div className="toggle-hint">{hint}</div>}
|
||
</div>
|
||
<span className={`toggle-switch ${checked ? 'on' : ''}`} onClick={() => !disabled && onChange(!checked)}>
|
||
<span className="toggle-thumb"></span>
|
||
</span>
|
||
</label>
|
||
);
|
||
|
||
const SegmentedControl = ({ options, value, onChange }) => (
|
||
<div className="segmented">
|
||
{options.map(opt => (
|
||
<button
|
||
key={opt.value}
|
||
className={`seg-btn ${value === opt.value ? 'active' : ''}`}
|
||
onClick={() => onChange(opt.value)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
const Row = ({ label, value, mono }) => (
|
||
<div className="row-kv">
|
||
<span className="row-label">{label}</span>
|
||
<span className={`row-value ${mono ? 'mono' : ''}`}>{value}</span>
|
||
</div>
|
||
);
|
||
|
||
const Stat = ({ label, value }) => (
|
||
<div className="stat">
|
||
<div className="stat-label">{label}</div>
|
||
<div className="stat-value">{value}</div>
|
||
</div>
|
||
);
|
||
|
||
const Banner = ({ kind = 'info', children }) => (
|
||
<div className={`banner ${kind}`}>
|
||
{kind === 'error' && <Icon.Alert />}
|
||
{kind === 'success' && <Icon.Check />}
|
||
<span>{children}</span>
|
||
</div>
|
||
);
|
||
|
||
const Empty = ({ icon: IconComp = Icon.Friends, children }) => (
|
||
<div className="empty-state">
|
||
<div className="empty-icon"><IconComp /></div>
|
||
<p>{children}</p>
|
||
</div>
|
||
);
|
||
|
||
// ===================================================================
|
||
// FRIENDS PAGE
|
||
// ===================================================================
|
||
|
||
const FRIENDS_DATA = [
|
||
{ id: 'max', userId: 'max', displayName: 'Max', username: 'maxx', initial: 'M', color: 'teal', status: 'accepted', direction: null, presence: 'online' },
|
||
{ id: 'lena', userId: 'lena', displayName: 'Lena', username: 'lena_k', initial: 'L', color: 'amber', status: 'accepted', direction: null, presence: 'online' },
|
||
{ id: 'tom', userId: 'tom', displayName: 'Tom', username: 'tomtom', initial: 'T', color: 'violet', status: 'accepted', direction: null, presence: 'idle' },
|
||
{ id: 'nico', userId: 'nico', displayName: 'nico', username: 'niklas', initial: 'N', color: 'teal', status: 'accepted', direction: null, presence: 'offline' },
|
||
{ id: 'julia', userId: 'julia', displayName: 'Julia', username: 'jul', initial: 'J', color: 'rose', status: 'accepted', direction: null, presence: 'dnd' },
|
||
{ id: 'ben', userId: 'ben', displayName: 'Ben', username: 'benny', initial: 'B', color: 'amber', status: 'pending', direction: 'outgoing' },
|
||
{ id: 'anna', userId: 'anna', displayName: 'Anna', username: 'anna_m', initial: 'A', color: 'violet', status: 'pending', direction: 'outgoing' },
|
||
{ id: 'kai', userId: 'kai', displayName: 'Kai', username: 'kaiser', initial: 'K', color: 'teal', status: 'pending', direction: 'incoming' },
|
||
{ id: 'sara', userId: 'sara', displayName: 'Sara', username: 'sari', initial: 'S', color: 'rose', status: 'pending', direction: 'incoming' },
|
||
];
|
||
|
||
const SEARCH_RESULTS = [
|
||
{ id: 'felix', userId: 'felix', displayName: 'Felix', username: 'felix_x', initial: 'F', color: 'violet' },
|
||
{ id: 'mia', userId: 'mia', displayName: 'Mia', username: 'mia_w', initial: 'M', color: 'amber' },
|
||
{ id: 'paul', userId: 'paul', displayName: 'Paul', username: 'paulk', initial: 'P', color: 'teal' },
|
||
];
|
||
|
||
const FriendRow = ({ profile, children }) => (
|
||
<div className="friend-row">
|
||
<Avatar chat={{initial: profile.initial, color: profile.color, presence: profile.presence}} />
|
||
<div className="friend-body">
|
||
<div className="friend-name">{profile.displayName}</div>
|
||
<div className="friend-handle">@{profile.username}</div>
|
||
</div>
|
||
<div className="friend-actions">{children}</div>
|
||
</div>
|
||
);
|
||
|
||
const FriendsPageContent = () => {
|
||
const [tab, setTab] = React.useState('friends');
|
||
const [query, setQuery] = React.useState('');
|
||
|
||
const accepted = FRIENDS_DATA.filter(f => f.status === 'accepted');
|
||
const outgoing = FRIENDS_DATA.filter(f => f.status === 'pending' && f.direction === 'outgoing');
|
||
const incoming = FRIENDS_DATA.filter(f => f.status === 'pending' && f.direction === 'incoming');
|
||
|
||
const showSearch = query.trim().length >= 2;
|
||
|
||
const tabs = [
|
||
{ key: 'friends', label: 'Freunde', count: accepted.length },
|
||
{ key: 'pending', label: 'Ausgehend', count: outgoing.length },
|
||
{ key: 'requests', label: 'Anfragen', count: incoming.length, highlight: incoming.length > 0 },
|
||
];
|
||
|
||
const currentList = tab === 'friends' ? accepted : tab === 'pending' ? outgoing : incoming;
|
||
|
||
return (
|
||
<div className="page-scroll">
|
||
<div className="page-inner friends-page">
|
||
<PageHeader title="Freunde" subtitle={`${accepted.length} Freunde · ${incoming.length} offene Anfragen`} />
|
||
|
||
<div className="search-wrap">
|
||
<Icon.Search />
|
||
<input
|
||
type="search"
|
||
placeholder="Benutzer suchen oder hinzufügen…"
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{showSearch && (
|
||
<div className="page-section">
|
||
<div className="page-section-header">
|
||
<h2 className="page-section-title">Suchergebnisse</h2>
|
||
</div>
|
||
<div className="friend-list">
|
||
{SEARCH_RESULTS.filter(r =>
|
||
r.displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||
r.username.toLowerCase().includes(query.toLowerCase())
|
||
).map(p => (
|
||
<FriendRow key={p.id} profile={p}>
|
||
<button className="btn btn-primary">
|
||
<Icon.AddUser /> Anfrage senden
|
||
</button>
|
||
</FriendRow>
|
||
))}
|
||
{SEARCH_RESULTS.filter(r =>
|
||
r.displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||
r.username.toLowerCase().includes(query.toLowerCase())
|
||
).length === 0 && (
|
||
<Empty icon={Icon.Search}>Keine Ergebnisse für „{query}"</Empty>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!showSearch && (
|
||
<>
|
||
<div className="tab-row">
|
||
{tabs.map(t => (
|
||
<button
|
||
key={t.key}
|
||
className={`tab-btn ${tab === t.key ? 'active' : ''}`}
|
||
onClick={() => setTab(t.key)}
|
||
>
|
||
{t.label}
|
||
<span className={`tab-count ${t.highlight ? 'highlight' : ''}`}>{t.count}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="friend-list">
|
||
{currentList.length === 0 && (
|
||
<Empty>
|
||
{tab === 'friends' && 'Noch keine Freunde. Suche oben nach Benutzern um anzufangen.'}
|
||
{tab === 'pending' && 'Keine ausgehenden Anfragen.'}
|
||
{tab === 'requests' && 'Keine eingehenden Anfragen.'}
|
||
</Empty>
|
||
)}
|
||
{currentList.map(f => (
|
||
<FriendRow key={f.id} profile={f}>
|
||
{tab === 'friends' && (
|
||
<>
|
||
<button className="btn btn-primary">
|
||
<Icon.Chat /> Nachricht
|
||
</button>
|
||
<button className="btn btn-ghost" title="Voice"><Icon.Phone /></button>
|
||
<button className="btn btn-danger-ghost">Entfernen</button>
|
||
</>
|
||
)}
|
||
{tab === 'pending' && (
|
||
<>
|
||
<span className="pending-label">Wartet auf Antwort…</span>
|
||
<button className="btn btn-ghost">Abbrechen</button>
|
||
</>
|
||
)}
|
||
{tab === 'requests' && (
|
||
<>
|
||
<button className="btn btn-primary">
|
||
<Icon.Check /> Annehmen
|
||
</button>
|
||
<button className="btn btn-ghost">Ablehnen</button>
|
||
</>
|
||
)}
|
||
</FriendRow>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===================================================================
|
||
// ADMIN PAGE
|
||
// ===================================================================
|
||
|
||
const INVITES_DATA = [
|
||
{ code: 'SQUAD-2k26', usesCount: 3, usesLimit: 10, expiresAt: '2026-05-01', disabled: false },
|
||
{ code: 'FRIENDS-OF-DENNIS', usesCount: 1, usesLimit: null, expiresAt: null, disabled: false },
|
||
{ code: 'BETA-ROUND-1', usesCount: 12, usesLimit: 12, expiresAt: null, disabled: false },
|
||
{ code: 'OLD-UNUSED', usesCount: 0, usesLimit: 5, expiresAt: '2026-03-01', disabled: true },
|
||
];
|
||
|
||
const USERS_DATA = [
|
||
{ userId: 'dennis', displayName: 'dennis', username: 'dennis', initial: 'D', color: 'teal', isAdmin: true, blockedFromInviting: false, banned: false, joinedAt: '14. Mär 2026' },
|
||
{ userId: 'max', displayName: 'Max', username: 'maxx', initial: 'M', color: 'teal', isAdmin: false, blockedFromInviting: false, banned: false, joinedAt: '16. Mär 2026' },
|
||
{ userId: 'lena', displayName: 'Lena', username: 'lena_k', initial: 'L', color: 'amber', isAdmin: false, blockedFromInviting: false, banned: false, joinedAt: '02. Apr 2026' },
|
||
{ userId: 'tom', displayName: 'Tom', username: 'tomtom', initial: 'T', color: 'violet', isAdmin: false, blockedFromInviting: true, banned: false, joinedAt: '08. Apr 2026' },
|
||
{ userId: 'spam', displayName: 'spam_user_42', username: 'spam42', initial: 'S', color: 'rose', isAdmin: false, blockedFromInviting: false, banned: true, joinedAt: '11. Apr 2026' },
|
||
];
|
||
|
||
const FlagChip = ({ active, tone, onToggle, children }) => (
|
||
<button
|
||
className={`flag-chip ${active ? 'active' : ''} ${tone === 'danger' ? 'danger' : ''}`}
|
||
onClick={() => onToggle(!active)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
|
||
const AdminPageContent = () => {
|
||
const [invitesEnabled, setInvitesEnabled] = React.useState(true);
|
||
const [invites, setInvites] = React.useState(INVITES_DATA);
|
||
const [users, setUsers] = React.useState(USERS_DATA);
|
||
const [copiedCode, setCopiedCode] = React.useState(null);
|
||
|
||
const handleCopy = (code) => {
|
||
setCopiedCode(code);
|
||
setTimeout(() => setCopiedCode(null), 1400);
|
||
};
|
||
|
||
const toggleUserFlag = (userId, flag) => {
|
||
setUsers(us => us.map(u => u.userId === userId ? {...u, [flag]: !u[flag]} : u));
|
||
};
|
||
|
||
const toggleInvite = (code) => {
|
||
setInvites(is => is.map(i => i.code === code ? {...i, disabled: !i.disabled} : i));
|
||
};
|
||
|
||
const deleteInvite = (code) => {
|
||
setInvites(is => is.filter(i => i.code !== code));
|
||
};
|
||
|
||
return (
|
||
<div className="page-scroll">
|
||
<div className="page-inner admin-page">
|
||
<PageHeader
|
||
title="Admin"
|
||
subtitle={`${users.length} User · ${invites.filter(i => !i.disabled).length} aktive Invites`}
|
||
/>
|
||
|
||
<div className="stats-grid">
|
||
<div className="stat-card">
|
||
<div className="stat-card-label">Gesamt User</div>
|
||
<div className="stat-card-value">{users.length}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-card-label">Admins</div>
|
||
<div className="stat-card-value">{users.filter(u => u.isAdmin).length}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-card-label">Gebannt</div>
|
||
<div className="stat-card-value danger">{users.filter(u => u.banned).length}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-card-label">Aktive Invites</div>
|
||
<div className="stat-card-value">{invites.filter(i => !i.disabled).length}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Section title="System">
|
||
<Toggle
|
||
label="Invites aktiviert"
|
||
hint="Wenn aus, können keine neuen User per Invite-Code beitreten."
|
||
checked={invitesEnabled}
|
||
onChange={setInvitesEnabled}
|
||
/>
|
||
</Section>
|
||
|
||
<Section
|
||
title="Invite-Codes"
|
||
action={
|
||
<button className="btn btn-primary">
|
||
<Icon.Plus /> Neuer Code
|
||
</button>
|
||
}
|
||
>
|
||
<div className="table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Code</th>
|
||
<th>Nutzungen</th>
|
||
<th>Ablauf</th>
|
||
<th>Status</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{invites.map(inv => {
|
||
const expired = inv.expiresAt && new Date(inv.expiresAt) < new Date('2026-04-19');
|
||
const status = inv.disabled ? 'disabled' : expired ? 'expired' : 'active';
|
||
const usesText = inv.usesLimit ? `${inv.usesCount}/${inv.usesLimit}` : `${inv.usesCount}/∞`;
|
||
return (
|
||
<tr key={inv.code}>
|
||
<td className="mono">{inv.code}</td>
|
||
<td className="muted">{usesText}</td>
|
||
<td className="muted">{inv.expiresAt || 'nie'}</td>
|
||
<td><span className={`status-pill ${status}`}>{status === 'active' ? 'Aktiv' : status === 'expired' ? 'Abgelaufen' : 'Deaktiviert'}</span></td>
|
||
<td>
|
||
<div className="row-actions">
|
||
<button className="icon-btn sm" title={copiedCode === inv.code ? 'Kopiert!' : 'Kopieren'} onClick={() => handleCopy(inv.code)}>
|
||
{copiedCode === inv.code ? <Icon.Check /> : <Icon.Copy />}
|
||
</button>
|
||
<button className="btn btn-xs btn-ghost" onClick={() => toggleInvite(inv.code)}>
|
||
{inv.disabled ? 'Aktivieren' : 'Deaktivieren'}
|
||
</button>
|
||
<button className="icon-btn sm danger" title="Löschen" onClick={() => deleteInvite(inv.code)}>
|
||
<Icon.Trash />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Benutzer">
|
||
<div className="user-admin-list">
|
||
{users.map(u => (
|
||
<div key={u.userId} className="user-admin-row">
|
||
<Avatar chat={{initial: u.initial, color: u.color}} />
|
||
<div className="user-admin-body">
|
||
<div className="user-admin-name">
|
||
{u.displayName}
|
||
{u.isAdmin && <span className="admin-badge"><Icon.Crown /> Admin</span>}
|
||
</div>
|
||
<div className="user-admin-meta">@{u.username} · beigetreten {u.joinedAt}</div>
|
||
</div>
|
||
<div className="user-admin-flags">
|
||
<FlagChip active={u.isAdmin} onToggle={() => toggleUserFlag(u.userId, 'isAdmin')}>Admin</FlagChip>
|
||
<FlagChip active={u.blockedFromInviting} onToggle={() => toggleUserFlag(u.userId, 'blockedFromInviting')}>Invite-Block</FlagChip>
|
||
<FlagChip active={u.banned} tone="danger" onToggle={() => toggleUserFlag(u.userId, 'banned')}>Bann</FlagChip>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===================================================================
|
||
// SETTINGS PAGE
|
||
// ===================================================================
|
||
|
||
const SettingsPageContent = ({ isDark, onToggleDark }) => {
|
||
const [language, setLanguage] = React.useState('de');
|
||
const [readReceipts, setReadReceipts] = React.useState(true);
|
||
const [allowDms, setAllowDms] = React.useState(true);
|
||
const [audioQuality, setAudioQuality] = React.useState('voice');
|
||
const [ptt, setPtt] = React.useState(false);
|
||
const [pttKey, setPttKey] = React.useState('Space');
|
||
const [capturingKey, setCapturingKey] = React.useState(false);
|
||
const [e2ee, setE2ee] = React.useState(true);
|
||
const [screenShareQuality, setScreenShareQuality] = React.useState('balanced');
|
||
|
||
const audioParams = audioQuality === 'hifi'
|
||
? { bitrate: '510 kbps', channels: 'Stereo', sample: '48 kHz', dsp: 'Off' }
|
||
: { bitrate: '48 kbps', channels: 'Mono', sample: '48 kHz', dsp: 'On' };
|
||
|
||
const screenParams = {
|
||
smooth: { bitrate: '3 Mbps', resolution: '1280×720', framerate: '60 fps' },
|
||
balanced: { bitrate: '6 Mbps', resolution: '1920×1080', framerate: '30 fps' },
|
||
crisp: { bitrate: '12 Mbps', resolution: '2560×1440', framerate: '30 fps' },
|
||
}[screenShareQuality];
|
||
|
||
return (
|
||
<div className="page-scroll">
|
||
<div className="page-inner settings-page">
|
||
<PageHeader title="Einstellungen" />
|
||
|
||
<Section title="Account">
|
||
<div className="avatar-control">
|
||
<div className="avatar-large" data-color="teal">D</div>
|
||
<div className="avatar-copy">
|
||
<div className="avatar-label">Avatar</div>
|
||
<div className="avatar-hint">Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.</div>
|
||
</div>
|
||
<button className="btn btn-primary">Ändern</button>
|
||
<button className="btn btn-danger-ghost">Entfernen</button>
|
||
</div>
|
||
<div className="row-stack">
|
||
<Row label="Benutzername" value="dennis" />
|
||
<Row label="Anzeigename" value="dennis" />
|
||
<Row label="User-ID" value="b4f2-a912-c7e8-d091" mono />
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Aussehen">
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<div className="toggle-label">Sprache</div>
|
||
<div className="toggle-hint">Sprache der App-Oberfläche</div>
|
||
</div>
|
||
<SegmentedControl
|
||
value={language}
|
||
onChange={setLanguage}
|
||
options={[{value: 'de', label: 'Deutsch'}, {value: 'en', label: 'English'}]}
|
||
/>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<div className="toggle-label">Theme</div>
|
||
<div className="toggle-hint">Dark Mode ist dezenter für späte Gaming-Sessions.</div>
|
||
</div>
|
||
<SegmentedControl
|
||
value={isDark ? 'dark' : 'light'}
|
||
onChange={v => { if ((v === 'dark') !== isDark) onToggleDark(); }}
|
||
options={[{value: 'light', label: '☀ Light'}, {value: 'dark', label: '☾ Dark'}]}
|
||
/>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Datenschutz">
|
||
<Toggle
|
||
label="Lesebestätigungen anzeigen"
|
||
hint="Freunde sehen wann du ihre Nachrichten gelesen hast. Beidseitig — du siehst dann auch ihre."
|
||
checked={readReceipts}
|
||
onChange={setReadReceipts}
|
||
/>
|
||
<Toggle
|
||
label="DMs von Fremden erlauben"
|
||
hint="Erlaubt Direktnachrichten von Usern mit denen du (noch) nicht befreundet bist."
|
||
checked={allowDms}
|
||
onChange={setAllowDms}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="Sprache & Audio">
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<div className="toggle-label">Audio-Qualität</div>
|
||
<div className="toggle-hint">
|
||
{audioQuality === 'hifi'
|
||
? 'Stereo 510 kbps Opus ohne Noise-Suppression — bester Musik/Broadcast-Sound.'
|
||
: 'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain — optimiert für Sprache.'}
|
||
</div>
|
||
</div>
|
||
<SegmentedControl
|
||
value={audioQuality}
|
||
onChange={setAudioQuality}
|
||
options={[{value: 'voice', label: 'Sprache'}, {value: 'hifi', label: 'HiFi'}]}
|
||
/>
|
||
</div>
|
||
<div className="stats-inline">
|
||
<Stat label="Bitrate" value={audioParams.bitrate} />
|
||
<Stat label="Channels" value={audioParams.channels} />
|
||
<Stat label="Sample" value={audioParams.sample} />
|
||
<Stat label="DSP" value={audioParams.dsp} />
|
||
</div>
|
||
<div className="divider"></div>
|
||
<Toggle
|
||
label="Push-to-Talk"
|
||
hint="Mic bleibt stumm bis die Taste gedrückt wird. Überschreibt den Mute-Button."
|
||
checked={ptt}
|
||
onChange={setPtt}
|
||
/>
|
||
{ptt && (
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<div className="toggle-label">Hotkey</div>
|
||
<div className="toggle-hint">Klick zum Ändern. Esc zum Abbrechen.</div>
|
||
</div>
|
||
<button
|
||
className={`key-capture ${capturingKey ? 'active' : ''}`}
|
||
onClick={() => setCapturingKey(!capturingKey)}
|
||
>
|
||
{capturingKey ? 'Taste drücken…' : pttKey}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="divider"></div>
|
||
<Toggle
|
||
label="Ende-zu-Ende-Verschlüsselung für Calls"
|
||
hint="Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben."
|
||
checked={e2ee}
|
||
onChange={setE2ee}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="Bildschirmfreigabe">
|
||
<div className="toggle-row">
|
||
<div className="toggle-copy">
|
||
<div className="toggle-label">Qualität</div>
|
||
<div className="toggle-hint">
|
||
WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an. Die Werte sind Obergrenzen.
|
||
</div>
|
||
</div>
|
||
<SegmentedControl
|
||
value={screenShareQuality}
|
||
onChange={setScreenShareQuality}
|
||
options={[
|
||
{value: 'smooth', label: 'Flüssig'},
|
||
{value: 'balanced', label: 'Balanced'},
|
||
{value: 'crisp', label: 'Scharf'},
|
||
]}
|
||
/>
|
||
</div>
|
||
<div className="stats-inline">
|
||
<Stat label="Bitrate (max)" value={screenParams.bitrate} />
|
||
<Stat label="Resolution" value={screenParams.resolution} />
|
||
<Stat label="Framerate" value={screenParams.framerate} />
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Geräte">
|
||
<div className="device-card">
|
||
<div className="device-card-title">
|
||
<Icon.Lock /> Dieses Gerät
|
||
</div>
|
||
<div className="row-stack" style={{marginTop: 10}}>
|
||
<Row label="Name" value="Dennis' MacBook Pro" />
|
||
<Row label="Plattform" value="macOS 14 · Desktop" />
|
||
<Row label="Device-ID" value="dev_01JC4NKT7XYZ" mono />
|
||
</div>
|
||
</div>
|
||
<div className="device-backup">
|
||
<div className="toggle-label">Geräteschlüssel-Backup</div>
|
||
<div className="toggle-hint" style={{marginTop: 4}}>
|
||
Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.
|
||
</div>
|
||
<div className="backup-row">
|
||
<input type="password" placeholder="Passphrase (min 8 Zeichen)" />
|
||
<button className="btn btn-primary">Export erstellen</button>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Danger Zone">
|
||
<button className="btn btn-danger">Abmelden</button>
|
||
</Section>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
Object.assign(window, { FriendsPageContent, AdminPageContent, SettingsPageContent });
|