웹: PC 중심 화면으로 재구성

- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보)
- 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms)
- 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임)
- 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅)
- 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값,
  기기별 설정), GameUI에 Side 패널 추가
- 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트
- 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정
- 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정)
- E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 05:04:00 +09:00
parent 847a714077
commit f81f20a4bf
20 changed files with 1172 additions and 392 deletions

View File

@@ -1,26 +1,36 @@
/** 홈: 큰 버튼 두 개 + 진행 중 게임 + 게임 카드 (docs/06 §2). */
import { useState } from 'react';
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { CATALOG } from '@bg/shared/lite';
import { Avatar, Sheet } from '../components/ui';
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 { formatRoomCode } from '@bg/shared/lite';
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
export function Home() {
const { me, activeRoom, siteName } = useSession();
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);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
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);
@@ -33,53 +43,71 @@ export function Home() {
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">
<header className="row" style={{ justifyContent: 'space-between' }}>
<h1>{siteName}</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>
<main className="page" style={{ gap: 28 }}>
{activeRoom && (
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block">
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block" style={{ minHeight: 60, fontSize: '1.15rem' }}>
▶ 하던 게임으로 돌아가기 (방 {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>
))}
<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>
<section className="stack">
<h2>곧 나와요</h2>
<p className="muted">{UPCOMING.join(' · ')}</p>
</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="게임 고르기">