This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
};
function avatarLetter(input: string | undefined): string {
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
}
export function UserBar() {
const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline';
async function changePresence(next: PresenceState) {
if (busy || next === presence) {
setOpen(false);
return;
}
setBusy(true);
try {
await updateOwnProfile(supabase, { presenceState: next });
await refreshProfile();
} catch (err: unknown) {
console.error('updatePresence failed', err);
} finally {
setBusy(false);
setOpen(false);
}
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<div className="relative 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">
{avatarLetter(profile?.displayName ?? profile?.username)}
<span
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
PRESENCE_DOT[presence]
}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-neutral-400">
{profile?.username ? '@' + profile.username : '—'}
</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
/>
</button>
{open && (
<div
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
>
{PRESENCE_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
role="menuitemradio"
aria-checked={opt === presence}
onClick={() => void changePresence(opt)}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
>
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && (
<span className="text-xs text-brand-300"></span>
)}
</button>
))}
</div>
)}
</div>
);
}