웹: 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:
@@ -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="게임 고르기">
|
||||
|
||||
Reference in New Issue
Block a user