/** Shared UI primitives (docs/07 Β§4). */ import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { PublicUser } from '@bg/shared/lite'; import { formatRoomCode, isBotId } from '@bg/shared/lite'; import { useToasts, toast } from '../store/toast'; import { useRoom } from '../net/socket'; import { haptic } from '../lib/feedback'; import { useOnline } from '../lib/pwa'; const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626']; export function Avatar({ user, size = 40 }: { user: Pick; size?: number }) { let h = 0; for (const ch of user.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0; const bg = AVATAR_COLORS[h % AVATAR_COLORS.length]; return ( {user.avatar ? : isBotId(user.id) ? 'πŸ€–' : [...user.nickname][0]} ); } export function CopyButton({ text, label, done = 'λ³΅μ‚¬ν–ˆμ–΄μš” βœ“', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: ReactNode; className?: string }) { const [copied, setCopied] = useState(false); const onClick = async () => { try { await navigator.clipboard.writeText(text); } catch { // Fallback for old browsers / insecure contexts. const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); if (!ok) { toast(`볡사가 μ•ˆ λΌμš”. 직접 적어 μ£Όμ„Έμš”: ${text}`, 'error', 6000); return; } } haptic(30); setCopied(true); setTimeout(() => setCopied(false), 1500); }; return ( ); } export function ShareButton({ title, text, url, className = 'btn btn-secondary' }: { title: string; text: string; url: string; className?: string }) { const canShare = typeof navigator !== 'undefined' && 'share' in navigator; if (!canShare) return ; return ( ); } export function Sheet({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) { const ref = useRef(null); useEffect(() => { const prev = document.activeElement as HTMLElement | null; ref.current?.focus(); const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); prev?.focus?.(); }; }, [onClose]); return (
e.target === e.currentTarget && onClose()}>
{children}
); } export function ConfirmSheet({ title, body, confirm, cancel = 'μ·¨μ†Œ', danger, onConfirm, onClose, }: { title: string; body?: ReactNode; confirm: string; cancel?: string; danger?: boolean; onConfirm: () => void; onClose: () => void; }) { return (

{title}

{body &&
{body}
}
); } export function Toasts() { const toasts = useToasts((s) => s.toasts); return (
{toasts.map((t) => (
{t.text}
))}
); } export function ConnectionBanner() { const status = useRoom((s) => s.status); const code = useRoom((s) => s.code); const online = useOnline(); if (!code) return online ? null :
인터넷 연결이 μ—†μ–΄μš”. μ—°κ²°λ˜λ©΄ λ‹€μ‹œ μ“Έ 수 μžˆμ–΄μš”.
; if (status === 'reconnecting' || status === 'connecting') return
λ‹€μ‹œ μ—°κ²°ν•˜λŠ” 쀑…
; if (status === 'offline') return
인터넷 연결이 λŠκ²Όμ–΄μš”. μ—°κ²°λ˜λ©΄ μžλ™μœΌλ‘œ μ΄μ–΄μ Έμš”.
; return null; } export function CodeLabel({ code }: { code: string }) { return {formatRoomCode(code)}; } export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) { return ( ); }