Files
joke-app/apps/web/src/pages/Home.tsx
EJClaw c5c30d4ada M2: 오셀로 추가, 게임 공통 E2E, 접근성 검사(axe) 통과
- 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수,
  무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개)
- 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴)
- e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수
- e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건
  (h1, 랜드마크, 제목 순서 정리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:19:05 +09:00

139 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { CATALOG, formatRoomCode } from '@bg/shared/lite';
import { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { CodeInput } from '../components/CodeInput';
import { GameGrid } from '../components/GameGrid';
import { gameName } from '../components/GameNotice';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
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 [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [codeKey, setCodeKey] = useState(0);
const available = catalog.filter((g) => g.available);
useEffect(() => {
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
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);
}
});
const joinCode = async (code: string) => {
try {
await api.room(code);
nav(`/r/${code}`);
} catch (e) {
toast((e as Error).message, 'error');
setCodeKey((k) => k + 1);
}
};
return (
<main className="page" style={{ gap: 28 }}>
<h1 className="sr-only">{useSession.getState().siteName}</h1>
{activeRoom && (
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block" style={{ minHeight: 60, fontSize: '1.15rem' }}>
▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)})
</Link>
)}
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 20 }}>
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
<h2>친구랑 같이 하기</h2>
<p className="muted">방을 만들면 코드와 링크가 바로 나와요. 친구에게 보내 주세요.</p>
<button
className="btn btn-primary btn-block"
style={{ minHeight: 72, fontSize: '1.3rem' }}
disabled={busy}
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}
>
+ 방 만들기
</button>
</div>
<div className="card stack" style={{ padding: 24 }}>
<h2>코드로 참여하기</h2>
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
</div>
</section>
<div className="two-col" style={{ gridTemplateColumns: undefined }}>
<section className="stack" style={{ gap: 16 }}>
<h2>게임 고르기</h2>
<GameGrid games={available} />
<p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>
</section>
<aside className="stack" style={{ gap: 12 }}>
<h2>지금 열린 방</h2>
{rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden>
{GAME_ICON[r.gameId] ?? '🎲'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span>
<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>
))}
</aside>
</div>
{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>
);
}