This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+438
View File
@@ -0,0 +1,438 @@
import {
type AdminProfileFlag,
type AdminProfileRow,
type AdminSetting,
createInvite,
deleteInvite,
type InviteRecord,
listAdminSettings,
listAllProfiles,
listInvites,
setInviteDisabled,
setUserFlag,
updateAdminSetting,
} from '@chat-app/shared/admin';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertIcon,
CopyIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
} from '../components/icons';
import { supabase } from '../lib/supabase';
export function AdminPage() {
const { t } = useTranslation(['app', 'errors']);
const [settings, setSettings] = useState<AdminSetting[]>([]);
const [invites, setInvites] = useState<InviteRecord[]>([]);
const [users, setUsers] = useState<AdminProfileRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
const [s, i, u] = await Promise.all([
listAdminSettings(supabase),
listInvites(supabase),
listAllProfiles(supabase),
]);
setSettings(s);
setInvites(i);
setUsers(u);
setError(null);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:admin.title')}
</h1>
</header>
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
{loading ? (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
</div>
) : (
<>
<SettingsSection settings={settings} onRefresh={refresh} />
<InvitesSection invites={invites} onRefresh={refresh} />
<UsersSection users={users} onRefresh={refresh} />
</>
)}
</div>
);
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
<header className="mb-4 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
{action}
</header>
{children}
</section>
);
}
// --- Settings --------------------------------------------------------------
function SettingsSection({ settings, onRefresh }: { settings: AdminSetting[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
const [busy, setBusy] = useState(false);
const invitesEnabled = Boolean(settings.find((s) => s.key === 'invites_enabled')?.value);
async function handleToggleInvites(next: boolean) {
if (busy) return;
setBusy(true);
try {
await updateAdminSetting(supabase, 'invites_enabled', next);
await onRefresh();
} catch (err: unknown) {
console.error(err);
} finally {
setBusy(false);
}
}
return (
<Section title={t('app:admin.settings_title')}>
<Toggle
label={t('app:admin.invites_enabled')}
hint={t('app:admin.invites_enabled_hint')}
checked={invitesEnabled}
disabled={busy}
onChange={(v) => void handleToggleInvites(v)}
/>
</Section>
);
}
// --- Invites ---------------------------------------------------------------
function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
const [busy, setBusy] = useState(false);
const [copiedCode, setCopiedCode] = useState<string | null>(null);
async function handleCreate() {
if (busy) return;
setBusy(true);
try {
await createInvite(supabase, {});
await onRefresh();
} catch (err: unknown) {
console.error(err);
} finally {
setBusy(false);
}
}
async function handleToggle(code: string, disabled: boolean) {
try {
await setInviteDisabled(supabase, code, !disabled);
await onRefresh();
} catch (err: unknown) {
console.error(err);
}
}
async function handleDelete(code: string) {
if (!window.confirm(code + ' ?')) return;
try {
await deleteInvite(supabase, code);
await onRefresh();
} catch (err: unknown) {
console.error(err);
}
}
async function handleCopy(code: string) {
try {
await navigator.clipboard.writeText(code);
setCopiedCode(code);
window.setTimeout(() => setCopiedCode((c) => (c === code ? null : c)), 1400);
} catch {
/* ignore */
}
}
return (
<Section
title={t('app:admin.invites_title')}
action={
<button
type="button"
onClick={() => void handleCreate()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
<span>{t('app:admin.invites_create')}</span>
</button>
}
>
{invites.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_expires')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_status')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{invites.map((inv) => {
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
const status = inv.disabled
? t('app:admin.invite_status_disabled')
: expired
? t('app:admin.invite_status_expired')
: t('app:admin.invite_status_active');
const statusColor = inv.disabled
? 'text-rose-300'
: expired
? 'text-amber-300'
: 'text-emerald-300';
const usesText = inv.usesLimit
? inv.usesCount + '/' + inv.usesLimit
: inv.usesCount + '/∞';
const expiresText = inv.expiresAt
? new Date(inv.expiresAt).toLocaleDateString()
: t('app:admin.invite_expires_never');
return (
<tr key={inv.code} className="py-2">
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td>
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
<td className="py-2 text-right">
<div className="inline-flex gap-1">
<IconButton
label={
copiedCode === inv.code
? t('app:admin.invites_copied')
: t('app:admin.invites_copy')
}
onClick={() => void handleCopy(inv.code)}
>
<CopyIcon className="h-3.5 w-3.5" />
</IconButton>
<button
type="button"
onClick={() => void handleToggle(inv.code, inv.disabled)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10"
>
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
</button>
<IconButton
label={t('app:admin.invites_delete')}
tone="danger"
onClick={() => void handleDelete(inv.code)}
>
<TrashIcon className="h-3.5 w-3.5" />
</IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Section>
);
}
// --- Users -----------------------------------------------------------------
function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
try {
await setUserFlag(supabase, userId, flag, value);
await onRefresh();
} catch (err: unknown) {
console.error(err);
}
}
return (
<Section title={t('app:admin.users_title')}>
{users.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p>
) : (
<ul className="divide-y divide-white/5">
{users.map((u) => {
const letter =
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<li key={u.userId} className="flex items-center gap-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
</div>
<div className="flex gap-2">
<FlagChip
label={t('app:admin.users_flag_admin')}
active={u.isAdmin}
onToggle={(v) => void toggle(u.userId, 'is_admin', v)}
/>
<FlagChip
label={t('app:admin.users_flag_blocked_inviting')}
active={u.blockedFromInviting}
onToggle={(v) => void toggle(u.userId, 'blocked_from_inviting', v)}
/>
<FlagChip
label={t('app:admin.users_flag_banned')}
tone="danger"
active={u.banned}
onToggle={(v) => void toggle(u.userId, 'banned', v)}
/>
</div>
</li>
);
})}
</ul>
)}
</Section>
);
}
// --- Bits ------------------------------------------------------------------
function Toggle({
label,
hint,
checked,
disabled,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
</span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="peer sr-only"
/>
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
);
}
function IconButton({
label,
children,
onClick,
tone,
}: {
label: string;
children: React.ReactNode;
onClick: () => void;
tone?: 'danger';
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
(tone === 'danger'
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40')
}
>
{children}
</button>
);
}
function FlagChip({
label,
active,
onToggle,
tone,
}: {
label: string;
active: boolean;
onToggle: (next: boolean) => void;
tone?: 'danger';
}) {
const activeClass =
tone === 'danger'
? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
: 'border-brand-400/40 bg-brand-500/20 text-brand-100';
return (
<button
type="button"
onClick={() => onToggle(!active)}
className={
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
}
>
{label}
</button>
);
}
@@ -0,0 +1,46 @@
import { completeSessionFromUrl } from '@chat-app/shared/auth';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import { AlertIcon, SpinnerIcon } from '../components/icons';
import { supabase } from '../lib/supabase';
type State = { kind: 'pending' } | { kind: 'done' } | { kind: 'error'; message: string };
export function AuthCallbackPage() {
const { t } = useTranslation(['common', 'errors']);
const [state, setState] = useState<State>({ kind: 'pending' });
useEffect(() => {
completeSessionFromUrl(supabase, window.location.href)
.then(() => {
window.history.replaceState(null, '', '/chats');
setState({ kind: 'done' });
})
.catch((err: unknown) => {
setState({
kind: 'error',
message: err instanceof Error ? err.message : t('errors:generic'),
});
});
}, [t]);
if (state.kind === 'done') return <Navigate to="/chats" replace />;
return (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
{state.kind === 'pending' ? (
<div className="flex items-center gap-3 text-neutral-400">
<SpinnerIcon className="h-5 w-5 text-brand-400" />
<span className="text-sm font-medium">{t('common:finalising_session')}</span>
</div>
) : (
<div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-4 text-sm text-rose-100">
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{state.message}</p>
</div>
)}
</main>
);
}
+625
View File
@@ -0,0 +1,625 @@
import {
loginWithMagicLink,
signUpWithMagicLink,
verifyMagicLinkOtp,
} from '@chat-app/shared/auth';
import {
extractErrorCode,
isSupportedLocale,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useCallback, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import {
AlertIcon,
ArrowRightIcon,
AtIcon,
CheckCircleIcon,
LockIcon,
LogoMark,
MailIcon,
ShieldIcon,
SparklesIcon,
SpinnerIcon,
TicketIcon,
} from '../components/icons';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { useAuth } from '../context/AuthContext';
import { env } from '../lib/env';
import { supabase } from '../lib/supabase';
type Mode = 'signup' | 'login';
type UiState =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'sent'; email: string }
| { kind: 'error'; message: string };
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/;
export function AuthPage() {
const { session } = useAuth();
const { t, i18n } = useTranslation(['auth', 'common', 'errors']);
const [ui, setUi] = useState<UiState>({ kind: 'idle' });
const [mode, setMode] = useState<Mode>('signup');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [inviteCode, setInviteCode] = useState('DEV-INVITE-001');
const usernameValid = useMemo(() => USERNAME_PATTERN.test(username), [username]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setUi({ kind: 'sending' });
try {
if (mode === 'signup') {
const activeLocale = i18n.resolvedLanguage ?? i18n.language;
await signUpWithMagicLink(supabase, {
email,
username,
inviteCode,
redirectTo: env.authRedirectUrl,
...(isSupportedLocale(activeLocale)
? { locale: activeLocale satisfies SupportedLocale }
: {}),
});
} else {
await loginWithMagicLink(supabase, email, env.authRedirectUrl);
}
setUi({ kind: 'sent', email });
} catch (err: unknown) {
const code = extractErrorCode(err);
const message = code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic');
setUi({ kind: 'error', message });
}
},
[mode, email, username, inviteCode, i18n, t],
);
// Already signed in? Bounce to chats. Guards take it from here.
if (session) return <Navigate to="/chats" replace />;
return (
<Shell>
<BrandPanel />
<FormCard
mode={mode}
onModeChange={setMode}
email={email}
onEmailChange={setEmail}
username={username}
onUsernameChange={setUsername}
usernameValid={usernameValid}
inviteCode={inviteCode}
onInviteChange={setInviteCode}
ui={ui}
onSubmit={handleSubmit}
/>
</Shell>
);
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="relative min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<BackgroundStage />
<div className="relative z-10 grid min-h-screen w-full grid-cols-1 gap-0 lg:grid-cols-[minmax(0,1fr)_minmax(440px,560px)]">
{children}
</div>
</main>
);
}
function BackgroundStage() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
<div className="absolute -left-40 top-[22%] h-[560px] w-[560px] -translate-y-1/2 rounded-full bg-brand-500/30 blur-3xl animate-blob-a xl:h-[680px] xl:w-[680px] 2xl:h-[820px] 2xl:w-[820px]" />
<div className="absolute left-[38%] top-[60%] h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-fuchsia-500/20 blur-3xl animate-blob-b xl:h-[640px] xl:w-[640px] 2xl:h-[780px] 2xl:w-[780px]" />
<div className="absolute -right-32 top-[12%] h-[420px] w-[420px] rounded-full bg-indigo-500/20 blur-3xl animate-blob-b xl:h-[520px] xl:w-[520px]" />
<div className="absolute -right-20 bottom-0 h-[460px] w-[460px] rounded-full bg-rose-500/10 blur-3xl animate-blob-a xl:h-[560px] xl:w-[560px]" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-ink-950/80" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.65)_100%)]" />
</div>
);
}
function BrandPanel() {
const { t } = useTranslation(['auth', 'common']);
return (
<section className="relative hidden lg:block">
<div className="grid h-full grid-rows-[auto_1fr_auto] px-10 py-10 xl:px-14 xl:py-14 2xl:px-20 2xl:py-16">
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<LogoMark className="h-9 w-9" />
<span className="font-display text-lg font-semibold tracking-tight">
{t('common:app_name')}
</span>
</div>
<LanguageSwitcher />
</header>
<div className="flex items-center">
<div className="w-full max-w-xl animate-fade-in xl:max-w-2xl 2xl:max-w-3xl">
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-neutral-300 backdrop-blur">
<ShieldIcon className="h-3.5 w-3.5 text-emerald-400" />
{t('auth:brand.badge')}
</p>
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-white xl:text-5xl 2xl:text-6xl">
{t('auth:brand.title_line_1')}
<br />
{t('auth:brand.title_line_2')}
</h1>
<p className="mt-5 max-w-lg text-base leading-relaxed text-neutral-400 xl:text-lg 2xl:max-w-xl">
{t('auth:brand.subtitle')}
</p>
<dl className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:mt-12 xl:gap-5 2xl:grid-cols-3">
<Feature
icon={<LockIcon className="h-5 w-5 text-brand-300" />}
title={t('auth:brand.feature_zk_title')}
desc={t('auth:brand.feature_zk_desc')}
/>
<Feature
icon={<SparklesIcon className="h-5 w-5 text-brand-300" />}
title={t('auth:brand.feature_selfhost_title')}
desc={t('auth:brand.feature_selfhost_desc')}
/>
<Feature
icon={<ShieldIcon className="h-5 w-5 text-brand-300" />}
title={t('auth:brand.feature_invite_title')}
desc={t('auth:brand.feature_invite_desc')}
/>
</dl>
</div>
</div>
<footer className="flex items-center justify-between text-xs text-neutral-500">
<span>v0.1.0 · {t('common:dev_build')}</span>
<span className="inline-flex items-center gap-1.5 text-neutral-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{t('common:local_stack_online')}
</span>
</footer>
</div>
</section>
);
}
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
return (
<div className="rounded-xl border border-white/5 bg-white/5 p-4 backdrop-blur">
<div className="flex items-center gap-2">
{icon}
<dt className="text-sm font-semibold text-white">{title}</dt>
</div>
<dd className="mt-1.5 text-sm text-neutral-400">{desc}</dd>
</div>
);
}
interface FormCardProps {
mode: Mode;
onModeChange: (m: Mode) => void;
email: string;
onEmailChange: (v: string) => void;
username: string;
onUsernameChange: (v: string) => void;
usernameValid: boolean;
inviteCode: string;
onInviteChange: (v: string) => void;
ui: UiState;
onSubmit: (e: React.FormEvent) => void;
}
function FormCard({
mode,
onModeChange,
email,
onEmailChange,
username,
onUsernameChange,
usernameValid,
inviteCode,
onInviteChange,
ui,
onSubmit,
}: FormCardProps) {
const { t } = useTranslation(['auth']);
const busy = ui.kind === 'sending';
const emailId = useId();
const usernameId = useId();
const inviteId = useId();
const titleKey = mode === 'signup' ? 'auth:signup.title' : 'auth:login.title';
const subtitleKey = mode === 'signup' ? 'auth:signup.subtitle' : 'auth:login.subtitle';
const ctaKey = mode === 'signup' ? 'auth:signup.cta' : 'auth:login.cta';
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
return (
<section className="relative flex items-center justify-center px-5 py-10 sm:px-8 lg:px-10 xl:px-16">
<div className="absolute left-6 right-6 top-6 flex items-center justify-between lg:hidden">
<div className="flex items-center gap-2.5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight">ChatApp</span>
</div>
<LanguageSwitcher compact />
</div>
<div className="w-full max-w-md animate-slide-up">
<div className="rounded-2xl border border-white/10 bg-ink-900/70 p-6 shadow-glow backdrop-blur-xl sm:p-8">
<header className="mb-6">
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
{t(titleKey)}
</h2>
<p className="mt-1.5 text-sm text-neutral-400">{t(subtitleKey)}</p>
</header>
<Segmented mode={mode} onChange={onModeChange} />
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<Field
id={emailId}
label={t('auth:fields.email')}
icon={<MailIcon className="h-4 w-4" />}
input={
<input
id={emailId}
type="email"
name="email"
inputMode="email"
autoComplete="email"
required
placeholder={t('auth:fields.email_placeholder')}
value={email}
onChange={(e) => onEmailChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
}
/>
{mode === 'signup' && (
<>
<Field
id={usernameId}
label={t('auth:fields.username')}
hint={
username.length > 0 && !usernameValid
? t('auth:fields.username_invalid')
: t('auth:fields.username_hint')
}
invalid={username.length > 0 && !usernameValid}
icon={<AtIcon className="h-4 w-4" />}
input={
<input
id={usernameId}
type="text"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required
placeholder={t('auth:fields.username_placeholder')}
value={username}
onChange={(e) => onUsernameChange(e.target.value.toLowerCase())}
pattern={USERNAME_PATTERN.source}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
}
/>
<Field
id={inviteId}
label={t('auth:fields.invite_code')}
hint={t('auth:fields.invite_hint')}
icon={<TicketIcon className="h-4 w-4" />}
input={
<input
id={inviteId}
type="text"
name="invite"
autoCorrect="off"
spellCheck={false}
required
value={inviteCode}
onChange={(e) => onInviteChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm font-mono text-white transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
}
/>
</>
)}
<button
type="submit"
disabled={busy}
aria-busy={busy}
className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t(ctaSendingKey)}</span>
</>
) : (
<>
<span>{t(ctaKey)}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
<StatusBanner ui={ui} />
{ui.kind === 'sent' && <OtpForm email={ui.email} />}
</form>
<Footer mode={mode} onModeChange={onModeChange} />
</div>
<p className="mt-6 text-center text-xs text-neutral-500">{t('auth:legal_note')}</p>
</div>
</section>
);
}
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']);
return (
<div
role="tablist"
aria-label="Authentication mode"
className="relative grid grid-cols-2 rounded-lg border border-white/10 bg-ink-800 p-1 text-sm"
>
<div
aria-hidden="true"
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-brand-500/20 ring-1 ring-brand-400/40 transition-transform duration-200"
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
/>
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
{t('auth:tab_signup')}
</SegmentButton>
<SegmentButton active={mode === 'login'} onClick={() => onChange('login')}>
{t('auth:tab_login')}
</SegmentButton>
</div>
);
}
function SegmentButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
(active ? 'text-white' : 'text-neutral-400 hover:text-neutral-200')
}
>
{children}
</button>
);
}
function Field({
id,
label,
hint,
icon,
input,
invalid,
}: {
id: string;
label: string;
hint?: string;
icon: React.ReactNode;
input: React.ReactNode;
invalid?: boolean;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{label}
</label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500">
{icon}
</span>
{input}
</div>
{hint && (
<p className={'text-xs ' + (invalid ? 'text-rose-400' : 'text-neutral-500')}>{hint}</p>
)}
</div>
);
}
function StatusBanner({ ui }: { ui: UiState }) {
const { t } = useTranslation(['auth']);
if (ui.kind === 'sent') {
return (
<div
role="status"
className="flex items-start gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3.5 text-sm text-emerald-100"
>
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-medium">
{t('auth:sent_banner', { email: ui.email })}
</p>
<p className="break-words text-xs text-emerald-200/80">
<InbucketHint />
</p>
</div>
</div>
);
}
if (ui.kind === 'error') {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3.5 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{ui.message}</p>
</div>
);
}
return null;
}
function InbucketHint() {
const { t } = useTranslation(['auth']);
const parts = t('auth:sent_banner_hint').split('Inbucket');
if (parts.length === 1) return <span>{t('auth:sent_banner_hint')}</span>;
return (
<span>
{parts[0]}
<a
href="http://127.0.0.1:54324"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-white"
>
Inbucket
</a>
{parts[1]}
</span>
);
}
function OtpForm({ email }: { email: string }) {
const { t } = useTranslation(['auth', 'errors']);
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputId = useId();
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
e.stopPropagation();
if (busy || token.length !== 6) return;
setBusy(true);
setError(null);
try {
await verifyMagicLinkOtp(supabase, email, token);
// Session updates via Supabase subscription; AuthPage Navigate redirects.
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}
return (
<div className="mt-4 space-y-2 rounded-lg border border-white/10 bg-ink-800/50 p-4">
<label
htmlFor={inputId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
>
{t('auth:otp_label')}
</label>
<input
id={inputId}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
value={token}
onChange={(e) => setToken(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleVerify(e);
}
}}
placeholder={t('auth:otp_placeholder')}
autoFocus
className="w-full rounded-lg border border-white/10 bg-ink-900 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-white placeholder-neutral-600 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<p className="text-xs text-neutral-500">{t('auth:otp_hint')}</p>
{error && (
<p
role="alert"
className="break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200"
>
{error}
</p>
)}
<button
type="button"
disabled={busy || token.length !== 6}
onClick={(e) => void handleVerify(e)}
aria-busy={busy}
className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
</button>
</div>
);
}
function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']);
const promptKey = mode === 'signup' ? 'auth:footer_signup_prompt' : 'auth:footer_login_prompt';
const switchKey =
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
return (
<div className="mt-6 flex items-center justify-between border-t border-white/5 pt-5 text-xs text-neutral-500">
<span>
{t(promptKey)}{' '}
<button
type="button"
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
className="cursor-pointer font-medium text-brand-300 underline-offset-2 hover:text-brand-200 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400/40 focus:ring-offset-2 focus:ring-offset-ink-900"
>
{t(switchKey)}
</button>
</span>
<a
href="http://127.0.0.1:54323"
target="_blank"
rel="noreferrer"
className="hover:text-neutral-300"
>
{t('auth:footer_studio')}
</a>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { CreateGroupDialog } from '../components/CreateGroupDialog';
import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons';
import { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase';
export function ChatsPage() {
const { conversations, loading, error, refresh, unread } = useConversationsContext();
const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false);
const sorted = useMemo(() => {
return [...conversations].sort((a, b) => {
const ta = a.lastMessageAt ?? a.createdAt;
const tb = b.lastMessageAt ?? b.createdAt;
return tb.localeCompare(ta);
});
}, [conversations]);
return (
<div className="flex h-full">
<ConversationList
items={sorted}
loading={loading}
error={error}
activeId={activeId}
unread={unread}
onAccept={async (id) => {
try {
await acceptDm(supabase, id);
await refresh();
} catch (err: unknown) {
const code = extractErrorCode(err);
console.error('acceptDm failed', code ?? err);
}
}}
onNewGroup={() => setCreateOpen(true)}
/>
<div className="flex-1 border-l border-white/5">
<Outlet />
</div>
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
</div>
);
}
function ConversationList({
items,
loading,
error,
activeId,
unread,
onAccept,
onNewGroup,
}: {
items: ConversationSummary[];
loading: boolean;
error: string | null;
activeId: string | undefined;
unread: Record<string, number>;
onAccept: (id: string) => void;
onNewGroup: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<aside
aria-label="Conversations"
className="flex h-full w-[320px] shrink-0 flex-col bg-ink-900/40"
>
<header className="flex items-center justify-between px-4 pb-3 pt-5">
<h2 className="font-display text-base font-semibold tracking-tight text-white">
{t('app:nav.chats')}
</h2>
<button
type="button"
onClick={onNewGroup}
aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-medium text-neutral-300 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<UsersIcon className="h-3.5 w-3.5" />
<PlusIcon className="h-3 w-3" />
</button>
</header>
{loading ? (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
</div>
) : error ? (
<p className="px-4 py-2 text-xs text-rose-300">{error}</p>
) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
<ChatBubbleIcon className="h-5 w-5" />
</div>
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p>
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto px-2 pb-4">
{items.map((c) => (
<li key={c.id}>
<ConversationRow
item={c}
active={c.id === activeId}
unreadCount={unread[c.id] ?? 0}
onAccept={onAccept}
/>
</li>
))}
</ul>
)}
</aside>
);
}
function ConversationRow({
item,
active,
unreadCount,
onAccept,
}: {
item: ConversationSummary;
active: boolean;
unreadCount: number;
onAccept: (id: string) => void;
}) {
const { t } = useTranslation(['app']);
const title =
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?';
if (!item.acceptedByMe) {
return (
<div className="my-1 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
<div className="flex items-center gap-3">
<Avatar letter={letter} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{title}</p>
<p className="truncate text-xs text-amber-200/80">
{t('app:friends.incoming_request')}
</p>
</div>
</div>
<button
type="button"
onClick={() => onAccept(item.id)}
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40"
>
{t('app:friends.action_accept')}
</button>
</div>
);
}
return (
<NavLink
to={'/chats/' + item.id}
className={
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
}
>
<Avatar letter={letter} />
<div className="min-w-0 flex-1">
<p
className={
'truncate text-sm ' +
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white')
}
>
{title}
</p>
<p className="truncate text-xs text-neutral-500">{handle}</p>
</div>
{unreadCount > 0 && (
<span
aria-label={'Unread: ' + unreadCount}
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</NavLink>
);
}
function Avatar({ letter }: { letter: string }) {
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
);
}
export function ChatsEmptyState() {
const { t } = useTranslation(['app']);
return (
<div className="flex h-full items-center justify-center p-10">
<div className="max-w-md text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
<ChatBubbleIcon className="h-6 w-6" />
</div>
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:chats.select_prompt')}
</h2>
<p className="mt-2 text-sm text-neutral-400">{t('app:chats.select_subtitle')}</p>
</div>
</div>
);
}
+321
View File
@@ -0,0 +1,321 @@
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { MessageBubble } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
const { session, device } = useAuth();
const { conversations, setActiveConversation, markRead } = useConversationsContext();
const conversation = useMemo(
() => conversations.find((c) => c.id === id) ?? null,
[conversations, id],
);
const peerId = conversation?.peer?.userId;
const peerPresence = usePeerPresence(peerId);
const { messages, loading, error, send } = useConversationMessages({
conversationId: id,
userId: session?.user.id,
deviceId: device?.id,
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
messageIds,
session?.user.id,
);
const myId = session?.user.id;
// Peer read tracking — only for 1:1 DMs.
const ownMessageIds = useMemo(
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
[messages, myId],
);
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
const lastSeenMessageId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m && m.senderId === myId && peerReadSet.has(m.id)) return m.id;
}
return null;
}, [messages, peerReadSet, myId]);
// Typing channel.
const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);
// Mark incoming messages as read (server-side, visible to peer if both sides
// have receipts on). Runs whenever new messages arrive or id changes.
useEffect(() => {
if (!id || messages.length === 0 || !myId) return;
const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
if (incoming.length === 0) return;
void markMessagesReadRemote(incoming).catch((err: unknown) => {
console.error('markMessagesRead failed', err);
});
}, [id, messages, myId]);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]);
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!id) return;
setActiveConversation(id);
return () => {
setActiveConversation(null);
};
}, [id, setActiveConversation]);
useEffect(() => {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
useEffect(() => {
setStickToBottom(true);
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [id]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setStickToBottom(distanceFromBottom < STICK_THRESHOLD);
}, []);
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
setSending(true);
setSendError(null);
try {
await send(text, attachments);
setText('');
setAttachments([]);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
} catch (err: unknown) {
const code = extractErrorCode(err);
setSendError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setSending(false);
}
}
function handleFilesChosen(list: FileList | null) {
if (!list) return;
const next: File[] = [];
for (let i = 0; i < list.length; i++) {
const f = list[i];
if (!f) continue;
if (!f.type.startsWith('image/')) continue;
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
}
next.push(f);
}
setAttachments((prev) => [...prev, ...next].slice(0, 4));
}
const isGroup = conversation?.type === 'group';
return (
<div className="relative flex h-full flex-col">
<ConversationHeader
conversation={conversation}
peerPresence={peerPresence}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
/>
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
{conversation && <InCallPanel conversation={conversation} />}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
</div>
) : error ? (
<Banner>{error}</Banner>
) : messages.length === 0 ? (
<p className="text-center text-sm text-neutral-500"></p>
) : (
<ul className="space-y-0.5">
{messages.map((m, idx) => {
const prev = messages[idx - 1];
const grouped = idx > 0 && prev?.senderId === m.senderId;
return (
<li key={m.id}>
<MessageBubble
message={m}
mine={m.senderId === myId}
groupedWithPrev={grouped}
conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
showSeen={m.id === lastSeenMessageId}
/>
</li>
);
})}
</ul>
)}
</div>
<TypingIndicator
typingUserIds={typingUserIds}
members={conversation?.members ?? []}
/>
<form onSubmit={handleSend} className="border-t border-white/5 p-4">
{sendError && (
<div className="mb-2">
<Banner>{sendError}</Banner>
</div>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, idx) => (
<AttachmentPreview
key={idx}
file={file}
onRemove={() =>
setAttachments((prev) => prev.filter((_, i) => i !== idx))
}
/>
))}
</div>
)}
<div className="flex items-end gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen"
title="Bild anhängen"
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<PlusIcon className="h-4 w-4" />
</button>
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
if (e.target.value.length > 0) notifyTyping();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
rows={1}
placeholder="…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<button
type="submit"
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
aria-busy={sending}
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
</button>
</div>
</form>
</div>
);
}
function Banner({ children }: { children: React.ReactNode }) {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
);
}
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const u = URL.createObjectURL(file);
setUrl(u);
return () => URL.revokeObjectURL(u);
}, [file]);
return (
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60">
{url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : (
<div className="h-20 w-20" />
)}
<button
type="button"
onClick={onRemove}
aria-label="Entfernen"
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white"
>
<XIcon className="h-3 w-3" />
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { DeviceRegistration } from '../components/DeviceRegistration';
import { useAuth } from '../context/AuthContext';
export function DevicePage() {
const { session, profile, setDevice } = useAuth();
const navigate = useNavigate();
if (!session) return null; // Guarded by RequireAuth, but be defensive.
const defaultName =
(profile?.displayName ?? profile?.username ?? 'Desktop') + ' Desktop';
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
<BackgroundStage />
<div className="relative z-10 w-full max-w-md">
<DeviceRegistration
userId={session.user.id}
defaultName={defaultName}
onRegistered={(device) => {
setDevice(device);
navigate('/chats', { replace: true });
}}
/>
</div>
</main>
);
}
function BackgroundStage() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
<div className="absolute -left-32 top-1/4 h-[460px] w-[460px] rounded-full bg-brand-500/25 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[460px] w-[460px] rounded-full bg-fuchsia-500/15 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.65)_100%)]" />
</div>
);
}
+573
View File
@@ -0,0 +1,573 @@
import { createDm } from '@chat-app/shared/chat';
import {
acceptFriendRequest,
type Friendship,
type ProfileBrief,
removeFriendship,
searchProfiles,
sendFriendRequest,
} from '@chat-app/shared/friends';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertIcon,
ChatBubbleIcon,
CheckCircleIcon,
PlusIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
} from '../components/icons';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
type Tab = 'friends' | 'pending' | 'requests';
export function FriendsPage() {
const { t } = useTranslation(['app', 'errors']);
const { friendships, loading, error, refresh } = useFriendshipsContext();
const [tab, setTab] = useState<Tab>('friends');
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [results, setResults] = useState<ProfileBrief[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
return () => window.clearTimeout(id);
}, [query]);
useEffect(() => {
if (debounced.length < 2) {
setResults([]);
setSearchError(null);
return;
}
let cancelled = false;
setSearching(true);
searchProfiles(supabase, debounced)
.then((data) => {
if (!cancelled) {
setResults(data);
setSearchError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) setSearchError(translateError(err, t));
})
.finally(() => {
if (!cancelled) setSearching(false);
});
return () => {
cancelled = true;
};
}, [debounced, t]);
const friendsByPeerId = useMemo(() => {
const map = new Map<string, Friendship>();
for (const f of friendships) map.set(f.peer.userId, f);
return map;
}, [friendships]);
const accepted = useMemo(
() => friendships.filter((f) => f.status === 'accepted'),
[friendships],
);
const outgoing = useMemo(
() => friendships.filter((f) => f.status === 'pending' && f.direction === 'outgoing'),
[friendships],
);
const incoming = useMemo(
() => friendships.filter((f) => f.status === 'pending' && f.direction === 'incoming'),
[friendships],
);
const performAction = useCallback(
async (id: string, fn: () => Promise<void>) => {
setPendingId(id);
setActionError(null);
try {
await fn();
await refresh();
} catch (err: unknown) {
setActionError(translateError(err, t));
} finally {
setPendingId(null);
}
},
[refresh, t],
);
return (
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:friends.title')}
</h1>
</header>
<div className="space-y-3">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value.toLowerCase())}
placeholder={t('app:friends.search_placeholder')}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
{query.length > 0 && (
<SearchResults
query={debounced}
searching={searching}
results={results}
error={searchError}
friendsByPeerId={friendsByPeerId}
pendingId={pendingId}
onAction={performAction}
/>
)}
</div>
<div className="flex gap-2 border-b border-white/5">
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
{t('app:friends.tab_friends')} · {accepted.length}
</TabButton>
<TabButton active={tab === 'pending'} onClick={() => setTab('pending')}>
{t('app:friends.tab_pending')} · {outgoing.length}
</TabButton>
<TabButton active={tab === 'requests'} onClick={() => setTab('requests')}>
{t('app:friends.tab_requests')} · {incoming.length}
</TabButton>
</div>
{error && <Banner kind="error">{error}</Banner>}
{actionError && <Banner kind="error">{actionError}</Banner>}
{loading ? (
<LoadingRow />
) : tab === 'friends' ? (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigateToChat(id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
) : tab === 'pending' ? (
<FriendList
items={outgoing}
emptyKey="app:friends.empty_pending"
renderActions={(f) => (
<SecondaryButton
busy={pendingId === f.peer.userId}
onClick={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
>
{t('app:friends.action_cancel')}
</SecondaryButton>
)}
/>
) : (
<FriendList
items={incoming}
emptyKey="app:friends.empty_requests"
renderActions={(f) => (
<RequestActions
busy={pendingId === f.peer.userId}
onAccept={() =>
performAction(f.peer.userId, () => acceptFriendRequest(supabase, f.peer.userId))
}
onDecline={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
)}
</div>
);
}
function navigateToChat(id: string): void {
// Push history + dispatch popstate so React Router re-evaluates the route.
window.history.pushState(null, '', '/chats/' + id);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const code = extractErrorCode(err);
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
if (err instanceof Error) return err.message;
return t('errors:generic');
}
// ---------------------------------------------------------------------------
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
(active
? 'border-brand-400 text-white'
: 'border-transparent text-neutral-400 hover:text-neutral-200')
}
>
{children}
</button>
);
}
function FriendList({
items,
emptyKey,
renderActions,
}: {
items: Friendship[];
emptyKey: string;
renderActions: (f: Friendship) => React.ReactNode;
}) {
const { t } = useTranslation(['app']);
if (items.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400">
<UsersIcon className="h-5 w-5" />
</div>
<p>{t(emptyKey)}</p>
</div>
);
}
return (
<ul className="space-y-1.5">
{items.map((f) => (
<li key={f.peer.userId}>
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
</li>
))}
</ul>
);
}
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm">
<Avatar profile={profile} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
</div>
<div className="flex shrink-0 gap-2">{children}</div>
</div>
);
}
function Avatar({ profile }: { profile: ProfileBrief }) {
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter || '?'}
</div>
);
}
function FriendActions({
busy,
onMessage,
onUnfriend,
}: {
busy: boolean;
onMessage: () => void;
onUnfriend: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<>
<PrimaryButton
busy={busy}
onClick={onMessage}
icon={<ChatBubbleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_message')}
</PrimaryButton>
<DangerButton busy={busy} onClick={onUnfriend}>
{t('app:friends.action_unfriend')}
</DangerButton>
</>
);
}
function RequestActions({
busy,
onAccept,
onDecline,
}: {
busy: boolean;
onAccept: () => void;
onDecline: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<>
<PrimaryButton
busy={busy}
onClick={onAccept}
icon={<CheckCircleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_accept')}
</PrimaryButton>
<SecondaryButton busy={busy} onClick={onDecline}>
{t('app:friends.action_decline')}
</SecondaryButton>
</>
);
}
function PrimaryButton({
busy,
onClick,
icon,
children,
}: {
busy: boolean;
onClick: () => void;
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
<span>{children}</span>
</button>
);
}
function SecondaryButton({
busy,
onClick,
children,
}: {
busy: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
</button>
);
}
function DangerButton({
busy,
onClick,
children,
}: {
busy: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
</button>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-3 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
<span></span>
</div>
);
}
function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.ReactNode }) {
const isError = kind === 'error';
return (
<div
role={isError ? 'alert' : 'status'}
className={
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
(isError
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
}
>
{isError ? (
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
) : (
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
)}
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
);
}
function SearchResults({
query,
searching,
results,
error,
friendsByPeerId,
pendingId,
onAction,
}: {
query: string;
searching: boolean;
results: ProfileBrief[];
error: string | null;
friendsByPeerId: Map<string, Friendship>;
pendingId: string | null;
onAction: (id: string, fn: () => Promise<void>) => Promise<void>;
}) {
const { t } = useTranslation(['app']);
if (query.length < 2) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>;
}
if (searching) {
return (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
<span></span>
</div>
);
}
if (error) return <Banner kind="error">{error}</Banner>;
if (results.length === 0) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>;
}
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:friends.search_results_title')}
</p>
<ul className="space-y-1.5">
{results.map((p) => {
const existing = friendsByPeerId.get(p.userId);
return (
<li key={p.userId}>
<FriendRow profile={p}>
<SearchActionButton
profile={p}
existing={existing}
busy={pendingId === p.userId}
onAction={onAction}
/>
</FriendRow>
</li>
);
})}
</ul>
</div>
);
}
function SearchActionButton({
profile,
existing,
busy,
onAction,
}: {
profile: ProfileBrief;
existing: Friendship | undefined;
busy: boolean;
onAction: (id: string, fn: () => Promise<void>) => Promise<void>;
}) {
const { t } = useTranslation(['app']);
if (existing?.status === 'accepted') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
<CheckCircleIcon className="h-3.5 w-3.5" />
{t('app:friends.already_friends')}
</span>
);
}
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
{t('app:friends.request_sent')}
</span>
);
}
if (existing?.status === 'pending' && existing.direction === 'incoming') {
return (
<PrimaryButton
busy={busy}
onClick={() =>
void onAction(profile.userId, () => acceptFriendRequest(supabase, profile.userId))
}
icon={<CheckCircleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_accept')}
</PrimaryButton>
);
}
return (
<PrimaryButton
busy={busy}
onClick={() =>
void onAction(profile.userId, () => sendFriendRequest(supabase, profile.userId))
}
icon={<PlusIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.send_request')}
</PrimaryButton>
);
}
+456
View File
@@ -0,0 +1,456 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import {
changeLocale as changeLocaleI18n,
SUPPORTED_LOCALES,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import {
getPttSettings,
keyCodeToLabel,
type PttSettings,
subscribePttSettings,
updatePttSettings,
} from '../lib/pttSettings';
import {
AUDIO_QUALITY_ORDER,
type AudioQuality,
type AudioSettings,
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
type CallE2EESettings,
getCallE2EESettings,
isE2EESupported,
subscribeCallE2EESettings,
updateCallE2EESettings,
} from '../lib/callE2EE';
import {
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
type ScreenShareSettings,
subscribeScreenShareSettings,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { supabase } from '../lib/supabase';
const LOCALE_LABELS: Record<SupportedLocale, string> = {
en: 'English',
de: 'Deutsch',
};
export function SettingsPage() {
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
const { profile, device, refreshProfile, signOut } = useAuth();
const [busy, setBusy] = useState(false);
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
setBusy(true);
try {
await updateOwnProfile(supabase, patch);
await refreshProfile();
} catch (err: unknown) {
console.error('updateProfile failed', err);
} finally {
setBusy(false);
}
}
async function handleLocaleChange(locale: SupportedLocale) {
await changeLocaleI18n(locale);
void patchProfile({ locale });
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:settings.title')}
</h1>
</header>
{/* Account */}
<Section title={t('app:settings.section_account')}>
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
{/* Appearance */}
<Section title={t('app:settings.section_appearance')}>
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:text-neutral-200')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
</div>
</SettingRow>
</Section>
{/* Privacy */}
<Section title={t('app:settings.section_privacy')}>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioQualityControls />
<div className="mt-3 border-t border-white/5 pt-3">
<PttControls />
</div>
<div className="mt-3 border-t border-white/5 pt-3">
<CallE2EEControls />
</div>
</Section>
{/* Screen-share quality */}
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
<ScreenShareControls />
</Section>
{/* Devices */}
<Section title={t('app:settings.section_devices')}>
{device && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
<LockIcon className="h-4 w-4" />
{t('app:settings.this_device')}
</div>
<dl className="mt-3 space-y-1.5 text-xs">
<Row label={t('auth:signed_in.display_name')} value={device.name} />
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
</dl>
</div>
)}
</Section>
{/* Danger zone */}
<Section title={t('app:settings.danger_zone')}>
<button
type="button"
onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50"
>
{t('app:settings.sign_out')}
</button>
</Section>
</div>
);
}
function PttControls() {
const { t } = useTranslation(['app']);
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
const [capturing, setCapturing] = useState(false);
useEffect(() => {
return subscribePttSettings(setPtt);
}, []);
useEffect(() => {
if (!capturing) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code === 'Escape') {
setCapturing(false);
return;
}
updatePttSettings({ key: e.code, keyLabel: keyCodeToLabel(e.code) });
setCapturing(false);
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturing]);
return (
<>
<Toggle
label={t('app:settings.ptt_enabled', { defaultValue: 'Push-to-Talk' })}
hint={t('app:settings.ptt_enabled_hint', {
defaultValue:
'Mic bleibt stumm bis die Taste gedrückt wird. Sonst overrides der normale Mute-Button.',
})}
checked={ptt.enabled}
onChange={(v) => updatePttSettings({ enabled: v })}
/>
<SettingRow
label={t('app:settings.ptt_key', { defaultValue: 'Hotkey' })}
>
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(capturing
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
}
>
{capturing
? t('app:settings.ptt_press_key', { defaultValue: 'Taste drücken…' })
: ptt.keyLabel}
</button>
</SettingRow>
</>
);
}
function CallE2EEControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
const [supported] = useState<boolean>(() => isE2EESupported());
useEffect(() => subscribeCallE2EESettings(setCfg), []);
return (
<>
<Toggle
label={t('app:settings.e2ee_calls', { defaultValue: 'Ende-zu-Ende-Verschlüsselung (Calls)' })}
hint={
supported
? t('app:settings.e2ee_calls_hint', {
defaultValue:
'Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.',
})
: t('app:settings.e2ee_calls_unsupported', {
defaultValue:
'Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.',
})
}
checked={cfg.enabled && supported}
disabled={!supported}
onChange={(v) => updateCallE2EESettings({ enabled: v })}
/>
</>
);
}
function AudioQualityControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<AudioSettings>(() => getAudioSettings());
useEffect(() => subscribeAudioSettings(setCfg), []);
const params = getAudioQualityParams(cfg.quality);
const labels: Record<AudioQuality, string> = {
voice: t('app:settings.audio_voice', { defaultValue: 'Sprache (Empfohlen)' }),
hifi: t('app:settings.audio_hifi', { defaultValue: 'HiFi / Musik' }),
};
return (
<>
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
{AUDIO_QUALITY_ORDER.map((q) => {
const active = cfg.quality === q;
return (
<button
key={q}
type="button"
onClick={() => updateAudioSettings({ quality: q })}
className={
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:text-neutral-200')
}
>
{labels[q]}
</button>
);
})}
</div>
</SettingRow>
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
<div className="grid grid-cols-4 gap-3">
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
<Stat label="Sample" value={params.sampleRateHz / 1000 + ' kHz'} />
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
</div>
</div>
<p className="text-xs text-neutral-500">
{cfg.quality === 'hifi'
? t('app:settings.audio_hifi_hint', {
defaultValue:
'Stereo 510 kbps Opus ohne Noise-Suppression/Echo-Cancellation — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.',
})
: t('app:settings.audio_voice_hint', {
defaultValue:
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
})}
</p>
</>
);
}
function ScreenShareControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
useEffect(() => subscribeScreenShareSettings(setCfg), []);
const params = getPresetParams(cfg.preset);
return (
<>
<SettingRow label={t('app:settings.screen_share_quality', { defaultValue: 'Qualität' })}>
<select
value={cfg.preset}
onChange={(e) =>
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
}
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label}
</option>
))}
</select>
</SettingRow>
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
<div className="grid grid-cols-3 gap-3">
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
<Stat
label="Resolution"
value={params.dims ? params.dims.width + '×' + params.dims.height : 'Auto'}
/>
<Stat label="Framerate" value={params.framerate + ' fps'} />
</div>
</div>
<p className="text-xs text-neutral-500">
{t('app:settings.screen_share_hint', {
defaultValue:
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
})}
</p>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
</div>
);
}
function formatBitrate(kbps: number): string {
if (kbps >= 1000) {
return (kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1) + ' Mbps';
}
return kbps + ' kbps';
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
{title}
</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-neutral-500">{label}</dt>
<dd
className={
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' +
(mono ? 'font-mono text-xs' : '')
}
title={value}
>
{value}
</dd>
</div>
);
}
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-neutral-200">{label}</span>
{children}
</div>
);
}
function Toggle({
label,
hint,
checked,
disabled,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
</span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="peer sr-only"
/>
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
);
}