initial
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user