feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- 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
This commit is contained in:
@@ -188,7 +188,7 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
|
||||
<div className="app-body">
|
||||
{variant.layout === 'rail' ? (
|
||||
<div className="rail-layout">
|
||||
<div className={`rail-layout ${activeTab !== 'chats' ? 'page-mode' : ''}`}>
|
||||
{/* Icon rail left */}
|
||||
<div className="icon-rail">
|
||||
<div className="rail-brand" title="Netralax"><Icon.Logo /></div>
|
||||
@@ -213,7 +213,8 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Chatlist + user profile at bottom */}
|
||||
{/* Chatlist + user profile at bottom — only on chats tab */}
|
||||
{activeTab === 'chats' && (
|
||||
<div className="chat-list rail-chatlist">
|
||||
<div className="chat-list-header">
|
||||
<div className="chat-list-title">Chats</div>
|
||||
@@ -235,8 +236,13 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chat area (unchanged below) */}
|
||||
<div className="chat-area">
|
||||
)}
|
||||
{/* Chat area OR page content */}
|
||||
<div className={`chat-area ${activeTab !== 'chats' ? 'has-page' : ''}`}>
|
||||
{activeTab === 'friends' && <FriendsPageContent />}
|
||||
{activeTab === 'admin' && <AdminPageContent />}
|
||||
{activeTab === 'settings' && <SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} />}
|
||||
{activeTab === 'chats' && <>
|
||||
{!(callScreen === 'active' && callMode !== 'fullscreen') && (
|
||||
<div className="chat-header">
|
||||
<Avatar chat={activeChat} />
|
||||
@@ -290,6 +296,7 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
</div>
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -329,6 +336,10 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
</div>
|
||||
|
||||
<div className="main-area">
|
||||
{activeTab === 'friends' && <div className="chat-area has-page" style={{width: '100%'}}><FriendsPageContent /></div>}
|
||||
{activeTab === 'admin' && <div className="chat-area has-page" style={{width: '100%'}}><AdminPageContent /></div>}
|
||||
{activeTab === 'settings' && <div className="chat-area has-page" style={{width: '100%'}}><SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} /></div>}
|
||||
{activeTab === 'chats' && <>
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<div className="chat-list-title">Chats</div>
|
||||
@@ -382,13 +393,14 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call screen switcher — only in Clean Rail */}
|
||||
{variant.layout === 'rail' && (
|
||||
{/* Call screen switcher — only in Clean Rail, only on chats tab */}
|
||||
{variant.layout === 'rail' && activeTab === 'chats' && (
|
||||
<div className="screen-switcher">
|
||||
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
|
||||
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
|
||||
|
||||
@@ -82,6 +82,18 @@ const Icon = {
|
||||
Minimize: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
|
||||
),
|
||||
Copy: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
),
|
||||
Trash: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
),
|
||||
Alert: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
),
|
||||
Check: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
),
|
||||
};
|
||||
|
||||
Object.assign(window, { Icon });
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
// 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 });
|
||||
Reference in New Issue
Block a user