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:
82
apps/web/src/components/CodeInput.tsx
Normal file
82
apps/web/src/components/CodeInput.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/** 6-digit code entry: big boxes, numeric keypad, paste detection, auto-submit (docs/06 §3). */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { normalizeRoomCode } from '@bg/shared/lite';
|
||||
|
||||
export function CodeInput({ onComplete, busy }: { onComplete: (code: string) => void; busy?: boolean }) {
|
||||
const [digits, setDigits] = useState('');
|
||||
const [clip, setClip] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
// Offer a big paste button if the clipboard already holds a code or invite link.
|
||||
navigator.clipboard
|
||||
?.readText?.()
|
||||
.then((t) => setClip(normalizeRoomCode(t)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const set = (v: string) => {
|
||||
const code = normalizeRoomCode(v);
|
||||
const next = code ?? v.replace(/\D/g, '').slice(0, 6);
|
||||
setDigits(next);
|
||||
if (next.length === 6) onComplete(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<label htmlFor="code-input" className="sr-only">
|
||||
방 코드 6자리
|
||||
</label>
|
||||
<div style={{ position: 'relative' }} onClick={() => inputRef.current?.focus()}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 6 }} aria-hidden>
|
||||
{[0, 1, 2, -1, 3, 4, 5].map((i, k) =>
|
||||
i < 0 ? (
|
||||
<span key={k} style={{ alignSelf: 'center', textAlign: 'center', fontSize: '1.4rem', color: 'var(--text-sub)' }}>
|
||||
·
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={k}
|
||||
style={{
|
||||
height: 64,
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${i === digits.length ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: 'var(--surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{digits[i] ?? ''}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="code-input"
|
||||
ref={inputRef}
|
||||
value={digits}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
set(e.clipboardData.getData('text'));
|
||||
}}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={40}
|
||||
disabled={busy}
|
||||
style={{ position: 'absolute', inset: 0, opacity: 0, fontSize: 16 }}
|
||||
/>
|
||||
</div>
|
||||
{clip && digits.length < 6 && (
|
||||
<button className="btn btn-soft btn-block" onClick={() => set(clip)}>
|
||||
📋 복사한 코드 {clip.slice(0, 3)} {clip.slice(3)} 붙여넣기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/NicknameForm.tsx
Normal file
60
apps/web/src/components/NicknameForm.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/** One-field start: nickname (pre-filled suggestion) + start button; Discord as secondary (docs/05 §1). */
|
||||
import { useState } from 'react';
|
||||
import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/lite';
|
||||
import { api, discordLoginUrl } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
export function NicknameForm({ next, submitLabel = '이 이름으로 시작하기', onDone }: { next: string; submitLabel?: string; onDone?: () => void }) {
|
||||
const [nick, setNick] = useState(() => suggestNickname());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const discord = useSession((s) => s.discordEnabled);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const n = normalizeNickname(nick);
|
||||
const err = nicknameError(n);
|
||||
if (err) return setError(err);
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.guest(n);
|
||||
useSession.getState().setMe(r.me);
|
||||
await useSession.getState().refresh();
|
||||
onDone?.();
|
||||
} catch (ex) {
|
||||
setError((ex as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<label className="field">
|
||||
내 이름(닉네임)
|
||||
<input
|
||||
className="input"
|
||||
value={nick}
|
||||
onChange={(e) => {
|
||||
setNick(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
maxLength={20}
|
||||
autoComplete="nickname"
|
||||
enterKeyHint="go"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
<button className="btn btn-primary btn-block" disabled={busy}>
|
||||
{busy ? '잠시만요…' : submitLabel}
|
||||
</button>
|
||||
{discord && (
|
||||
<a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}>
|
||||
디스코드로 로그인
|
||||
</a>
|
||||
)}
|
||||
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/components/QrModal.tsx
Normal file
42
apps/web/src/components/QrModal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sheet, CodeLabel } from './ui';
|
||||
|
||||
/** QR is generated lazily so the library is not in the first-page bundle. */
|
||||
export function QrModal({ url, code, onClose }: { url: string; code: string; onClose: () => void }) {
|
||||
const [cells, setCells] = useState<{ n: number; path: string } | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
import('qrcode-generator').then(({ default: qrcode }) => {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount();
|
||||
let path = '';
|
||||
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) path += `M${x} ${y}h1v1h-1z`;
|
||||
if (alive) setCells({ n, path });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url]);
|
||||
return (
|
||||
<Sheet onClose={onClose} label="QR 코드">
|
||||
<h2>휴대폰 카메라로 찍으면 들어와요</h2>
|
||||
<div style={{ background: '#fff', borderRadius: 16, padding: 12, width: 'min(80vw, 320px)', aspectRatio: '1', alignSelf: 'center' }}>
|
||||
{cells ? (
|
||||
<svg viewBox={`-2 -2 ${cells.n + 4} ${cells.n + 4}`} width="100%" height="100%" role="img" aria-label="초대 QR 코드" shapeRendering="crispEdges">
|
||||
<path d={cells.path} fill="#000" />
|
||||
</svg>
|
||||
) : (
|
||||
<p className="muted">만드는 중…</p>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ textAlign: 'center', fontSize: '1.6rem', fontWeight: 700 }}>
|
||||
코드 <CodeLabel code={code} />
|
||||
</p>
|
||||
<button className="btn btn-secondary btn-block" onClick={onClose}>
|
||||
닫기
|
||||
</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/components/TurnTimer.tsx
Normal file
44
apps/web/src/components/TurnTimer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/** Ring countdown to a server deadline (docs/07 §4). */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { socket } from '../net/socket';
|
||||
import { haptic, sounds } from '../lib/feedback';
|
||||
|
||||
export function useServerNow(intervalMs = 250): number {
|
||||
const [now, setNow] = useState(() => socket.now());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(socket.now()), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function TurnTimer({ deadline, totalMs, mine, size = 44 }: { deadline: number | null; totalMs: number; mine: boolean; size?: number }) {
|
||||
const now = useServerNow();
|
||||
const warned = useRef<number | null>(null);
|
||||
const left = deadline ? Math.max(0, deadline - now) : null;
|
||||
const sec = left === null ? null : Math.ceil(left / 1000);
|
||||
useEffect(() => {
|
||||
if (!mine || sec === null) return;
|
||||
if ((sec === 10 || sec === 5) && warned.current !== sec) {
|
||||
warned.current = sec;
|
||||
sounds.warn();
|
||||
if (sec === 5) haptic([60, 40, 60]);
|
||||
}
|
||||
}, [sec, mine]);
|
||||
if (left === null) return null;
|
||||
const frac = Math.min(1, left / Math.max(1, totalMs));
|
||||
const color = sec! <= 5 ? 'var(--danger)' : sec! <= 10 ? 'var(--warning)' : 'var(--success)';
|
||||
const r = size / 2 - 4;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<span style={{ position: 'relative', width: size, height: size, display: 'inline-flex' }} role="timer" aria-label={`남은 시간 ${sec}초`}>
|
||||
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }} aria-hidden>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-2)" strokeWidth={4} />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={4} strokeDasharray={c} strokeDashoffset={c * (1 - frac)} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: size > 40 ? '0.95rem' : '0.8rem', color }}>
|
||||
{sec! >= 600 ? `${Math.floor(sec! / 60)}분` : sec! >= 100 ? `${Math.floor(sec! / 60)}:${String(sec! % 60).padStart(2, '0')}` : sec}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/ui.tsx
Normal file
163
apps/web/src/components/ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user