- 휴대폰에서는 아이콘 아래 짧은 글자(홈/링크/채팅/규칙/설정/나가기)로 쌓아 320px 한 줄에 맞춘다. - 방장 전용 '게임 끝내기(무효)'는 오류로 멈춘 게임일 때만 막대에 나온다. - CopyButton의 done에 글자 대신 요소도 넣을 수 있게. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
/** 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<PublicUser, 'id' | 'nickname' | 'avatar'>; 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 (
|
|
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
|
|
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<button type="button" className={className} onClick={onClick} aria-live="polite">
|
|
{copied ? done : label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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 <CopyButton text={`${text}\n${url}`} label="💬 초대글 복사" className={className} />;
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={className}
|
|
onClick={() => navigator.share({ title, text, url }).catch(() => {})}
|
|
>
|
|
📤 공유하기
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function Sheet({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) {
|
|
const ref = useRef<HTMLDivElement>(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 (
|
|
<div className="sheet-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
<div className="sheet" role="dialog" aria-modal="true" aria-label={label} tabIndex={-1} ref={ref}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Sheet onClose={onClose} label={title}>
|
|
<h2>{title}</h2>
|
|
{body && <div className="muted">{body}</div>}
|
|
<div className="stack">
|
|
<button
|
|
className={`btn btn-block ${danger ? 'btn-danger' : 'btn-primary'}`}
|
|
onClick={() => {
|
|
onConfirm();
|
|
onClose();
|
|
}}
|
|
>
|
|
{confirm}
|
|
</button>
|
|
<button className="btn btn-block btn-secondary" onClick={onClose}>
|
|
{cancel}
|
|
</button>
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
export function Toasts() {
|
|
const toasts = useToasts((s) => s.toasts);
|
|
return (
|
|
<div className="toasts" role="status" aria-live="polite">
|
|
{toasts.map((t) => (
|
|
<div key={t.id} className={`toast ${t.kind === 'error' ? 'error' : ''}`}>
|
|
{t.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ConnectionBanner() {
|
|
const status = useRoom((s) => s.status);
|
|
const code = useRoom((s) => s.code);
|
|
const online = useOnline();
|
|
if (!code) return online ? null : <div className="conn-banner" role="status">인터넷 연결이 없어요. 연결되면 다시 쓸 수 있어요.</div>;
|
|
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
|
|
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
|
|
return null;
|
|
}
|
|
|
|
export function CodeLabel({ code }: { code: string }) {
|
|
return <span style={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '0.08em' }}>{formatRoomCode(code)}</span>;
|
|
}
|
|
|
|
export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) {
|
|
return (
|
|
<nav className="topbar" aria-label="페이지 메뉴">
|
|
{onBack && (
|
|
<button className="icon-btn" onClick={onBack} aria-label="뒤로">
|
|
←<span className="lbl">뒤로</span>
|
|
</button>
|
|
)}
|
|
<div className="topbar-title">{title}</div>
|
|
{right}
|
|
</nav>
|
|
);
|
|
}
|