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:
19
apps/web/index.html
Normal file
19
apps/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#F7F5F0" />
|
||||
<meta name="description" content="광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임" />
|
||||
<meta property="og:title" content="같이 놀자 보드게임" />
|
||||
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>같이 놀자 보드게임</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>이 사이트를 쓰려면 자바스크립트를 켜 주세요.</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,11 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bg/shared": "workspace:*",
|
||||
"pretendard": "1.3.9",
|
||||
"qrcode-generator": "2.0.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-router": "8.4.0",
|
||||
"zustand": "5.0.15"
|
||||
"zustand": "5.0.15",
|
||||
"@bg/games": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
|
||||
1
apps/web/public/favicon.svg
Normal file
1
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
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>
|
||||
);
|
||||
}
|
||||
431
apps/web/src/games/omok/index.tsx
Normal file
431
apps/web/src/games/omok/index.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/** 오목 화면 (docs/games/omok.md §9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { OmokOptions, OmokView, Pt } from '@bg/games/omok';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
const COLS = 'ABCDEFGHIJKLMNOPQRS';
|
||||
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
|
||||
const RULE_KO: Record<OmokOptions['ruleSet'], string> = { korean: '한국식', renju: '렌주룰', free: '자유룰' };
|
||||
|
||||
function stars(size: number): Pt[] {
|
||||
const s = size === 19 ? [3, 9, 15] : [3, 7, 11];
|
||||
return s.flatMap((x) => s.map((y) => ({ x, y })));
|
||||
}
|
||||
|
||||
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
|
||||
return (
|
||||
<g opacity={ghost ? 0.55 : 1}>
|
||||
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.06} />
|
||||
{color === 'black' ? (
|
||||
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
|
||||
) : (
|
||||
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps<OmokView>) {
|
||||
const n = v.size;
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) / 2, y: (n - 1) / 2 });
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
const prevTurn = useRef(v.turn);
|
||||
|
||||
const forbidden = useMemo(() => new Set(v.forbidden.map((p) => p.y * n + p.x)), [v.forbidden, n]);
|
||||
|
||||
// Sounds, vibration and screen-reader announcements on new moves.
|
||||
useEffect(() => {
|
||||
if (v.moveCount > prevCount.current && v.lastMove) {
|
||||
sounds.stone();
|
||||
const who = v.moves[v.moves.length - 1]!.color;
|
||||
setAnnounce(`${COLOR_KO[who]}이 ${label(n, v.lastMove)}에 두었어요.`);
|
||||
}
|
||||
if (v.phase === 'playing' && myTurn && (prevTurn.current !== v.turn || v.moveCount === 0) && v.moveCount !== prevCount.current) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
prevCount.current = v.moveCount;
|
||||
prevTurn.current = v.turn;
|
||||
setPreview(null);
|
||||
}, [v.moveCount, v.turn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canPlace = (p: Pt) =>
|
||||
myTurn && !pending && v.board[p.y * n + p.x] === 0 && !forbidden.has(p.y * n + p.x) && (!v.firstMoveCenter || v.moveCount > 0 || (p.x === (n - 1) / 2 && p.y === (n - 1) / 2));
|
||||
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const tap = (p: Pt) => {
|
||||
setCursor(p);
|
||||
if (!myTurn) return;
|
||||
if (v.board[p.y * n + p.x] !== 0) return;
|
||||
if (forbidden.has(p.y * n + p.x)) {
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce('여기는 금수 자리라 둘 수 없어요.');
|
||||
}
|
||||
if (!tapConfirm) {
|
||||
place(p);
|
||||
return;
|
||||
}
|
||||
if (preview && preview.x === p.x && preview.y === p.y) place(p);
|
||||
else setPreview(p);
|
||||
};
|
||||
|
||||
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
const x = Math.round(loc.x);
|
||||
const y = Math.round(loc.y);
|
||||
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (canPlace(cursor)) place(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x));
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
|
||||
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
|
||||
const opp: Color | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null;
|
||||
|
||||
const pad = 0.6;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
<PlayerBar view={v} color={opp ?? 'white'} players={players} deadline={deadline} me={me} />
|
||||
<div className="board-wrap">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default' }}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p);
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||
{v.forbidden.map((p) => (
|
||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{v.board.map((c, i) =>
|
||||
c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />,
|
||||
)}
|
||||
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
|
||||
{[...winSet].map((i) => (
|
||||
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
|
||||
))}
|
||||
{preview && v.myColor && <Stone color={v.myColor} cx={preview.x} cy={preview.y} r={0.46} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} className="kbd-cursor" />}
|
||||
</svg>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{forbidden.has(preview.y * n + preview.x) ? '여기는 둘 수 없어요' : `${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlayerBar view={v} color={v.myColor ?? 'black'} players={players} deadline={deadline} me={me} />
|
||||
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` (${undoLeft}번 남음)` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부 제안
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<span>상대의 답을 기다리는 중이에요…</span>
|
||||
<button className="btn btn-ghost" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">{incoming === 'undo' ? '수락하면 돌이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && (
|
||||
<ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(500);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (tc.kind === 'fischer') {
|
||||
const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0));
|
||||
const s = Math.ceil(ms / 1000);
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000;
|
||||
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="card row"
|
||||
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
|
||||
>
|
||||
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
|
||||
<Stone color={color} cx={0} cy={0} r={0.9} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">{COLOR_KO[color]}{active ? ' · 두는 중' : ''}</div>
|
||||
</div>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: OmokView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
|
||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||
if (v.myColor === v.turn) {
|
||||
if (v.moveCount === 0 && v.firstMoveCenter) return '내 차례예요. 첫 수는 가운데에 둬요';
|
||||
return `내 차례예요 (${COLOR_KO[v.turn]}). 놓을 곳을 눌러 주세요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, Partial<OmokOptions['timeControl']>][] = [
|
||||
['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }],
|
||||
['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }],
|
||||
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||
['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: OmokOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||
return `f${Math.round(tc.baseSec / 60)}`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<OmokOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>규칙</strong>
|
||||
<Seg value={o.ruleSet} options={[['korean', '한국식(쌍삼 금지)'], ['renju', '렌주룰'], ['free', '자유룰']]} onChange={(ruleSet) => set({ ruleSet })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={timeKey(o.timeControl)}
|
||||
options={TIME_PRESETS.map(([k, l]) => [k, l] as [string, string])}
|
||||
onChange={(k) => set({ timeControl: { ...o.timeControl, ...TIME_PRESETS.find((p) => p[0] === k)![2] } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>흑(먼저 두는 쪽)</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['random', '무작위'], ['hostBlack', '방장이 흑'], ['hostWhite', '방장이 백'], ['alternate', '판마다 번갈아']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>판 크기</strong>
|
||||
<Seg value={o.boardSize} options={[[15, '15줄'], [19, '19줄']]} onChange={(boardSize) => set({ boardSize })} />
|
||||
</div>
|
||||
{o.ruleSet === 'free' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>자유룰 승리</strong>
|
||||
<Seg value={o.freeWin} options={[['fivePlus', '5목 이상'], ['exactFive', '정확히 5목']]} onChange={(freeWin) => set({ freeWin })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
{o.timeControl.kind === 'perMove' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간이 다 되면</strong>
|
||||
<Seg value={o.timeoutPolicy} options={[['autoMove', '자동으로 두기'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>첫 수 가운데</strong>
|
||||
<Seg value={o.firstMoveCenter} options={[['auto', '렌주만'], ['on', '항상'], ['off', '안 함']]} onChange={(firstMoveCenter) => set({ firstMoveCenter })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>도움 표시</strong>
|
||||
<Seg value={o.showForbidden ? 'on' : 'off'} options={[['on', '금수 자리 표시'], ['off', '금수 표시 안 함']]} onChange={(k) => set({ showForbidden: k === 'on' })} />
|
||||
<Seg value={o.hints} options={[['warn', '위험한 자리 알려 주기'], ['off', '알려 주지 않기']]} onChange={(hints) => set({ hints })} />
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: OmokOptions): string {
|
||||
const t = TIME_PRESETS.find((p) => p[0] === timeKey(o.timeControl))?.[1] ?? '';
|
||||
return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: OmokOptions }) {
|
||||
const rule = options?.ruleSet ?? 'korean';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>흑과 백이 번갈아 한 수씩 판의 빈 교차점에 돌을 놓아요. 가로, 세로, 대각선 중 한 줄로 내 돌 <strong>5개</strong>를 먼저 이으면 이겨요. 흑이 먼저 둬요.</p>
|
||||
{rule === 'korean' && (
|
||||
<p>
|
||||
<strong>한국식</strong>: 흑과 백 모두 <strong>쌍삼(열린 3을 두 개 동시에 만드는 수)</strong>을 둘 수 없어요. 6개 이상 이어지면 이긴 것이 아니에요.
|
||||
</p>
|
||||
)}
|
||||
{rule === 'renju' && (
|
||||
<p>
|
||||
<strong>렌주룰</strong>: 흑만 쌍삼, 사사(4를 두 개 동시에), 6목 이상을 둘 수 없어요. 백은 제한이 없고 6목 이상도 이겨요. 첫 수는 가운데에 둬요.
|
||||
</p>
|
||||
)}
|
||||
{rule === 'free' && (
|
||||
<p>
|
||||
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
|
||||
</p>
|
||||
)}
|
||||
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).</p>
|
||||
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<OmokView, OmokOptions> = {
|
||||
Board: OmokBoard,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
22
apps/web/src/games/registry.ts
Normal file
22
apps/web/src/games/registry.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GameUI } from './types';
|
||||
|
||||
/** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */
|
||||
export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
omok: () => import('./omok'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
export async function loadGameUI(id: string): Promise<GameUI | null> {
|
||||
const hit = cache.get(id);
|
||||
if (hit) return hit;
|
||||
const loader = GAME_UI[id];
|
||||
if (!loader) return null;
|
||||
const mod = (await loader()).default;
|
||||
cache.set(id, mod);
|
||||
return mod;
|
||||
}
|
||||
|
||||
export function cachedGameUI(id: string): GameUI | null {
|
||||
return cache.get(id) ?? null;
|
||||
}
|
||||
27
apps/web/src/games/types.ts
Normal file
27
apps/web/src/games/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
export interface BoardProps<V = unknown> {
|
||||
view: V;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
deadline: number | null;
|
||||
active: string[];
|
||||
tapConfirm: boolean;
|
||||
}
|
||||
|
||||
export interface OptionsProps<O = unknown> {
|
||||
value: O;
|
||||
onChange: (next: O) => void;
|
||||
}
|
||||
|
||||
export interface GameUI<V = any, O = any> {
|
||||
Board: ComponentType<BoardProps<V>>;
|
||||
statusText(view: V, me: string | null, players: Record<string, PublicUser>): string;
|
||||
isMyTurn(view: V, me: string | null): boolean;
|
||||
Options: ComponentType<OptionsProps<O>>;
|
||||
optionsSummary(options: O): string;
|
||||
Rules: ComponentType<{ options?: O }>;
|
||||
}
|
||||
13
apps/web/src/lib/catalog-ui.ts
Normal file
13
apps/web/src/lib/catalog-ui.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const GAME_ICON: Record<string, string> = {
|
||||
omok: '⚫',
|
||||
baduk: '⚪',
|
||||
chess: '♟️',
|
||||
janggi: '🀄',
|
||||
seotda: '🎴',
|
||||
poker: '🃏',
|
||||
mahjong: '🀇',
|
||||
yut: '🪵',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['체스', '바둑', '장기', '오셀로', '섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
48
apps/web/src/lib/feedback.ts
Normal file
48
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Tiny synthesized sounds + vibration (no audio files; docs/07 §6). */
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function audio(): AudioContext | null {
|
||||
if (!useSession.getState().settings.sound) return null;
|
||||
try {
|
||||
ctx ??= new AudioContext();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tone(freq: number, ms: number, type: OscillatorType = 'sine', gain = 0.15, delay = 0): void {
|
||||
const a = audio();
|
||||
if (!a) return;
|
||||
const t = a.currentTime + delay / 1000;
|
||||
const o = a.createOscillator();
|
||||
const g = a.createGain();
|
||||
o.type = type;
|
||||
o.frequency.value = freq;
|
||||
g.gain.setValueAtTime(gain, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000);
|
||||
o.connect(g).connect(a.destination);
|
||||
o.start(t);
|
||||
o.stop(t + ms / 1000 + 0.02);
|
||||
}
|
||||
|
||||
export const sounds = {
|
||||
stone: () => tone(900, 70, 'triangle', 0.25),
|
||||
myTurn: () => {
|
||||
tone(660, 120);
|
||||
tone(880, 160, 'sine', 0.15, 120);
|
||||
},
|
||||
warn: () => tone(520, 90, 'square', 0.06),
|
||||
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)),
|
||||
lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)),
|
||||
};
|
||||
|
||||
export function haptic(pattern: number | number[]): void {
|
||||
if (!useSession.getState().settings.vibrate) return;
|
||||
try {
|
||||
navigator.vibrate?.(pattern);
|
||||
} catch {}
|
||||
}
|
||||
64
apps/web/src/main.tsx
Normal file
64
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { StrictMode, Suspense, lazy, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router';
|
||||
import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css';
|
||||
import './styles/global.css';
|
||||
import { ConnectionBanner, Toasts } from './components/ui';
|
||||
import { useSession } from './store/session';
|
||||
import { toast } from './store/toast';
|
||||
import { Home } from './pages/Home';
|
||||
|
||||
const JoinPage = lazy(() => import('./pages/JoinPage'));
|
||||
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
||||
const GamePage = lazy(() => import('./pages/GamePage'));
|
||||
const MePage = lazy(() => import('./pages/MePage'));
|
||||
const RulesPage = lazy(() => import('./pages/RulesPage'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
|
||||
function Shell() {
|
||||
const loaded = useSession((s) => s.loaded);
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
useEffect(() => {
|
||||
void useSession.getState().refresh();
|
||||
const onUnauthorized = () => void useSession.getState().refresh();
|
||||
window.addEventListener('bg:unauthorized', onUnauthorized);
|
||||
return () => window.removeEventListener('bg:unauthorized', onUnauthorized);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(loc.search);
|
||||
if (q.get('linked') === '1') toast('디스코드 계정과 연결했어요.');
|
||||
if (q.get('login') === 'failed') toast('디스코드 로그인에 실패했어요. 다시 시도해 주세요.', 'error');
|
||||
if (q.has('linked') || q.has('login')) {
|
||||
q.delete('linked');
|
||||
q.delete('login');
|
||||
nav({ pathname: loc.pathname, search: q.toString() }, { replace: true });
|
||||
}
|
||||
}, [loc.search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!loaded) return <div className="page muted">불러오는 중…</div>;
|
||||
return (
|
||||
<>
|
||||
<ConnectionBanner />
|
||||
<Toasts />
|
||||
<Suspense fallback={<div className="page muted">불러오는 중…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/j" element={<JoinPage />} />
|
||||
<Route path="/r/:code" element={<RoomPage />} />
|
||||
<Route path="/g/:gameId" element={<GamePage />} />
|
||||
<Route path="/rules/:gameId" element={<RulesPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<Shell />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
72
apps/web/src/net/api.ts
Normal file
72
apps/web/src/net/api.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError('인터넷 연결을 확인해 주세요.', 0);
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
|
||||
if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
fontScale?: 'normal' | 'large' | 'xlarge';
|
||||
theme?: 'auto' | 'light' | 'dark';
|
||||
sound?: boolean;
|
||||
vibrate?: boolean;
|
||||
colorBlind?: boolean;
|
||||
tapConfirm?: boolean;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
me: PublicUser | null;
|
||||
settings?: Settings;
|
||||
useAvatar?: boolean;
|
||||
hasDiscordAvatar?: boolean;
|
||||
activeRoom?: string | null;
|
||||
}
|
||||
|
||||
export interface RoomPreview {
|
||||
code: string;
|
||||
codeLabel: string;
|
||||
gameId: string;
|
||||
host: string;
|
||||
seated: number;
|
||||
maxPlayers: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: () => call<{ discord: boolean; catalog: CatalogEntry[] }>('GET', '/api/config'),
|
||||
me: () => call<MeResponse>('GET', '/api/me'),
|
||||
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
|
||||
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
|
||||
logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}),
|
||||
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
||||
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
||||
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
||||
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
||||
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
||||
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
||||
publicRooms: (gameId: string) =>
|
||||
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
|
||||
};
|
||||
|
||||
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;
|
||||
344
apps/web/src/net/socket.ts
Normal file
344
apps/web/src/net/socket.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* WebSocket client with auto-reconnect, seq ordering, ping/clock sync (docs/03 §4–§8).
|
||||
* Room/game state lives in the zustand store below; components only read it.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { PROTOCOL, type GameResultView, type PublicUser, type RoomView, type ServerMessage } from '@bg/shared/lite';
|
||||
import { toast } from '../store/toast';
|
||||
|
||||
export type ConnStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'replaced' | 'offline';
|
||||
|
||||
export interface ChatLine {
|
||||
id: number;
|
||||
from: PublicUser | null;
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
seq: number;
|
||||
view: unknown;
|
||||
active: string[];
|
||||
deadline: number | null;
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
interface RoomStore {
|
||||
status: ConnStatus;
|
||||
me: PublicUser | null;
|
||||
code: string | null;
|
||||
room: RoomView | null;
|
||||
roomSeq: number;
|
||||
game: GameState | null;
|
||||
result: GameResultView | null;
|
||||
chat: ChatLine[];
|
||||
emotes: { id: number; from: string; emote: string }[];
|
||||
pending: number | null;
|
||||
slow: boolean;
|
||||
rttMs: number | null;
|
||||
clockOffset: number;
|
||||
joinError: string | null;
|
||||
kicked: boolean;
|
||||
needsRefresh: boolean;
|
||||
}
|
||||
|
||||
export const useRoom = create<RoomStore>(() => ({
|
||||
status: 'idle',
|
||||
me: null,
|
||||
code: null,
|
||||
room: null,
|
||||
roomSeq: -1,
|
||||
game: null,
|
||||
result: null,
|
||||
chat: [],
|
||||
emotes: [],
|
||||
pending: null,
|
||||
slow: false,
|
||||
rttMs: null,
|
||||
clockOffset: 0,
|
||||
joinError: null,
|
||||
kicked: false,
|
||||
needsRefresh: false,
|
||||
}));
|
||||
|
||||
const BACKOFF = [500, 1000, 2000, 4000, 8000];
|
||||
const MAX_BACKOFF = 10_000;
|
||||
const PING_EVERY = 10_000;
|
||||
const PONG_TIMEOUT = 5_000;
|
||||
|
||||
class GameSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private attempt = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private offsets: number[] = [];
|
||||
private wanted = false;
|
||||
private cs = Date.now();
|
||||
private chatId = 1;
|
||||
private onMessageHooks = new Set<(m: ServerMessage) => void>();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') this.kick();
|
||||
});
|
||||
window.addEventListener('online', () => this.kick());
|
||||
window.addEventListener('offline', () => useRoom.setState({ status: 'offline' }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (or keep) a connection. Safe to call repeatedly. */
|
||||
start(): void {
|
||||
this.wanted = true;
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
this.open();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.wanted = false;
|
||||
this.clearTimers();
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
useRoom.setState({ status: 'idle' });
|
||||
}
|
||||
|
||||
/** Reconnect immediately if we should be connected but are not. */
|
||||
private kick(): void {
|
||||
if (!this.wanted) return;
|
||||
if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
this.open();
|
||||
} else if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ping();
|
||||
}
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
this.ws = ws;
|
||||
useRoom.setState({ status: this.attempt === 0 ? 'connecting' : 'reconnecting' });
|
||||
ws.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.pingTimer = setInterval(() => this.ping(), PING_EVERY);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
let m: ServerMessage;
|
||||
try {
|
||||
m = JSON.parse(String(e.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handle(m);
|
||||
for (const h of this.onMessageHooks) h(m);
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
if (this.ws !== ws) return;
|
||||
this.clearTimers();
|
||||
this.ws = null;
|
||||
const st = useRoom.getState().status;
|
||||
if (!this.wanted || st === 'replaced' || useRoom.getState().kicked) return;
|
||||
if (e.code === 4401 || e.code === 1008) {
|
||||
// Not logged in (session expired): stop and let the page ask for a nickname.
|
||||
useRoom.setState({ status: 'idle' });
|
||||
this.wanted = false;
|
||||
window.dispatchEvent(new Event('bg:unauthorized'));
|
||||
return;
|
||||
}
|
||||
if (this.attempt >= 1) {
|
||||
// Repeated failures may mean the session is gone (the upgrade got a 401).
|
||||
fetch('/api/me', { credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((d: { me: unknown }) => {
|
||||
if (d.me === null) {
|
||||
this.wanted = false;
|
||||
useRoom.setState({ status: 'idle' });
|
||||
window.dispatchEvent(new Event('bg:unauthorized'));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(extraDelay = 0): void {
|
||||
const base = BACKOFF[this.attempt] ?? MAX_BACKOFF;
|
||||
const jitter = base * (0.7 + Math.random() * 0.6);
|
||||
this.attempt++;
|
||||
useRoom.setState({ status: navigator.onLine === false ? 'offline' : 'reconnecting' });
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
if (this.wanted) this.open();
|
||||
}, jitter + extraDelay);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
if (this.pongTimer) clearTimeout(this.pongTimer);
|
||||
this.pingTimer = null;
|
||||
this.pongTimer = null;
|
||||
}
|
||||
|
||||
private ping(): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN || this.pongTimer) return;
|
||||
this.send({ t: 'ping', ts: Date.now() });
|
||||
this.pongTimer = setTimeout(() => {
|
||||
this.pongTimer = null;
|
||||
// Dead connection: force a reconnect.
|
||||
this.ws?.close();
|
||||
}, PONG_TIMEOUT);
|
||||
}
|
||||
|
||||
send(m: unknown): boolean {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return false;
|
||||
this.ws.send(JSON.stringify(m));
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(fn: (m: ServerMessage) => void): () => void {
|
||||
this.onMessageHooks.add(fn);
|
||||
return () => this.onMessageHooks.delete(fn);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- room commands
|
||||
|
||||
join(code: string, as: 'player' | 'spectator' = 'player'): void {
|
||||
const cur = useRoom.getState();
|
||||
if (cur.code !== code) {
|
||||
useRoom.setState({ code, room: null, roomSeq: -1, game: null, result: null, chat: [], joinError: null, kicked: false, pending: null });
|
||||
}
|
||||
this.start();
|
||||
this.send({ t: 'join', code, as });
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
this.send({ t: 'leave' });
|
||||
useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null });
|
||||
}
|
||||
|
||||
/** Sends a game action; returns false when not connected. */
|
||||
act(a: unknown): boolean {
|
||||
const cs = ++this.cs;
|
||||
if (!this.send({ t: 'act', cs, a })) {
|
||||
toast('연결이 끊겨 있어요. 다시 연결되면 해 주세요.', 'error');
|
||||
return false;
|
||||
}
|
||||
useRoom.setState({ pending: cs, slow: false });
|
||||
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
||||
this.pendingTimer = setTimeout(() => {
|
||||
if (useRoom.getState().pending === cs) {
|
||||
useRoom.setState({ slow: true });
|
||||
this.ping();
|
||||
}
|
||||
}, 2000);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Server time estimate. */
|
||||
now(): number {
|
||||
return Date.now() + useRoom.getState().clockOffset;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- inbound
|
||||
|
||||
private handle(m: ServerMessage): void {
|
||||
const st = useRoom.getState();
|
||||
switch (m.t) {
|
||||
case 'welcome': {
|
||||
if (m.protocol !== PROTOCOL) useRoom.setState({ needsRefresh: true });
|
||||
useRoom.setState({ status: 'open', me: m.me });
|
||||
this.offsets = [m.serverTime - Date.now()];
|
||||
useRoom.setState({ clockOffset: this.offsets[0]! });
|
||||
// Re-join the room we were in (reconnect path).
|
||||
if (st.code && !st.kicked) this.send({ t: 'join', code: st.code, as: 'player' });
|
||||
break;
|
||||
}
|
||||
case 'room':
|
||||
if (m.seq >= st.roomSeq || m.room.code !== st.room?.code) {
|
||||
useRoom.setState({ room: m.room, roomSeq: m.seq, joinError: null });
|
||||
if (m.room.status === 'playing' && st.result) useRoom.setState({ result: null });
|
||||
}
|
||||
break;
|
||||
case 'state': {
|
||||
if (st.game && m.seq < st.game.seq) break; // stale
|
||||
const patch: Partial<RoomStore> = {
|
||||
game: { seq: m.seq, view: m.view, active: m.active, deadline: m.deadline, events: m.events },
|
||||
};
|
||||
if (m.cs !== undefined && m.cs === st.pending) {
|
||||
patch.pending = null;
|
||||
patch.slow = false;
|
||||
}
|
||||
useRoom.setState(patch);
|
||||
break;
|
||||
}
|
||||
case 'reject':
|
||||
if (m.cs === st.pending) useRoom.setState({ pending: null, slow: false });
|
||||
toast(m.reason, 'error');
|
||||
break;
|
||||
case 'result':
|
||||
useRoom.setState({ result: m.result });
|
||||
break;
|
||||
case 'chat':
|
||||
useRoom.setState({ chat: [...st.chat, { id: this.chatId++, from: m.from, text: m.text, at: m.at }].slice(-100) });
|
||||
break;
|
||||
case 'emote': {
|
||||
const id = this.chatId++;
|
||||
useRoom.setState({ emotes: [...st.emotes, { id, from: m.from, emote: m.id }].slice(-10) });
|
||||
setTimeout(() => useRoom.setState((s) => ({ emotes: s.emotes.filter((e) => e.id !== id) })), 2500);
|
||||
break;
|
||||
}
|
||||
case 'notice':
|
||||
toast(m.message);
|
||||
break;
|
||||
case 'error':
|
||||
if (m.code === 'room-not-found' || m.code === 'join-failed') useRoom.setState({ joinError: m.message });
|
||||
else toast(m.message, 'error');
|
||||
break;
|
||||
case 'pong': {
|
||||
if (this.pongTimer) clearTimeout(this.pongTimer);
|
||||
this.pongTimer = null;
|
||||
const now = Date.now();
|
||||
const rtt = now - m.ts;
|
||||
const offset = m.serverTime + rtt / 2 - now;
|
||||
this.offsets = [...this.offsets, offset].slice(-5);
|
||||
const sorted = [...this.offsets].sort((a, b) => a - b);
|
||||
useRoom.setState({ rttMs: rtt, clockOffset: sorted[Math.floor(sorted.length / 2)]! });
|
||||
if (st.slow && st.pending) {
|
||||
// Connection is alive but our action got no answer: resync instead of resending.
|
||||
this.send({ t: 'sync' });
|
||||
useRoom.setState({ pending: null, slow: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bye':
|
||||
if (m.reason === 'restart') {
|
||||
toast('서버를 업데이트하고 있어요. 잠시 후 자동으로 다시 연결돼요.');
|
||||
this.attempt = 0;
|
||||
this.ws?.close();
|
||||
// onclose schedules a reconnect; add 1–3s so clients don't all return at once.
|
||||
this.ws = null;
|
||||
this.clearTimers();
|
||||
this.scheduleReconnect(1000 + Math.random() * 2000);
|
||||
} else if (m.reason === 'replaced') {
|
||||
useRoom.setState({ status: 'replaced' });
|
||||
} else if (m.reason === 'kicked') {
|
||||
useRoom.setState({ kicked: true, joinError: '방장이 방에서 내보냈어요.' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** "Use here" after another tab took over. */
|
||||
reclaim(): void {
|
||||
useRoom.setState({ status: 'connecting' });
|
||||
this.wanted = true;
|
||||
this.attempt = 0;
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new GameSocket();
|
||||
103
apps/web/src/pages/GamePage.tsx
Normal file
103
apps/web/src/pages/GamePage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { catalogById, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
import { GAME_ICON } from '../lib/catalog-ui';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId = '' } = useParams();
|
||||
const g = catalogById(gameId);
|
||||
const nav = useNavigate();
|
||||
const me = useSession((s) => s.me);
|
||||
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
|
||||
const [needName, setNeedName] = useState<null | (() => void)>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!g) return;
|
||||
const load = () => api.publicRooms(gameId).then((r) => setRooms(r.rooms)).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [gameId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!g) return <main className="page">없는 게임이에요.</main>;
|
||||
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
|
||||
const run = (p: () => Promise<{ code: string }>) =>
|
||||
withName(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
nav(`/r/${(await p()).code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title={g.nameKo} onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<span style={{ fontSize: '3.5rem' }} aria-hidden>
|
||||
{GAME_ICON[g.id] ?? '🎲'}
|
||||
</span>
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<h1>{g.nameKo}</h1>
|
||||
<p className="muted">{g.blurb}</p>
|
||||
<p className="small">
|
||||
{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · 약 {g.minutes}분 · {g.level}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack">
|
||||
<button className="btn btn-primary btn-block" style={{ minHeight: 64 }} disabled={busy} onClick={() => run(() => api.createRoom(g.id))}>
|
||||
+ 친구랑 하기 (방 만들기)
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
|
||||
⚡ 아무나와 빠른 시작
|
||||
</button>
|
||||
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
|
||||
📖 규칙 보기
|
||||
</Link>
|
||||
</div>
|
||||
<section className="stack">
|
||||
<h2>기다리는 방</h2>
|
||||
{rooms.length === 0 && <p className="muted">지금 열린 공개 방이 없어요. [빠른 시작]을 누르면 새 방이 열려요.</p>}
|
||||
{rooms.map((r) => (
|
||||
<div key={r.code} className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{r.host}님의 방</strong>
|
||||
<div className="muted small">
|
||||
{r.seated}/{r.max}명 · 코드 {formatRoomCode(r.code)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
|
||||
참여
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{needName && (
|
||||
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
|
||||
<h2>먼저 이름을 정해 주세요</h2>
|
||||
<NicknameForm
|
||||
next={`/g/${g.id}`}
|
||||
onDone={() => {
|
||||
const fn = needName;
|
||||
setNeedName(null);
|
||||
fn();
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/pages/Home.tsx
Normal file
109
apps/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/** 홈: 큰 버튼 두 개 + 진행 중 게임 + 게임 카드 (docs/06 §2). */
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { CATALOG } from '@bg/shared/lite';
|
||||
import { Avatar, Sheet } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
|
||||
|
||||
export function Home() {
|
||||
const { me, activeRoom } = useSession();
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
const nav = useNavigate();
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [needName, setNeedName] = useState<null | (() => void)>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const available = catalog.filter((g) => g.available);
|
||||
|
||||
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
|
||||
|
||||
const create = (gameId: string) =>
|
||||
withName(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { code } = await api.createRoom(gameId);
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<h1>같이 놀자 보드게임</h1>
|
||||
<Link to="/me" className="btn btn-ghost" aria-label="내 정보">
|
||||
{me ? <Avatar user={me} size={36} /> : '👤'} <span className="small">{me ? me.nickname : '내 정보'}</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{activeRoom && (
|
||||
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block">
|
||||
▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)})
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="stack">
|
||||
<button className="btn btn-primary btn-block" style={{ minHeight: 72, fontSize: '1.25rem' }} disabled={busy} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}>
|
||||
+ 방 만들기
|
||||
</button>
|
||||
<Link to="/j" className="btn btn-secondary btn-block" style={{ minHeight: 72, fontSize: '1.25rem' }}>
|
||||
🔢 코드로 참여하기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="stack">
|
||||
<h2>게임 고르기</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
|
||||
{available.map((g) => (
|
||||
<Link key={g.id} to={`/g/${g.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontSize: '2.4rem' }} aria-hidden>
|
||||
{GAME_ICON[g.id] ?? '🎲'}
|
||||
</span>
|
||||
<strong style={{ fontSize: '1.15rem' }}>{g.nameKo}</strong>
|
||||
<span className="muted small">{g.blurb}</span>
|
||||
<span className="small">
|
||||
{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · {g.minutes}분
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
|
||||
{picker && (
|
||||
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
|
||||
<h2>어떤 게임을 할까요?</h2>
|
||||
{available.map((g) => (
|
||||
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}>
|
||||
{GAME_ICON[g.id]} {g.nameKo}
|
||||
</button>
|
||||
))}
|
||||
</Sheet>
|
||||
)}
|
||||
{needName && (
|
||||
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
|
||||
<h2>먼저 이름을 정해 주세요</h2>
|
||||
<NicknameForm
|
||||
next="/"
|
||||
onDone={() => {
|
||||
const fn = needName;
|
||||
setNeedName(null);
|
||||
fn();
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/pages/JoinPage.tsx
Normal file
36
apps/web/src/pages/JoinPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { api } from '../net/api';
|
||||
|
||||
export default function JoinPage() {
|
||||
const nav = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [key, setKey] = useState(0);
|
||||
const go = async (code: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.room(code);
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setKey((k) => k + 1); // reset the boxes
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TopBar title="코드로 참여하기" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<h1>방 코드 6자리를 눌러 주세요</h1>
|
||||
<p className="muted">방을 만든 사람에게 받은 숫자예요. 다 누르면 바로 들어가요.</p>
|
||||
<CodeInput key={key} onComplete={go} busy={busy} />
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
206
apps/web/src/pages/MePage.tsx
Normal file
206
apps/web/src/pages/MePage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/** 계정 페이지 (docs/05 §7). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api, discordLoginUrl } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
|
||||
function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={k} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MePage() {
|
||||
const nav = useNavigate();
|
||||
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings } = useSession();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [nick, setNick] = useState(me?.nickname ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<{ gameId: string; played: number; wins: number; draws: number }[]>([]);
|
||||
const [confirm, setConfirm] = useState<null | 'logout' | 'logoutAll' | 'delete'>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (me) api.stats().then((r) => setStats(r.stats)).catch(() => {});
|
||||
}, [me]);
|
||||
|
||||
const saveNick = async () => {
|
||||
const n = normalizeNickname(nick);
|
||||
const e = nicknameError(n);
|
||||
if (e) return setError(e);
|
||||
try {
|
||||
const r = await api.updateMe({ nickname: n });
|
||||
useSession.getState().setMe(r.me);
|
||||
setEditing(false);
|
||||
toast('닉네임을 바꿨어요.');
|
||||
} catch (ex) {
|
||||
setError((ex as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const display = (
|
||||
<section className="card stack">
|
||||
<h2>화면 설정</h2>
|
||||
<Seg label="글자 크기" value={settings.fontScale} options={[['normal', '보통'], ['large', '크게'], ['xlarge', '아주 크게']]} onChange={(fontScale) => updateSettings({ fontScale })} />
|
||||
<Seg label="화면 색" value={settings.theme} options={[['auto', '자동'], ['light', '밝게'], ['dark', '어둡게']]} onChange={(theme) => updateSettings({ theme })} />
|
||||
<Seg label="소리" value={settings.sound ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ sound: v === 'on' })} />
|
||||
<Seg label="진동" value={settings.vibrate ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ vibrate: v === 'on' })} />
|
||||
<Seg
|
||||
label="돌·카드 놓기"
|
||||
value={settings.tapConfirm ? 'on' : 'off'}
|
||||
options={[['on', '두 번 눌러 확정(실수 방지)'], ['off', '한 번에 놓기']]}
|
||||
onChange={(v) => updateSettings({ tapConfirm: v === 'on' })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="내 정보" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h2>시작하기</h2>
|
||||
<NicknameForm next="/me" />
|
||||
</section>
|
||||
{display}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="내 정보" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
|
||||
<Avatar user={me} size={88} />
|
||||
{editing ? (
|
||||
<div className="stack" style={{ width: '100%' }}>
|
||||
<input className="input" value={nick} onChange={(e) => (setNick(e.target.value), setError(null))} maxLength={20} autoFocus aria-label="새 닉네임" />
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={saveNick}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => (setEditing(false), setNick(me.nickname), setError(null))}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted small">게임 중에 바꾸면 그 게임이 끝난 뒤에 보여요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1>{me.nickname}</h1>
|
||||
<button className="btn btn-secondary" onClick={() => (setNick(me.nickname), setEditing(true))}>
|
||||
✏️ 닉네임 바꾸기
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
|
||||
{me.kind === 'guest' && discordEnabled && (
|
||||
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
|
||||
디스코드 연결하기 (기록을 안전하게 보관)
|
||||
</a>
|
||||
)}
|
||||
{me.kind === 'member' && hasDiscordAvatar && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={async () => {
|
||||
await api.updateMe({ useAvatar: !useAvatar });
|
||||
await useSession.getState().refresh();
|
||||
}}
|
||||
>
|
||||
{useAvatar ? '디스코드 프로필 사진 숨기기' : '디스코드 프로필 사진 쓰기'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{display}
|
||||
|
||||
<section className="card stack">
|
||||
<h2>내 기록</h2>
|
||||
{stats.length === 0 ? (
|
||||
<p className="muted">아직 끝까지 한 게임이 없어요.</p>
|
||||
) : (
|
||||
stats.map((s) => (
|
||||
<div key={s.gameId} className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{catalogById(s.gameId)?.nameKo ?? s.gameId}</strong>
|
||||
<span>
|
||||
{s.played}판 {s.wins}승{s.draws ? ` ${s.draws}무` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="stack">
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
|
||||
로그아웃
|
||||
</button>
|
||||
{me.kind === 'member' && (
|
||||
<button className="btn btn-ghost btn-block" onClick={() => setConfirm('logoutAll')}>
|
||||
모든 기기에서 로그아웃
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-block small muted" onClick={() => setConfirm('delete')}>
|
||||
계정 삭제
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{confirm === 'logout' && (
|
||||
<ConfirmSheet
|
||||
title="로그아웃할까요?"
|
||||
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
|
||||
confirm="로그아웃"
|
||||
danger={me.kind === 'guest'}
|
||||
onConfirm={async () => {
|
||||
await api.logout();
|
||||
await useSession.getState().refresh();
|
||||
nav('/');
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'logoutAll' && (
|
||||
<ConfirmSheet
|
||||
title="모든 기기에서 로그아웃할까요?"
|
||||
confirm="모두 로그아웃"
|
||||
onConfirm={async () => {
|
||||
await api.logoutAll();
|
||||
await useSession.getState().refresh();
|
||||
nav('/');
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'delete' && (
|
||||
<ConfirmSheet
|
||||
title="계정을 삭제할까요?"
|
||||
body="닉네임과 연결 정보가 지워지고 되돌릴 수 없어요."
|
||||
confirm="삭제하기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await api.deleteMe();
|
||||
await useSession.getState().refresh();
|
||||
nav('/');
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/pages/NotFound.tsx
Normal file
12
apps/web/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>페이지를 찾을 수 없어요</h1>
|
||||
<Link className="btn btn-primary" to="/">
|
||||
홈으로
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
621
apps/web/src/pages/RoomPage.tsx
Normal file
621
apps/web/src/pages/RoomPage.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { CHAT_MAX, EMOTE_LABEL, EMOTES, catalogById, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { QrModal } from '../components/QrModal';
|
||||
import { api, type RoomPreview } from '../net/api';
|
||||
import { socket, useRoom } from '../net/socket';
|
||||
import { useSession } from '../store/session';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { sounds } from '../lib/feedback';
|
||||
|
||||
export default function RoomPage() {
|
||||
const { code = '' } = useParams();
|
||||
const me = useSession((s) => s.me);
|
||||
const nav = useNavigate();
|
||||
const [preview, setPreview] = useState<RoomPreview | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
setPreviewError('방 코드가 올바르지 않아요.');
|
||||
return;
|
||||
}
|
||||
if (!me) api.room(code).then(setPreview, (e) => setPreviewError((e as Error).message));
|
||||
}, [code, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && /^\d{6}$/.test(code)) socket.join(code);
|
||||
}, [code, me]);
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="방에 들어가기" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
{previewError ? (
|
||||
<RoomGone message={previewError} />
|
||||
) : (
|
||||
<section className="card stack">
|
||||
{preview ? (
|
||||
<>
|
||||
<h1>
|
||||
{catalogById(preview.gameId)?.nameKo ?? '게임'} 방에 초대받았어요
|
||||
</h1>
|
||||
<p className="muted">
|
||||
방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">방 정보를 불러오는 중…</p>
|
||||
)}
|
||||
<NicknameForm next={`/r/${code}`} submitLabel="이 이름으로 입장하기" />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Room code={code} me={me} />;
|
||||
}
|
||||
|
||||
function RoomGone({ message }: { message: string }) {
|
||||
const nav = useNavigate();
|
||||
return (
|
||||
<section className="card stack">
|
||||
<h1>{message}</h1>
|
||||
<button className="btn btn-primary btn-block" onClick={() => nav('/j')}>
|
||||
다른 코드 입력하기
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
|
||||
홈으로
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
const nav = useNavigate();
|
||||
const { room, game, result, status, joinError, pending, slow, needsRefresh } = useRoom();
|
||||
const [ui, setUi] = useState<GameUI | null>(null);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [leaveAsk, setLeaveAsk] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const gameId = room?.gameId;
|
||||
|
||||
useEffect(() => {
|
||||
if (gameId) loadGameUI(gameId).then(setUi);
|
||||
}, [gameId]);
|
||||
|
||||
const players = useMemo(() => {
|
||||
const map: Record<string, PublicUser> = {};
|
||||
for (const s of room?.seats ?? []) if (s) map[s.user.id] = s.user;
|
||||
for (const u of room?.spectators ?? []) map[u.id] = u;
|
||||
return map;
|
||||
}, [room]);
|
||||
|
||||
useTurnTitle(ui, game?.view, me.id);
|
||||
|
||||
if (status === 'replaced') {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h1>다른 창에서 이 방을 열었어요</h1>
|
||||
<p className="muted">한 번에 한 창에서만 할 수 있어요.</p>
|
||||
<button className="btn btn-primary btn-block" onClick={() => socket.reclaim()}>
|
||||
여기서 계속하기
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (joinError) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="방" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<RoomGone message={joinError} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!room || room.code !== code || !ui) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`방 ${formatRoomCode(code)}`} onBack={() => nav('/')} />
|
||||
<main className="page muted">방에 들어가는 중…</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const g = catalogById(room.gameId);
|
||||
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
|
||||
const isHost = room.hostId === me.id;
|
||||
const doLeave = () => {
|
||||
socket.leave();
|
||||
nav('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar
|
||||
title={
|
||||
<>
|
||||
{g?.nameKo} · <CodeLabel code={code} />
|
||||
</>
|
||||
}
|
||||
onBack={() => nav('/')}
|
||||
right={
|
||||
<>
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
<button className="icon-btn" onClick={() => setMenu(true)} aria-label="메뉴">
|
||||
⋯<span className="lbl">메뉴</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{needsRefresh && (
|
||||
<div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}>
|
||||
<span>새 버전이 있어요.</span>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => location.reload()}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inGame && game ? (
|
||||
<GameView room={room} ui={ui} me={me} players={players} pending={pending !== null} slow={slow} />
|
||||
) : (
|
||||
<Lobby room={room} ui={ui} me={me} />
|
||||
)}
|
||||
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
|
||||
<ChatDock room={room} me={me} open={chatOpen} onClose={() => setChatOpen(false)} />
|
||||
{menu && (
|
||||
<Sheet onClose={() => setMenu(false)} label="메뉴">
|
||||
<h2>메뉴</h2>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||
📖 규칙 보기
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav('/me')}>
|
||||
⚙️ 글자 크기 · 소리 설정
|
||||
</button>
|
||||
{isHost && room.status === 'paused' && (
|
||||
<button className="btn btn-danger btn-block" onClick={() => (socket.send({ t: 'void' }), setMenu(false))}>
|
||||
게임 끝내기(무효)
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-block" onClick={() => (setMenu(false), setLeaveAsk(true))}>
|
||||
🚪 방 나가기
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
{leaveAsk && (
|
||||
<ConfirmSheet
|
||||
title="방에서 나갈까요?"
|
||||
body={room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined}
|
||||
confirm="나가기"
|
||||
danger
|
||||
onConfirm={doLeave}
|
||||
onClose={() => setLeaveAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blink the tab title when it's my turn and the tab is hidden (docs/07 §6). */
|
||||
function useTurnTitle(ui: GameUI | null, view: unknown, me: string) {
|
||||
const myTurn = !!ui && !!view && ui.isMyTurn(view, me);
|
||||
const prev = useRef(false);
|
||||
useEffect(() => {
|
||||
if (myTurn && !prev.current && document.visibilityState === 'hidden') sounds.myTurn();
|
||||
prev.current = myTurn;
|
||||
if (!myTurn) return;
|
||||
const base = document.title;
|
||||
let on = false;
|
||||
const t = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
document.title = base;
|
||||
return;
|
||||
}
|
||||
on = !on;
|
||||
document.title = on ? '● 내 차례예요' : base;
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
document.title = base;
|
||||
};
|
||||
}, [myTurn]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lobby
|
||||
|
||||
function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {
|
||||
const [qr, setQr] = useState(false);
|
||||
const [settings, setSettings] = useState(false);
|
||||
const isHost = room.hostId === me.id;
|
||||
const mySeat = room.seats.findIndex((s) => s?.user.id === me.id);
|
||||
const seated = room.seats.filter(Boolean).length;
|
||||
const need = Math.max(0, room.minPlayers - seated);
|
||||
const url = `${location.origin}/r/${room.code}`;
|
||||
const gameName = catalogById(room.gameId)?.nameKo ?? '게임';
|
||||
const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname;
|
||||
const shareText = `${host}님이 ${gameName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
|
||||
const [askStart, setAskStart] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="card stack" style={{ textAlign: 'center' }} aria-label="초대하기">
|
||||
<p className="muted">친구에게 이 코드를 알려 주세요</p>
|
||||
<p style={{ fontSize: '3rem', fontWeight: 800, lineHeight: 1.1 }}>
|
||||
<CodeLabel code={room.code} />
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CopyButton text={room.code} label="🔢 코드 복사" />
|
||||
<CopyButton text={url} label="🔗 링크 복사" />
|
||||
<ShareButton title={`${gameName} 같이 해요`} text={shareText} url={url} />
|
||||
<button className="btn btn-secondary" onClick={() => setQr(true)}>
|
||||
📷 QR 보기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="stack" aria-label="자리">
|
||||
<h2>
|
||||
자리 ({seated}/{room.maxPlayers})
|
||||
</h2>
|
||||
{room.seats.map((s, i) =>
|
||||
s ? (
|
||||
<div key={i} className="card row" style={{ gap: 12 }}>
|
||||
<Avatar user={s.user} size={44} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
|
||||
{s.user.nickname}
|
||||
{s.user.id === me.id && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="row small" style={{ gap: 6 }}>
|
||||
<span className="dot" style={{ background: s.connected ? 'var(--success)' : s.away ? 'var(--text-sub)' : 'var(--warning)' }} />
|
||||
{s.connected ? '접속 중' : s.away ? '자리 비움' : '연결 끊김'}
|
||||
{room.hostId !== s.user.id && (s.ready ? <span className="badge success">준비 완료</span> : <span className="badge">준비 중</span>)}
|
||||
</div>
|
||||
</div>
|
||||
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
|
||||
</div>
|
||||
) : (
|
||||
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 72, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
|
||||
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
|
||||
{mySeat >= 0 && room.allowSpectators && (
|
||||
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'unseat' })}>
|
||||
관전으로 바꾸기
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack">
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3>게임 설정</h3>
|
||||
<p className="muted">{ui.optionsSummary(room.options)}</p>
|
||||
</div>
|
||||
{isHost && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSettings(true)}>
|
||||
설정 바꾸기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
{isHost ? (
|
||||
<button className="btn btn-primary" disabled={need > 0} onClick={() => (notReady > 0 ? setAskStart(true) : socket.send({ t: 'start' }))}>
|
||||
{need > 0 ? `${need}명 더 필요해요` : '▶ 게임 시작'}
|
||||
</button>
|
||||
) : mySeat >= 0 ? (
|
||||
room.seats[mySeat]!.ready ? (
|
||||
<button className="btn btn-secondary" onClick={() => socket.send({ t: 'ready', ready: false })}>
|
||||
준비 취소
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-success" onClick={() => socket.send({ t: 'ready', ready: true })}>
|
||||
✓ 준비하기
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!room.seats.some((s) => s === null)} onClick={() => socket.send({ t: 'seat' })}>
|
||||
{room.seats.some((s) => s === null) ? '자리에 앉기' : '자리가 다 찼어요 (관전 중)'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{qr && <QrModal url={url} code={room.code} onClose={() => setQr(false)} />}
|
||||
{settings && <SettingsSheet room={room} ui={ui} onClose={() => setSettings(false)} />}
|
||||
{askStart && (
|
||||
<ConfirmSheet title="준비 안 된 사람이 있어요" body="그래도 시작할까요?" confirm="시작하기" onConfirm={() => socket.send({ t: 'start' })} onClose={() => setAskStart(false)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kick, setKick] = useState(false);
|
||||
void room;
|
||||
return (
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(true)} aria-label={`${target.nickname} 관리`}>
|
||||
⋯
|
||||
</button>
|
||||
{open && (
|
||||
<Sheet onClose={() => setOpen(false)} label={`${target.nickname} 관리`}>
|
||||
<h2>{target.nickname}</h2>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => (socket.send({ t: 'host', userId: target.id }), setOpen(false))}>
|
||||
👑 방장 넘기기
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={() => (setOpen(false), setKick(true))}>
|
||||
내보내기
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
{kick && (
|
||||
<ConfirmSheet
|
||||
title={`${target.nickname}님을 내보낼까요?`}
|
||||
body="내보낸 사람은 이 방에 다시 들어올 수 없어요."
|
||||
confirm="내보내기"
|
||||
danger
|
||||
onConfirm={() => socket.send({ t: 'kick', userId: target.id })}
|
||||
onClose={() => setKick(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) {
|
||||
const [options, setOptions] = useState(room.options);
|
||||
const [visibility, setVisibility] = useState(room.visibility);
|
||||
const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators);
|
||||
const [chatEnabled, setChatEnabled] = useState(room.chatEnabled);
|
||||
const save = () => {
|
||||
socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled });
|
||||
onClose();
|
||||
};
|
||||
const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={value} onClick={() => set(true)}>
|
||||
{on}
|
||||
</button>
|
||||
<button type="button" aria-pressed={!value} onClick={() => set(false)}>
|
||||
{off}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Sheet onClose={onClose} label="게임 설정">
|
||||
<h2>게임 설정</h2>
|
||||
<ui.Options value={options} onChange={setOptions} />
|
||||
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
|
||||
<strong>방 공개</strong>
|
||||
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나(공개)', '친구만')}
|
||||
<strong>관전</strong>
|
||||
{seg(allowSpectators, setAllowSpectators, '관전 허용', '관전 안 됨')}
|
||||
<strong>채팅</strong>
|
||||
{seg(chatEnabled, setChatEnabled, '채팅 켜기', '채팅 끄기')}
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- game
|
||||
|
||||
function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui: GameUI; me: PublicUser; players: Record<string, PublicUser>; pending: boolean; slow: boolean }) {
|
||||
const game = useRoom((s) => s.game)!;
|
||||
const tapConfirm = useSession((s) => s.settings.tapConfirm);
|
||||
const myTurn = ui.isMyTurn(game.view, me.id);
|
||||
const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined;
|
||||
return (
|
||||
<main className="page" style={{ gap: 12 }}>
|
||||
<div
|
||||
className="card"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontWeight: 700,
|
||||
fontSize: '1.1rem',
|
||||
background: myTurn ? 'var(--primary)' : 'var(--surface)',
|
||||
color: myTurn ? 'var(--primary-text)' : 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)}
|
||||
{slow && <span className="small"> · 연결 확인 중…</span>}
|
||||
{!isPlayer && <span className="badge" style={{ marginLeft: 8 }}>관전 중</span>}
|
||||
</div>
|
||||
<ui.Board
|
||||
view={game.view}
|
||||
me={me.id}
|
||||
players={players}
|
||||
act={(a) => socket.act(a)}
|
||||
pending={pending}
|
||||
deadline={game.deadline}
|
||||
active={game.active}
|
||||
tapConfirm={tapConfirm}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: PublicUser; players: Record<string, PublicUser>; onLeave: () => void }) {
|
||||
const result = useRoom((s) => s.result)!;
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const played = useRef(false);
|
||||
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
|
||||
const amSeated = seated.includes(me.id);
|
||||
const voted = room.rematchVotes.includes(me.id);
|
||||
const allFirst = result.ranking.length === 1;
|
||||
const won = !allFirst && result.ranking[0]?.includes(me.id);
|
||||
const lost = !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
|
||||
useEffect(() => {
|
||||
if (played.current) return;
|
||||
played.current = true;
|
||||
if (won) sounds.win();
|
||||
else if (lost) sounds.lose();
|
||||
}, [won, lost]);
|
||||
if (hidden) {
|
||||
return (
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={() => setHidden(false)}>
|
||||
결과 다시 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Sheet onClose={() => setHidden(true)} label="게임 결과">
|
||||
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
|
||||
<p style={{ textAlign: 'center' }}>{result.summary}</p>
|
||||
<div className="stack">
|
||||
{result.ranking.map((group, i) =>
|
||||
group.map((u) => (
|
||||
<div key={u} className="row">
|
||||
<strong style={{ width: 40 }}>{i + 1}등</strong>
|
||||
{players[u] && <Avatar user={players[u]!} size={28} />}
|
||||
<span>{players[u]?.nickname ?? '나간 사람'}</span>
|
||||
<span className="muted small" style={{ marginLeft: 'auto' }}>
|
||||
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
|
||||
</span>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
{amSeated && (
|
||||
<button className="btn btn-primary btn-block" disabled={voted} onClick={() => socket.send({ t: 'rematch' })}>
|
||||
{voted ? `다른 사람을 기다리는 중… (${room.rematchVotes.length}/${seated.length})` : `🔁 한 판 더 (${room.rematchVotes.length}/${seated.length})`}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
|
||||
판 다시 보기
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-block" onClick={onLeave}>
|
||||
방 나가기
|
||||
</button>
|
||||
{room.lastSeed && (
|
||||
<details className="small muted">
|
||||
<summary>공정성 확인</summary>
|
||||
<p>시작할 때 공개한 값: {room.seedHash ?? '-'}</p>
|
||||
<p>이번 판의 난수 씨앗: {room.lastSeed}</p>
|
||||
<p>씨앗의 SHA-256이 위 값과 같으면 서버가 게임 도중 난수를 바꾸지 않았다는 뜻이에요.</p>
|
||||
</details>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chat
|
||||
|
||||
/** Unread counter shared between the top-bar button and the chat sheet. */
|
||||
const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 }));
|
||||
|
||||
function ChatButton({ onOpen }: { onOpen: () => void }) {
|
||||
const total = useRoom((s) => s.chat.length);
|
||||
const seen = useChatSeen((s) => s.seen);
|
||||
const unread = Math.max(0, total - seen);
|
||||
return (
|
||||
<button className="icon-btn" onClick={onOpen} aria-label={`채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}>
|
||||
💬<span className="lbl">채팅</span>
|
||||
{unread > 0 && <span className="badge warning">{unread}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatDock({ room, me, open, onClose }: { room: RoomView; me: PublicUser; open: boolean; onClose: () => void }) {
|
||||
const chat = useRoom((s) => s.chat);
|
||||
const emotes = useRoom((s) => s.emotes);
|
||||
const setOpen = (v: boolean) => !v && onClose();
|
||||
const [text, setText] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenUsers, setHiddenUsers] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
useChatSeen.setState({ seen: chat.length });
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||
}
|
||||
}, [open, chat.length]);
|
||||
const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? '';
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', right: 16, bottom: 'calc(96px + env(safe-area-inset-bottom))', zIndex: 16, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', pointerEvents: 'none' }}>
|
||||
{emotes.map((e) => (
|
||||
<div key={e.id} className="toast" style={{ pointerEvents: 'none' }}>
|
||||
{nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{open && (
|
||||
<Sheet onClose={() => setOpen(false)} label="채팅">
|
||||
<h2>채팅</h2>
|
||||
<div className="row">
|
||||
{EMOTES.map((id) => (
|
||||
<button key={id} className="btn btn-secondary btn-sm" onClick={() => socket.send({ t: 'emote', id })}>
|
||||
{EMOTE_LABEL[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div ref={listRef} className="stack" style={{ maxHeight: '40vh', overflow: 'auto', gap: 8 }} aria-live="polite">
|
||||
{chat.length === 0 && <p className="muted">아직 메시지가 없어요.</p>}
|
||||
{chat
|
||||
.filter((m) => !m.from || !hiddenUsers.has(m.from.id))
|
||||
.map((m) => (
|
||||
<div key={m.id}>
|
||||
<strong>{m.from?.nickname ?? '알림'}</strong>{' '}
|
||||
{m.from && m.from.id !== me.id && (
|
||||
<button className="btn-ghost small muted" style={{ border: 0, background: 'none', cursor: 'pointer' }} onClick={() => setHiddenUsers(new Set([...hiddenUsers, m.from!.id]))}>
|
||||
(가리기)
|
||||
</button>
|
||||
)}
|
||||
<div>{m.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{room.chatEnabled ? (
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) });
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<input className="input" style={{ flex: 1 }} value={text} onChange={(e) => setText(e.target.value)} maxLength={CHAT_MAX} placeholder="메시지 입력" aria-label="메시지" enterKeyHint="send" />
|
||||
<button className="btn btn-primary">보내기</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">이 방은 채팅을 쓰지 않아요. 위의 반응 버튼은 쓸 수 있어요.</p>
|
||||
)}
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/pages/RulesPage.tsx
Normal file
24
apps/web/src/pages/RulesPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { catalogById } from '@bg/shared/lite';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
|
||||
export default function RulesPage() {
|
||||
const { gameId = '' } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [ui, setUi] = useState<GameUI | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
loadGameUI(gameId).then(setUi);
|
||||
}, [gameId]);
|
||||
const g = catalogById(gameId);
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
||||
<main className="page">
|
||||
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/store/session.ts
Normal file
88
apps/web/src/store/session.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/** Who am I + display settings (docs/05 §7, docs/07 §2). */
|
||||
import { create } from 'zustand';
|
||||
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
|
||||
import { api, type Settings } from '../net/api';
|
||||
|
||||
const LS_KEY = 'bg.settings';
|
||||
|
||||
export const DEFAULT_SETTINGS: Required<Settings> = {
|
||||
fontScale: 'normal',
|
||||
theme: 'auto',
|
||||
sound: true,
|
||||
vibrate: true,
|
||||
colorBlind: false,
|
||||
tapConfirm: true,
|
||||
};
|
||||
|
||||
function loadLocal(): Required<Settings> {
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(LS_KEY) ?? '{}') };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySettings(s: Required<Settings>): void {
|
||||
const html = document.documentElement;
|
||||
html.dataset.fontScale = s.fontScale;
|
||||
html.dataset.theme = s.theme;
|
||||
html.dataset.colorBlind = String(s.colorBlind);
|
||||
const dark = s.theme === 'dark' || (s.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#16181d' : '#F7F5F0');
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
loaded: boolean;
|
||||
me: PublicUser | null;
|
||||
activeRoom: string | null;
|
||||
discordEnabled: boolean;
|
||||
catalog: CatalogEntry[];
|
||||
settings: Required<Settings>;
|
||||
useAvatar: boolean;
|
||||
hasDiscordAvatar: boolean;
|
||||
refresh(): Promise<void>;
|
||||
setMe(me: PublicUser | null): void;
|
||||
updateSettings(patch: Settings): void;
|
||||
}
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
loaded: false,
|
||||
me: null,
|
||||
activeRoom: null,
|
||||
discordEnabled: false,
|
||||
catalog: [],
|
||||
settings: loadLocal(),
|
||||
useAvatar: true,
|
||||
hasDiscordAvatar: false,
|
||||
async refresh() {
|
||||
const [cfg, me] = await Promise.all([
|
||||
api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[] })),
|
||||
api.me().catch(() => ({ me: null }) as Awaited<ReturnType<typeof api.me>>),
|
||||
]);
|
||||
const settings = { ...get().settings, ...(me.settings ?? {}) };
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
set({
|
||||
loaded: true,
|
||||
me: me.me,
|
||||
activeRoom: me.activeRoom ?? null,
|
||||
discordEnabled: cfg.discord,
|
||||
catalog: cfg.catalog,
|
||||
settings,
|
||||
useAvatar: me.useAvatar ?? true,
|
||||
hasDiscordAvatar: me.hasDiscordAvatar ?? false,
|
||||
});
|
||||
},
|
||||
setMe(me) {
|
||||
set({ me });
|
||||
},
|
||||
updateSettings(patch) {
|
||||
const settings = { ...get().settings, ...patch };
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
set({ settings });
|
||||
if (get().me) api.updateMe({ settings }).catch(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
applySettings(loadLocal());
|
||||
17
apps/web/src/store/toast.ts
Normal file
17
apps/web/src/store/toast.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
kind: 'info' | 'error';
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export const useToasts = create<{ toasts: Toast[] }>(() => ({ toasts: [] }));
|
||||
|
||||
export function toast(text: string, kind: Toast['kind'] = 'info', ms = 3000): void {
|
||||
const id = nextId++;
|
||||
useToasts.setState((s) => ({ toasts: [...s.toasts.filter((t) => t.text !== text), { id, text, kind }].slice(-3) }));
|
||||
setTimeout(() => useToasts.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), ms);
|
||||
}
|
||||
530
apps/web/src/styles/global.css
Normal file
530
apps/web/src/styles/global.css
Normal file
@@ -0,0 +1,530 @@
|
||||
/* Design tokens: docs/07-ui-ux.md §2 */
|
||||
:root {
|
||||
--bg: #f7f5f0;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #efece4;
|
||||
--text: #1c1e21;
|
||||
--text-sub: #4a4f57;
|
||||
--border: #d9d5cb;
|
||||
--primary: #2563eb;
|
||||
--primary-press: #1d4fd0;
|
||||
--primary-text: #ffffff;
|
||||
--primary-soft: #dbe7ff;
|
||||
--success: #15803d;
|
||||
--success-soft: #dcfce7;
|
||||
--warning: #b45309;
|
||||
--warning-soft: #fef3c7;
|
||||
--danger: #b91c1c;
|
||||
--danger-soft: #fee2e2;
|
||||
--felt: #1f6b4a;
|
||||
--wood: #e3b76b;
|
||||
--wood-line: #7a5a22;
|
||||
--focus: #f59e0b;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--tap: 48px;
|
||||
--btn-h: 56px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #16181d;
|
||||
--surface: #20232a;
|
||||
--surface-2: #2a2e37;
|
||||
--text: #f2f3f5;
|
||||
--text-sub: #b8bcc4;
|
||||
--border: #3a3f4a;
|
||||
--primary: #5b8def;
|
||||
--primary-press: #4a7de0;
|
||||
--primary-text: #0b1020;
|
||||
--primary-soft: #1e2a44;
|
||||
--success: #4ade80;
|
||||
--success-soft: #133222;
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #3a2c0c;
|
||||
--danger: #f87171;
|
||||
--danger-soft: #3d1717;
|
||||
--felt: #17513a;
|
||||
--wood: #b98c45;
|
||||
--wood-line: #4a3612;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme='auto'] {
|
||||
--bg: #16181d;
|
||||
--surface: #20232a;
|
||||
--surface-2: #2a2e37;
|
||||
--text: #f2f3f5;
|
||||
--text-sub: #b8bcc4;
|
||||
--border: #3a3f4a;
|
||||
--primary: #5b8def;
|
||||
--primary-press: #4a7de0;
|
||||
--primary-text: #0b1020;
|
||||
--primary-soft: #1e2a44;
|
||||
--success: #4ade80;
|
||||
--success-soft: #133222;
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #3a2c0c;
|
||||
--danger: #f87171;
|
||||
--danger-soft: #3d1717;
|
||||
--felt: #17513a;
|
||||
--wood: #b98c45;
|
||||
--wood-line: #4a3612;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Font scale setting: 보통 / 크게 / 아주 크게. Everything else is in rem. */
|
||||
html {
|
||||
font-size: 106.25%; /* 17px */
|
||||
}
|
||||
html[data-font-scale='large'] {
|
||||
font-size: 127.5%;
|
||||
}
|
||||
html[data-font-scale='xlarge'] {
|
||||
font-size: 148.75%;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px calc(120px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
min-height: var(--btn-h);
|
||||
min-width: var(--tap);
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid transparent;
|
||||
font-size: 1.06rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: transform 0.08s ease, background 0.15s ease;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-press);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-soft {
|
||||
background: var(--primary-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--surface);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: var(--tap);
|
||||
padding: 0 14px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
min-height: var(--tap);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* ---------- inputs ---------- */
|
||||
.input {
|
||||
min-height: var(--btn-h);
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 0 16px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
.field-error {
|
||||
color: var(--danger);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
label.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.segmented button {
|
||||
min-height: var(--tap);
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.segmented button[aria-pressed='true'] {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 56px;
|
||||
padding: 4px 8px;
|
||||
padding-top: calc(4px + env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title {
|
||||
flex: 1;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- bottom action bar ---------- */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 15;
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.bottom-bar-inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.bottom-bar-inner > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- banners & toasts ---------- */
|
||||
.conn-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 8px 12px;
|
||||
padding-top: calc(8px + env(safe-area-inset-top));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: calc(64px + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(92vw, 420px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
animation: toast-in 0.2s ease;
|
||||
}
|
||||
.toast.error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- sheets / modals ---------- */
|
||||
.sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: fade 0.15s ease;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.sheet-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.sheet {
|
||||
background: var(--surface);
|
||||
width: min(100%, 520px);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
animation: sheet-up 0.2s ease;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.sheet {
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(24px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- avatar ---------- */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.badge.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Icon + small label stacked (keeps text with icons but saves width on phones). */
|
||||
.icon-btn {
|
||||
min-width: var(--tap);
|
||||
min-height: var(--tap);
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.1;
|
||||
position: relative;
|
||||
}
|
||||
.icon-btn .lbl {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.icon-btn .badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.sheet:focus {
|
||||
outline: none;
|
||||
}
|
||||
.board-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: min(100%, calc(100vh - 300px));
|
||||
min-width: min(100%, 320px);
|
||||
align-self: center;
|
||||
}
|
||||
9
apps/web/tsconfig.json
Normal file
9
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
19
apps/web/vite.config.ts
Normal file
19
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/healthz': 'http://localhost:3000',
|
||||
'/ws': { target: 'ws://localhost:3000', ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
cssCodeSplit: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user