M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy

- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장),
  링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정),
  오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내),
  결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동)
- WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리,
  ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리
- @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip
- e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더
- deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example
- 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:37:33 +09:00
parent b1f3d7c500
commit 17b1d0fcda
46 changed files with 3590 additions and 90 deletions

View File

@@ -0,0 +1,163 @@
/** Shared UI primitives (docs/07 §4). */
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { PublicUser } from '@bg/shared/lite';
import { formatRoomCode } from '@bg/shared/lite';
import { useToasts, toast } from '../store/toast';
import { useRoom } from '../net/socket';
import { haptic } from '../lib/feedback';
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" /> : [...user.nickname][0]}
</span>
);
}
export function CopyButton({ text, label, done = '복사했어요 ✓', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: string; 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);
if (!code) return null;
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 (
<header className="topbar">
{onBack && (
<button className="icon-btn" onClick={onBack} aria-label="뒤로">
←<span className="lbl">뒤로</span>
</button>
)}
<div className="topbar-title">{title}</div>
{right}
</header>
);
}