- 오셀로: 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>
139 lines
5.5 KiB
TypeScript
139 lines
5.5 KiB
TypeScript
/** 홈 (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>
|
||
);
|
||
}
|