'use client'; import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { ChevronDown, LogIn, LogOut, Settings, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from './AtprotoSessionProvider'; import ScopeSelector from './oauth/ScopeSelector'; import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; import { encodeRepo } from '@/utils/atproto/urls'; type Variant = 'compact' | 'inline' | 'pill'; /** * Session control for the nav. Renders a "Sign in" button when signed out * and an avatar pill (with a small menu) when signed in. * * Three visual variants to fit the different Header modes: * - 'compact' — fits the universal-link compact header card * - 'inline' — fits the simple / default header's centered nav row * - 'pill' — standalone (used in account-page header context) */ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant }) { const { session, did, signIn, signOut, loading } = useAtprotoSession(); const [open, setOpen] = useState(false); const [signInInput, setSignInInput] = useState(''); const [step, setStep] = useState<'handle' | 'scopes'>('handle'); const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [profile, setProfile] = useState(null); const rootRef = useRef(null); function closePopover() { setOpen(false); setStep('handle'); setPendingAccount(''); setBusy(false); setError(null); } // Close on outside click. useEffect(() => { if (!open) return; function onClick(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { closePopover(); } } document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [open]); // Lazy-load profile so the menu can show avatar + display name. useEffect(() => { if (!did) { setProfile(null); return undefined; } let cancelled = false; getProfile(did).then((p) => { if (!cancelled) setProfile(p); }); return () => { cancelled = true; }; }, [did]); if (loading) return null; // ─── Signed out ───────────────────────────────────────────────────────── if (!session) { return (
{open && step === 'handle' && ( { const v = signInInput.trim(); if (!v) return; setError(null); setPendingAccount(v); setStep('scopes'); }} /> )} {open && step === 'scopes' && (
{ setStep('handle'); setError(null); }} onContinue={async (scopeString) => { setBusy(true); setError(null); try { await signIn(pendingAccount, scopeString); } catch (err) { setBusy(false); setError(err instanceof Error ? err.message : String(err)); } }} />
)}
); } // ─── Signed in ────────────────────────────────────────────────────────── const handle = profile?.handle || did?.slice(0, 24); const displayName = profile?.displayName?.trim() || handle; const avatar = profile?.avatar; return (
{open && did && (
{displayName}
@{handle}
} label="My repo in Explorer" onClick={() => setOpen(false)} /> } label="Account settings" onClick={() => setOpen(false)} />
)}
); } // ─── Sub-components ──────────────────────────────────────────────────────── function SignInPopover({ value, onChange, busy, error, onSubmit, }: { value: string; onChange: (v: string) => void; busy: boolean; error: string | null; onSubmit: () => void; }) { return (
Sign in with your atproto handle
We’ll ask which permissions to grant before redirecting.
{ e.preventDefault(); onSubmit(); }} style={{ padding: '0.625rem 0.75rem 0.75rem' }} > onChange(e.target.value)} disabled={busy} style={{ width: '100%', padding: '0.5rem 0.625rem', background: 'var(--bg-tertiary)', border: '1px solid var(--border-medium)', color: 'var(--text-primary)', fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', outline: 'none', }} /> {error && (

{error}

)}
); } function MenuLink({ href, icon, label, onClick, }: { href: string; icon: React.ReactNode; label: string; onClick: () => void; }) { return ( {icon} {label} ); } // ─── Style helpers ───────────────────────────────────────────────────────── function triggerButtonStyle(variant: Variant): React.CSSProperties { const base: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: '0.375rem', background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', color: 'var(--text-secondary)', fontFamily: 'var(--font-serif)', cursor: 'pointer', transition: 'border-color 0.2s ease, color 0.2s ease', }; if (variant === 'inline') { return { ...base, padding: '0.5rem 0.875rem', fontSize: '0.95rem' }; } return { ...base, padding: '0.35rem 0.625rem', fontSize: '0.8125rem' }; } function pillStyle(variant: Variant): React.CSSProperties { const base: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: '0.4rem', padding: variant === 'inline' ? '0.4rem 0.625rem' : '0.3rem 0.5rem', background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', color: 'var(--text-secondary)', fontFamily: 'var(--font-serif)', fontSize: variant === 'inline' ? '0.9rem' : '0.8125rem', cursor: 'pointer', transition: 'border-color 0.2s ease, color 0.2s ease', }; return base; } function menuStyle(extra?: React.CSSProperties): React.CSSProperties { return { position: 'absolute', top: 'calc(100% + 0.5rem)', right: 0, minWidth: '14rem', background: 'var(--bg-secondary)', border: '1px solid var(--border-medium)', boxShadow: 'var(--shadow-overlay)', zIndex: 60, display: 'flex', flexDirection: 'column', ...extra, }; } function menuHeaderStyle(): React.CSSProperties { return { padding: '0.625rem 0.75rem', borderBottom: '1px solid var(--border-subtle)', }; } function menuItemStyle({ danger }: { danger?: boolean } = {}): React.CSSProperties { return { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'transparent', border: 0, color: danger ? 'var(--danger)' : 'var(--text-secondary)', fontFamily: 'var(--font-serif)', fontSize: '0.8125rem', cursor: 'pointer', textDecoration: 'none', textAlign: 'left', }; }