initial
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import { updateOwnProfile } from '@chat-app/shared/auth';
|
||||
import {
|
||||
changeLocale as changeLocaleI18n,
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from '@chat-app/shared/i18n';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
getPttSettings,
|
||||
keyCodeToLabel,
|
||||
type PttSettings,
|
||||
subscribePttSettings,
|
||||
updatePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
AUDIO_QUALITY_ORDER,
|
||||
type AudioQuality,
|
||||
type AudioSettings,
|
||||
getAudioQualityParams,
|
||||
getAudioSettings,
|
||||
subscribeAudioSettings,
|
||||
updateAudioSettings,
|
||||
} from '../lib/audioSettings';
|
||||
import {
|
||||
type CallE2EESettings,
|
||||
getCallE2EESettings,
|
||||
isE2EESupported,
|
||||
subscribeCallE2EESettings,
|
||||
updateCallE2EESettings,
|
||||
} from '../lib/callE2EE';
|
||||
import {
|
||||
getPresetParams,
|
||||
getScreenShareSettings,
|
||||
PRESET_ORDER,
|
||||
type ScreenSharePreset,
|
||||
type ScreenShareSettings,
|
||||
subscribeScreenShareSettings,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
};
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
|
||||
const { profile, device, refreshProfile, signOut } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateOwnProfile(supabase, patch);
|
||||
await refreshProfile();
|
||||
} catch (err: unknown) {
|
||||
console.error('updateProfile failed', err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLocaleChange(locale: SupportedLocale) {
|
||||
await changeLocaleI18n(locale);
|
||||
void patchProfile({ locale });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('app:settings.section_account')}>
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(active
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Privacy */}
|
||||
<Section title={t('app:settings.section_privacy')}>
|
||||
<Toggle
|
||||
label={t('app:settings.show_read_receipts')}
|
||||
hint={t('app:settings.show_read_receipts_hint')}
|
||||
checked={profile?.showReadReceipts ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t('app:settings.allow_dms_strangers')}
|
||||
hint={t('app:settings.allow_dms_strangers_hint')}
|
||||
checked={profile?.allowDmsFromStrangers ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioQualityControls />
|
||||
<div className="mt-3 border-t border-white/5 pt-3">
|
||||
<PttControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-white/5 pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Screen-share quality */}
|
||||
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
|
||||
{/* Devices */}
|
||||
<Section title={t('app:settings.section_devices')}>
|
||||
{device && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{t('app:settings.this_device')}
|
||||
</div>
|
||||
<dl className="mt-3 space-y-1.5 text-xs">
|
||||
<Row label={t('auth:signed_in.display_name')} value={device.name} />
|
||||
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
|
||||
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<Section title={t('app:settings.danger_zone')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50"
|
||||
>
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PttControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribePttSettings(setPtt);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturing) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.code === 'Escape') {
|
||||
setCapturing(false);
|
||||
return;
|
||||
}
|
||||
updatePttSettings({ key: e.code, keyLabel: keyCodeToLabel(e.code) });
|
||||
setCapturing(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey, { capture: true });
|
||||
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||
}, [capturing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toggle
|
||||
label={t('app:settings.ptt_enabled', { defaultValue: 'Push-to-Talk' })}
|
||||
hint={t('app:settings.ptt_enabled_hint', {
|
||||
defaultValue:
|
||||
'Mic bleibt stumm bis die Taste gedrückt wird. Sonst overrides der normale Mute-Button.',
|
||||
})}
|
||||
checked={ptt.enabled}
|
||||
onChange={(v) => updatePttSettings({ enabled: v })}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('app:settings.ptt_key', { defaultValue: 'Hotkey' })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
className={
|
||||
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(capturing
|
||||
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
|
||||
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
? t('app:settings.ptt_press_key', { defaultValue: 'Taste drücken…' })
|
||||
: ptt.keyLabel}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CallE2EEControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
|
||||
const [supported] = useState<boolean>(() => isE2EESupported());
|
||||
|
||||
useEffect(() => subscribeCallE2EESettings(setCfg), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toggle
|
||||
label={t('app:settings.e2ee_calls', { defaultValue: 'Ende-zu-Ende-Verschlüsselung (Calls)' })}
|
||||
hint={
|
||||
supported
|
||||
? t('app:settings.e2ee_calls_hint', {
|
||||
defaultValue:
|
||||
'Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.',
|
||||
})
|
||||
: t('app:settings.e2ee_calls_unsupported', {
|
||||
defaultValue:
|
||||
'Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.',
|
||||
})
|
||||
}
|
||||
checked={cfg.enabled && supported}
|
||||
disabled={!supported}
|
||||
onChange={(v) => updateCallE2EESettings({ enabled: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioQualityControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<AudioSettings>(() => getAudioSettings());
|
||||
|
||||
useEffect(() => subscribeAudioSettings(setCfg), []);
|
||||
|
||||
const params = getAudioQualityParams(cfg.quality);
|
||||
const labels: Record<AudioQuality, string> = {
|
||||
voice: t('app:settings.audio_voice', { defaultValue: 'Sprache (Empfohlen)' }),
|
||||
hifi: t('app:settings.audio_hifi', { defaultValue: 'HiFi / Musik' }),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
||||
{AUDIO_QUALITY_ORDER.map((q) => {
|
||||
const active = cfg.quality === q;
|
||||
return (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
onClick={() => updateAudioSettings({ quality: q })}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(active
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
}
|
||||
>
|
||||
{labels[q]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
|
||||
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
|
||||
<Stat label="Sample" value={params.sampleRateHz / 1000 + ' kHz'} />
|
||||
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{cfg.quality === 'hifi'
|
||||
? t('app:settings.audio_hifi_hint', {
|
||||
defaultValue:
|
||||
'Stereo 510 kbps Opus ohne Noise-Suppression/Echo-Cancellation — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.',
|
||||
})
|
||||
: t('app:settings.audio_voice_hint', {
|
||||
defaultValue:
|
||||
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenShareControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
|
||||
|
||||
useEffect(() => subscribeScreenShareSettings(setCfg), []);
|
||||
|
||||
const params = getPresetParams(cfg.preset);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow label={t('app:settings.screen_share_quality', { defaultValue: 'Qualität' })}>
|
||||
<select
|
||||
value={cfg.preset}
|
||||
onChange={(e) =>
|
||||
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
|
||||
}
|
||||
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{getPresetParams(p).label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
|
||||
<Stat
|
||||
label="Resolution"
|
||||
value={params.dims ? params.dims.width + '×' + params.dims.height : 'Auto'}
|
||||
/>
|
||||
<Stat label="Framerate" value={params.framerate + ' fps'} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('app:settings.screen_share_hint', {
|
||||
defaultValue:
|
||||
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
|
||||
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBitrate(kbps: number): string {
|
||||
if (kbps >= 1000) {
|
||||
return (kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1) + ' Mbps';
|
||||
}
|
||||
return kbps + ' kbps';
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-neutral-500">{label}</dt>
|
||||
<dd
|
||||
className={
|
||||
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' +
|
||||
(mono ? 'font-mono text-xs' : '')
|
||||
}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-neutral-200">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||||
</span>
|
||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user