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:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user