Merge branch 'm9/replay' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-06 13:35:23 +09:00
19 changed files with 1612 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ 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 ReplayPage = lazy(() => import('./pages/ReplayPage'));
const RulesPage = lazy(() => import('./pages/RulesPage'));
const NotFound = lazy(() => import('./pages/NotFound'));
const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
@@ -68,6 +69,7 @@ function Shell() {
<Route path="/g/:gameId" element={<GamePage />} />
<Route path="/rules/:gameId" element={<RulesPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/replay/:gamePk" element={<ReplayPage />} />
<Route path="/admin/*" element={<AdminApp />} />
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -1,4 +1,4 @@
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
import type { CatalogEntry, MyGameSummary, PublicUser, ReplayData } from '@bg/shared/lite';
export class ApiError extends Error {
constructor(
@@ -66,6 +66,9 @@ export const api = {
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'),
myGames: (before?: string | null) =>
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
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}`),

View File

@@ -1,9 +1,10 @@
/** 계정 페이지 (docs/05 §7). */
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
import { Link, useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { gameName } from '../components/GameNotice';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
@@ -152,6 +153,8 @@ export default function MePage() {
)}
</section>
<MyGames />
<section className="stack">
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
로그아웃
@@ -210,3 +213,83 @@ export default function MePage() {
</>
);
}
function resultLabel(g: MyGameSummary): string {
if (g.endReason === 'void') return '무효';
if (g.myRank === null) return '끝남';
if (g.players.length === 1) return '혼자 했어요';
if (g.players.every((p) => p.rank === 1)) return '무승부';
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
return `${g.myRank}등`;
}
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
function MyGames() {
const [games, setGames] = useState<MyGameSummary[] | null>(null);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = (before: string | null) => {
setLoading(true);
api
.myGames(before)
.then((r) => {
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
setNext(r.next);
})
.catch((e) => toast((e as Error).message, 'error'))
.finally(() => setLoading(false));
};
useEffect(() => load(null), []);
return (
<section className="card stack" aria-labelledby="my-games-title">
<h2 id="my-games-title">내 게임 기록</h2>
{games === null ? (
<p className="muted">불러오는 중…</p>
) : games.length === 0 ? (
<p className="muted">아직 다시 볼 게임이 없어요.</p>
) : (
<ul className="my-games">
{games.map((g) => {
const label = resultLabel(g);
return (
<li key={g.gamePk} className="my-game">
<div className="stack" style={{ gap: 4, flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 8 }}>
<strong>{gameName(g.gameId)}</strong>
<span className={`badge${label === '이겼어요' || label === '1등' ? ' success' : ''}`}>{label}</span>
</div>
<span className="muted small">
{new Date(g.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</span>
<span className="small">
{g.players.map((p, i) => (
<span key={i}>
{i > 0 && ' · '}
{p.me ? <strong>나</strong> : p.nickname}
</span>
))}
</span>
</div>
{g.replayable ? (
<Link className="btn btn-soft" to={`/replay/${g.gamePk}`} aria-label={`${gameName(g.gameId)} ${label} 다시 보기`}>
▶ 다시 보기
</Link>
) : (
<span className="muted small" style={{ maxWidth: 110, textAlign: 'right' }}>
다시 볼 수 없어요
</span>
)}
</li>
);
})}
</ul>
)}
{next && (
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
{loading ? '불러오는 중…' : '더 보기'}
</button>
)}
</section>
);
}

View File

@@ -0,0 +1,261 @@
/**
* 게임 기록 다시 보기 (docs/09 §8, docs/07 §10). Shows a finished game step by step with the game's own
* Board/Side, read-only: `act` does nothing and clicks/keys inside the board are swallowed.
*/
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react';
import { useNavigate, useParams } from 'react-router';
import { applyViewPatch, type ReplayData } from '@bg/shared/lite';
import { TopBar } from '../components/ui';
import { gameName } from '../components/GameNotice';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { loadGameUI } from '../games/registry';
import type { GameUI } from '../games/types';
import { useWide } from '../lib/media';
const STEP_MS = 1000;
export default function ReplayPage() {
const { gamePk = '' } = useParams();
const me = useSession((s) => s.me);
const nav = useNavigate();
const [data, setData] = useState<{ replay: ReplayData; ui: GameUI } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!me) return;
let alive = true;
setData(null);
setError(null);
api.replay(gamePk).then(
async (replay) => {
const ui = await loadGameUI(replay.gameId);
if (!alive) return;
if (ui) setData({ replay, ui });
else setError('이 게임은 다시 볼 수 없어요.');
},
(e) => alive && setError((e as Error).message),
);
return () => {
alive = false;
};
}, [gamePk, me?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Came from the room or the account page: go back there; opened directly: the account page.
const back = () => ((window.history.state as { idx?: number } | null)?.idx ? nav(-1) : nav('/me'));
if (!me || error || !data) {
return (
<>
<TopBar title="다시 보기" onBack={back} />
<main className="page">
{!me || error ? (
<section className="card stack">
<h1>{error ?? '로그인한 뒤에 다시 볼 수 있어요.'}</h1>
<button className="btn btn-primary btn-block" onClick={() => nav('/me')}>
내 게임 기록 보기
</button>
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
홈으로
</button>
</section>
) : (
<p className="muted">기록을 불러오는 중…</p>
)}
</main>
</>
);
}
return <Player data={data.replay} ui={data.ui} onBack={back} />;
}
/** Swallows clicks and pointer/keyboard input inside the board (scrolling still works). */
const swallow = (e: SyntheticEvent) => {
e.stopPropagation();
if (e.type === 'click') e.preventDefault();
};
const swallowKeys = (e: React.KeyboardEvent) => {
if (e.key !== 'Tab') swallow(e);
};
const readOnly = {
className: 'replay-readonly',
onClickCapture: swallow,
onPointerDownCapture: swallow,
onPointerUpCapture: swallow,
onPointerMoveCapture: swallow,
onMouseDownCapture: swallow,
onKeyDownCapture: swallowKeys,
onDragStartCapture: swallow,
};
function Player({ data, ui, onBack }: { data: ReplayData; ui: GameUI; onBack: () => void }) {
const wide = useWide();
const tapConfirm = useSession((s) => s.settings.tapConfirm);
const views = useMemo(() => {
const out: unknown[] = [];
let v: unknown;
for (const f of data.frames) {
v = f.view !== undefined ? f.view : applyViewPatch(v, f.patch);
out.push(v);
}
return out;
}, [data]);
const last = views.length - 1;
const [i, setI] = useState(0);
const [playing, setPlaying] = useState(false);
// The board is remounted on jumps (처음/끝/slider) so its move animations and sounds only follow single steps.
const [mount, setMount] = useState(0);
const at = useRef(0);
const go = (n: number, keepPlaying = false) => {
const t = Math.max(0, Math.min(last, n));
if (Math.abs(t - at.current) > 1) setMount((m) => m + 1);
at.current = t;
setI(t);
if (!keepPlaying) setPlaying(false);
};
const goRef = useRef(go);
goRef.current = go;
useEffect(() => {
if (!playing) return;
if (i >= last) return setPlaying(false);
const t = setTimeout(() => goRef.current(i + 1, true), STEP_MS);
return () => clearTimeout(t);
}, [playing, i, last]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
if (el?.closest('input, textarea, select, [contenteditable="true"]')) return;
const d = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
if (d) goRef.current(at.current + d);
else if (e.key === 'Home') goRef.current(0);
else if (e.key === 'End') goRef.current(Number.MAX_SAFE_INTEGER);
else return;
e.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const frame = data.frames[i]!;
const view = views[i];
const nick = (id: string | null) => (id && data.players[id]?.nickname) || '알 수 없음';
const who = frame.actor === data.me ? '내가' : `${nick(frame.actor)}님이`;
const desc =
i === 0
? '게임을 시작했어요.'
: frame.auto
? `시간이 지나거나 자리를 떠서 ${frame.actor === data.me ? '내' : `${nick(frame.actor)}님`} 차례를 자동으로 넘겼어요.`
: `${who} 움직였어요.`;
// The game's own status line talks to a live player ("놓을 곳을 눌러 주세요"); say whose turn it was instead.
const turn = frame.active.length
? `이때 ${frame.active.map((p) => (p === data.me ? '내' : `${nick(p)}님`)).join(', ')} 차례였어요.`
: ui.statusText(view, data.me, data.players);
const sec = Math.max(0, Math.round((frame.at - data.startedAt) / 1000));
const clock = `${Math.floor(sec / 60)}분 ${String(sec % 60).padStart(2, '0')}초`;
const props = {
view,
me: data.me,
players: data.players,
act: () => {},
pending: false,
deadline: null,
active: frame.active,
tapConfirm,
};
const when = new Date(data.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const status = (
<div className="card turn-status" role="status" style={{ padding: '14px 16px', fontWeight: 700, fontSize: '1.15rem', width: '100%' }}>
{i === last ? `끝: ${data.result.summary}` : turn}
</div>
);
const controls = (
<section className="replay-controls" aria-label="다시 보기 조작">
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong className="replay-count">
{i === 0 ? '시작' : `${i}번째`} <span className="muted">/ 전체 {last}번</span>
</strong>
<span className="muted small">{clock}</span>
</div>
<p className="small replay-desc" aria-live="polite">
{desc}
</p>
<input
type="range"
className="replay-slider"
min={0}
max={last}
step={1}
value={i}
onChange={(e) => go(Number(e.target.value))}
aria-label="몇 번째 장면인지 고르기"
aria-valuetext={i === 0 ? '시작' : `${i}번째 / 전체 ${last}번`}
/>
<div className="replay-buttons">
<button className="btn btn-secondary" onClick={() => go(0)} disabled={i === 0} aria-label="처음으로">
<span aria-hidden>⏮</span>처음
</button>
<button className="btn btn-secondary" onClick={() => go(i - 1)} disabled={i === 0} aria-label="이전">
<span aria-hidden>◁</span>이전
</button>
<button
className="btn btn-primary"
onClick={() => {
if (playing) return setPlaying(false);
if (i >= last) go(0);
setPlaying(true);
}}
disabled={last === 0}
aria-label={playing ? '멈춤' : '재생'}
>
<span aria-hidden>{playing ? '⏸' : '▶'}</span>
{playing ? '멈춤' : '재생'}
</button>
<button className="btn btn-secondary" onClick={() => go(i + 1)} disabled={i === last} aria-label="다음">
<span aria-hidden>▷</span>다음
</button>
<button className="btn btn-secondary" onClick={() => go(last)} disabled={i === last} aria-label="끝으로">
<span aria-hidden>⏭</span>끝
</button>
</div>
</section>
);
return (
<>
<nav className="room-bar" aria-label="다시 보기 메뉴">
<h1 className="sr-only">{gameName(data.gameId)} 다시 보기</h1>
<div className="room-bar-inner">
<button className="btn btn-ghost btn-sm" onClick={onBack}>
← 뒤로
</button>
<strong style={{ fontSize: '1.1rem' }}>{gameName(data.gameId)} 다시 보기</strong>
<span className="muted small wide-only">{when}</span>
</div>
</nav>
<div className={`game-layout${wide ? '' : ' replay-narrow'}`}>
<div className="game-main">
{!wide && status}
<div {...readOnly}>
<ui.Board key={mount} {...props} />
</div>
</div>
<aside className="game-side" aria-label="게임 정보">
{wide && controls}
{wide && status}
<div {...readOnly}>
<ui.Side key={mount} {...props} />
</div>
</aside>
</div>
{!wide && (
<div className="bottom-bar">
<div className="bottom-bar-inner">{controls}</div>
</div>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Link, useNavigate, useParams } from 'react-router';
import { 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';
@@ -622,6 +622,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
판 다시 보기
</button>
{room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && (
<Link className="btn btn-secondary btn-block" to={`/replay/${room.lastGamePk}`}>
▶ 처음부터 다시 보기
</Link>
)}
<button className="btn btn-ghost btn-block" onClick={onLeave}>
방 나가기
</button>

View File

@@ -957,3 +957,80 @@ label.field {
.schema-group > legend {
grid-column: 1 / -1;
}
/* Replay (docs/09 §8): read-only board, step controls in the side panel (PC) or a bottom bar (phone/tablet) */
.replay-readonly {
display: contents;
}
.replay-readonly button,
.replay-readonly [role='button'],
.replay-readonly svg {
cursor: default;
}
.replay-controls {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 12px;
}
.bottom-bar .replay-controls {
box-shadow: none;
background: none;
padding: 0;
}
.replay-count {
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.replay-desc {
margin: 0;
min-height: 1.4em;
}
.replay-slider {
width: 100%;
height: 36px;
margin: 0;
accent-color: var(--primary);
}
.replay-buttons {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.replay-buttons .btn {
min-width: 0;
min-height: 60px;
padding: 0 4px;
flex-direction: column;
gap: 2px;
font-size: 0.95rem;
line-height: 1.1;
}
.replay-buttons .btn span {
font-size: 1.2rem;
}
.game-layout.replay-narrow {
padding-bottom: calc(240px + env(safe-area-inset-bottom));
}
.my-games {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.my-game {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--border);
}
.my-game:first-child {
border-top: 0;
padding-top: 0;
}