웹: 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

@@ -0,0 +1,106 @@
/** Chat list + quick reactions + input. Inline on PC, inside a sheet on phones. */
import { useEffect, useRef, useState } from 'react';
import { CHAT_MAX, EMOTE_LABEL, EMOTES, type PublicUser, type RoomView } from '@bg/shared/lite';
import { create } from 'zustand';
import { socket, useRoom } from '../net/socket';
export const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 }));
export function ChatPanel({ room, me, title = true }: { room: RoomView; me: PublicUser; title?: boolean }) {
const chat = useRoom((s) => s.chat);
const [text, setText] = useState('');
const [hidden, setHidden] = useState<Set<string>>(new Set());
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
useChatSeen.setState({ seen: chat.length });
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [chat.length]);
// Enter anywhere (outside inputs) focuses chat — PC shortcut (docs/07 §3.5).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.key === 'Enter' && !['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(t.tagName) && t.getAttribute('role') !== 'grid') {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<section className="chat-panel" aria-label="채팅">
{title && <h3>채팅</h3>}
<div ref={listRef} className="chat-list" aria-live="polite">
{chat.length === 0 && <p className="muted small">아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.</p>}
{chat
.filter((m) => !m.from || !hidden.has(m.from.id))
.map((m) => (
<div key={m.id} className="chat-line">
<strong>{m.from?.nickname ?? '알림'}</strong>
{m.text}
{m.from && m.from.id !== me.id && (
<button
className="muted small"
style={{ border: 0, background: 'none', cursor: 'pointer' }}
title="이 사람의 채팅 가리기"
onClick={() => setHidden(new Set([...hidden, m.from!.id]))}
>
가리기
</button>
)}
</div>
))}
</div>
<div className="emote-row">
{EMOTES.map((id) => (
<button key={id} type="button" onClick={() => socket.send({ t: 'emote', id })}>
{EMOTE_LABEL[id]}
</button>
))}
</div>
{room.chatEnabled ? (
<form
className="row"
style={{ flexWrap: 'nowrap' }}
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) });
setText('');
}}
>
<input
ref={inputRef}
className="input"
style={{ flex: 1, minHeight: 44 }}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && (e.currentTarget as HTMLInputElement).blur()}
maxLength={CHAT_MAX}
placeholder="메시지 입력 (Enter)"
aria-label="메시지"
enterKeyHint="send"
/>
<button className="btn btn-primary btn-sm">보내기</button>
</form>
) : (
<p className="muted small">이 방은 채팅을 쓰지 않아요. 반응 버튼은 쓸 수 있어요.</p>
)}
</section>
);
}
/** Floating emote bubbles (both layouts). */
export function EmoteBubbles({ room }: { room: RoomView }) {
const emotes = useRoom((s) => s.emotes);
const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? '';
return (
<div style={{ position: 'fixed', left: '50%', transform: 'translateX(-50%)', bottom: 24, zIndex: 35, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center', pointerEvents: 'none' }}>
{emotes.map((e) => (
<div key={e.id} className="toast">
{nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''}
</div>
))}
</div>
);
}

View File

@@ -2,19 +2,20 @@
import { useEffect, useRef, useState } from 'react';
import { normalizeRoomCode } from '@bg/shared/lite';
export function CodeInput({ onComplete, busy }: { onComplete: (code: string) => void; busy?: boolean }) {
export function CodeInput({ onComplete, busy, autoFocus = true }: { onComplete: (code: string) => void; busy?: boolean; autoFocus?: boolean }) {
const [digits, setDigits] = useState('');
const [clip, setClip] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
if (autoFocus) inputRef.current?.focus();
// Offer a big paste button if the clipboard already holds a code or invite link.
if (!autoFocus) return; // reading the clipboard can prompt; only on the dedicated join page
navigator.clipboard
?.readText?.()
.then((t) => setClip(normalizeRoomCode(t)))
.catch(() => {});
}, []);
}, [autoFocus]);
const set = (v: string) => {
const code = normalizeRoomCode(v);

View File

@@ -0,0 +1,24 @@
import { Link } from 'react-router';
import type { CatalogEntry } from '@bg/shared/lite';
import { GAME_ICON } from '../lib/catalog-ui';
export function GameGrid({ games, columns = 'repeat(auto-fill, minmax(200px, 1fr))' }: { games: CatalogEntry[]; columns?: string }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: columns, gap: 16 }}>
{games.map((g) => (
<Link key={g.id} to={`/g/${g.id}`} className="card game-card" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', flexDirection: 'column', gap: 8, padding: 20 }}>
<span style={{ fontSize: '3rem', lineHeight: 1 }} aria-hidden>
{GAME_ICON[g.id] ?? '🎲'}
</span>
<strong style={{ fontSize: '1.25rem' }}>{g.nameKo}</strong>
<span className="muted">{g.blurb}</span>
<span className="row small" style={{ gap: 6, marginTop: 'auto' }}>
<span className="badge">{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`}</span>
<span className="badge">{g.minutes}분</span>
<span className="badge">{g.level}</span>
</span>
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,51 @@
/** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */
import { useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router';
import { normalizeRoomCode } from '@bg/shared/lite';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
import { Avatar } from './ui';
export function SiteHeader() {
const { me, siteName, role } = useSession();
const [code, setCode] = useState('');
const nav = useNavigate();
const join = async (e: React.FormEvent) => {
e.preventDefault();
const c = normalizeRoomCode(code);
if (!c) return toast('방 코드 6자리를 넣어 주세요.', 'error');
try {
await api.room(c);
setCode('');
nav(`/r/${c}`);
} catch (ex) {
toast((ex as Error).message, 'error');
}
};
return (
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-logo" aria-label={`${siteName} 홈`}>
<img src="/favicon.svg" alt="" width={32} height={32} />
<span className="full">{siteName}</span>
</Link>
<nav className="site-nav" aria-label="주 메뉴">
<NavLink to="/" end>
홈
</NavLink>
<NavLink to="/games">게임 목록</NavLink>
{role !== 'user' && <NavLink to="/admin">관리자</NavLink>}
</nav>
<form className="header-join" onSubmit={join} aria-label="코드로 참여">
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" />
<button className="btn btn-primary btn-sm">참여</button>
</form>
<Link to="/me" className="header-user" aria-label="내 정보">
{me ? <Avatar user={me} size={36} /> : <span style={{ fontSize: 24 }}>👤</span>}
<span className="wide-only">{me ? me.nickname : '시작하기'}</span>
</Link>
</div>
</header>
);
}