From f81f20a4bffb69f4016802751d8f20a5caa1b384 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sun, 4 Oct 2026 05:04:00 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9:=20PC=20=EC=A4=91=EC=8B=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보) - 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms) - 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임) - 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅) - 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값, 기기별 설정), GameUI에 Side 패널 추가 - 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트 - 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정 - 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정) - E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC Co-Authored-By: Claude Opus 5.5 --- apps/server/src/http/app.ts | 1 + apps/server/src/rooms/manager.ts | 5 +- apps/web/src/components/ChatPanel.tsx | 106 ++++++ apps/web/src/components/CodeInput.tsx | 7 +- apps/web/src/components/GameGrid.tsx | 24 ++ apps/web/src/components/SiteHeader.tsx | 51 +++ apps/web/src/games/omok/index.tsx | 207 +++++++----- apps/web/src/games/types.ts | 3 + apps/web/src/lib/media.ts | 17 + apps/web/src/main.tsx | 4 + apps/web/src/net/api.ts | 1 + apps/web/src/pages/GamesPage.tsx | 18 + apps/web/src/pages/Home.tsx | 116 ++++--- apps/web/src/pages/RoomPage.tsx | 417 ++++++++++++----------- apps/web/src/pages/admin/AdminApp.tsx | 66 ++-- apps/web/src/pages/admin/SchemaForm.tsx | 6 +- apps/web/src/store/session.ts | 15 +- apps/web/src/styles/global.css | 429 ++++++++++++++++++++++++ e2e/admin.e2e.ts | 10 +- e2e/omok.e2e.ts | 61 ++-- 20 files changed, 1172 insertions(+), 392 deletions(-) create mode 100644 apps/web/src/components/ChatPanel.tsx create mode 100644 apps/web/src/components/GameGrid.tsx create mode 100644 apps/web/src/components/SiteHeader.tsx create mode 100644 apps/web/src/lib/media.ts create mode 100644 apps/web/src/pages/GamesPage.tsx diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 95e85e9..580cec9 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -280,6 +280,7 @@ export function createHttpApp(d: HttpDeps) { }); app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) })); + app.get('/api/public-rooms', (c) => c.json({ rooms: d.rooms.publicRooms(null).slice(0, 50) })); app.route( '/api/admin', diff --git a/apps/server/src/rooms/manager.ts b/apps/server/src/rooms/manager.ts index 98b5621..e5b21bd 100644 --- a/apps/server/src/rooms/manager.ts +++ b/apps/server/src/rooms/manager.ts @@ -130,11 +130,12 @@ export class RoomManager { } } - publicRooms(gameId: string): { code: string; host: string; seated: number; max: number; options: unknown }[] { + publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] { return this.all() - .filter((r) => r.config.gameId === gameId && r.config.visibility === 'public' && r.status === 'lobby') + .filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby') .map((r) => ({ code: r.code, + gameId: r.config.gameId, host: this.deps.users.publicUser(r.hostId)?.nickname ?? '', seated: r.seatedIds().length, max: r.config.maxPlayers, diff --git a/apps/web/src/components/ChatPanel.tsx b/apps/web/src/components/ChatPanel.tsx new file mode 100644 index 0000000..2e4438c --- /dev/null +++ b/apps/web/src/components/ChatPanel.tsx @@ -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>(new Set()); + const listRef = useRef(null); + const inputRef = useRef(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 ( +
+ {title &&

채팅

} +
+ {chat.length === 0 &&

아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.

} + {chat + .filter((m) => !m.from || !hidden.has(m.from.id)) + .map((m) => ( +
+ {m.from?.nickname ?? '알림'} + {m.text} + {m.from && m.from.id !== me.id && ( + + )} +
+ ))} +
+
+ {EMOTES.map((id) => ( + + ))} +
+ {room.chatEnabled ? ( +
{ + e.preventDefault(); + if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) }); + setText(''); + }} + > + setText(e.target.value)} + onKeyDown={(e) => e.key === 'Escape' && (e.currentTarget as HTMLInputElement).blur()} + maxLength={CHAT_MAX} + placeholder="메시지 입력 (Enter)" + aria-label="메시지" + enterKeyHint="send" + /> + +
+ ) : ( +

이 방은 채팅을 쓰지 않아요. 반응 버튼은 쓸 수 있어요.

+ )} +
+ ); +} + +/** 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 ( +
+ {emotes.map((e) => ( +
+ {nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/CodeInput.tsx b/apps/web/src/components/CodeInput.tsx index ef91d6e..14abc33 100644 --- a/apps/web/src/components/CodeInput.tsx +++ b/apps/web/src/components/CodeInput.tsx @@ -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(null); const inputRef = useRef(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); diff --git a/apps/web/src/components/GameGrid.tsx b/apps/web/src/components/GameGrid.tsx new file mode 100644 index 0000000..c505b56 --- /dev/null +++ b/apps/web/src/components/GameGrid.tsx @@ -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 ( +
+ {games.map((g) => ( + + + {GAME_ICON[g.id] ?? '🎲'} + + {g.nameKo} + {g.blurb} + + {g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} + {g.minutes}분 + {g.level} + + + ))} +
+ ); +} diff --git a/apps/web/src/components/SiteHeader.tsx b/apps/web/src/components/SiteHeader.tsx new file mode 100644 index 0000000..c651fb1 --- /dev/null +++ b/apps/web/src/components/SiteHeader.tsx @@ -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 ( +
+
+ + + {siteName} + + +
+ setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" /> + +
+ + {me ? : 👤} + {me ? me.nickname : '시작하기'} + +
+
+ ); +} diff --git a/apps/web/src/games/omok/index.tsx b/apps/web/src/games/omok/index.tsx index bf2a60f..3900c86 100644 --- a/apps/web/src/games/omok/index.tsx +++ b/apps/web/src/games/omok/index.tsx @@ -31,12 +31,12 @@ function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: numb ); } -function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps) { +function OmokBoard({ view: v, act, pending, tapConfirm }: BoardProps) { const n = v.size; const myTurn = v.phase === 'playing' && v.myColor === v.turn; const [preview, setPreview] = useState(null); + const [hover, setHover] = useState(null); const [cursor, setCursor] = useState({ x: (n - 1) / 2, y: (n - 1) / 2 }); - const [confirm, setConfirm] = useState(null); const [announce, setAnnounce] = useState(''); const [kbd, setKbd] = useState(false); const svgRef = useRef(null); @@ -67,18 +67,20 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: const place = (p: Pt) => { act({ type: 'place', x: p.x, y: p.y }); setPreview(null); + setHover(null); }; - const tap = (p: Pt) => { + const tap = (p: Pt, mouse: boolean) => { setCursor(p); - if (!myTurn) return; - if (v.board[p.y * n + p.x] !== 0) return; + if (!myTurn || v.board[p.y * n + p.x] !== 0) return; if (forbidden.has(p.y * n + p.x)) { haptic([30, 30, 30]); setAnnounce('여기는 금수 자리라 둘 수 없어요.'); + return; } - if (!tapConfirm) { - place(p); + // Mouse: one click places (hover already shows the preview). Touch: optional two-step confirm. + if (mouse || !tapConfirm) { + if (canPlace(p)) place(p); return; } if (preview && preview.x === p.x && preview.y === p.y) place(p); @@ -110,6 +112,85 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: }; const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x)); + const ghost = preview ?? (hover && canPlace(hover) ? hover : null); + const pad = 0.9; + return ( +
+ { + if (e.pointerType !== 'mouse' || !myTurn) return; + const p = pointToCell(e); + if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p); + }} + onPointerLeave={() => setHover(null)} + onPointerUp={(e) => { + setKbd(false); + const p = pointToCell(e); + if (p) tap(p, e.pointerType === 'mouse'); + }} + tabIndex={0} + role="grid" + aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`} + onKeyDown={onKey} + > + {Array.from({ length: n }, (_, i) => ( + + + + + ))} + {Array.from({ length: n }, (_, i) => ( + + + {COLS[i]} + + + {n - i} + + + ))} + {stars(n).map((p) => ( + + ))} + {myTurn && v.hints?.urgent.map((p) => )} + {myTurn && v.hints?.warn.map((p) => )} + {v.forbidden.map((p) => ( + + + + + ))} + {v.board.map((c, i) => (c === 0 ? null : ))} + {v.lastMove && } + {[...winSet].map((i) => ( + + ))} + {ghost && v.myColor && } + {myTurn && kbd && } + +

+ {announce} +

+ {preview && myTurn && ( +
+ + +
+ )} +
+ ); +} + +/** Right-hand panel on PC (below the board on narrow screens). */ +function OmokSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(null); const incoming = v.phase === 'playing' && v.myColor ? v.undo.pending && v.undo.pending.by !== v.myColor @@ -120,86 +201,27 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: : null; const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor); const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null; - const opp: Color | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null; - - const pad = 0.6; + const opp: Color = v.myColor === 'white' ? 'black' : 'white'; + const mine: Color = v.myColor ?? 'black'; return ( -
- -
- { - setKbd(false); - const p = pointToCell(e); - if (p) tap(p); - }} - tabIndex={0} - role="grid" - aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`} - onKeyDown={onKey} - > - {Array.from({ length: n }, (_, i) => ( - - - - - ))} - {stars(n).map((p) => ( - - ))} - {myTurn && v.hints?.urgent.map((p) => )} - {myTurn && v.hints?.warn.map((p) => )} - {v.forbidden.map((p) => ( - - - - - ))} - {v.board.map((c, i) => - c === 0 ? null : , - )} - {v.lastMove && } - {[...winSet].map((i) => ( - - ))} - {preview && v.myColor && } - {myTurn && kbd && } - -
-

- {announce} -

- - {preview && myTurn && ( -
- - -
- )} - - - + <> + + {v.phase === 'playing' && v.myColor && ( -
+
{v.undo.allowed && ( )} {v.draw.allowed && ( )}
)} {outgoing && ( -
- 상대의 답을 기다리는 중이에요… -
)} + {incoming && ( {}} label="상대의 요청">

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

@@ -232,10 +255,29 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: {confirm === 'resign' && ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> )} - {confirm === 'draw' && ( - act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} /> - )} -
+ {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} + + ); +} + +function MoveList({ view: v }: { view: OmokView }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.moveCount]); + if (v.moveCount === 0) return null; + return ( +
+ 기보 ({v.moveCount}수) +
    + {v.moves.map((m, i) => ( +
  1. + {i + 1}. {m.color === 'black' ? '●' : '○'} {label(v.size, m.pt)} + {m.auto ? '*' : ''} +
  2. + ))} +
+
); } @@ -435,6 +477,7 @@ function Rules({ options }: { options?: OmokOptions }) { const ui: GameUI = { Board: OmokBoard, + Side: OmokSide, statusText, isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me, Options, diff --git a/apps/web/src/games/types.ts b/apps/web/src/games/types.ts index 34829bc..342bc10 100644 --- a/apps/web/src/games/types.ts +++ b/apps/web/src/games/types.ts @@ -18,7 +18,10 @@ export interface OptionsProps { } export interface GameUI { + /** The board / table only (center of the game screen). */ Board: ComponentType>; + /** Players, clocks, action buttons, move history (right panel on PC). */ + Side: ComponentType>; statusText(view: V, me: string | null, players: Record): string; isMyTurn(view: V, me: string | null): boolean; Options: ComponentType>; diff --git a/apps/web/src/lib/media.ts b/apps/web/src/lib/media.ts new file mode 100644 index 0000000..7c4b8f5 --- /dev/null +++ b/apps/web/src/lib/media.ts @@ -0,0 +1,17 @@ +import { useSyncExternalStore } from 'react'; + +/** Subscribes to a CSS media query. */ +export function useMedia(query: string): boolean { + return useSyncExternalStore( + (cb) => { + const m = matchMedia(query); + m.addEventListener('change', cb); + return () => m.removeEventListener('change', cb); + }, + () => matchMedia(query).matches, + () => false, + ); +} + +export const useWide = () => useMedia('(min-width: 1100px)'); +export const usePhone = () => useMedia('(max-width: 719px)'); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5679446..2a9444b 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import { ConnectionBanner, Toasts } from './components/ui'; import { useSession } from './store/session'; import { toast } from './store/toast'; import { Home } from './pages/Home'; +import { SiteHeader } from './components/SiteHeader'; const JoinPage = lazy(() => import('./pages/JoinPage')); const RoomPage = lazy(() => import('./pages/RoomPage')); @@ -15,6 +16,7 @@ const MePage = lazy(() => import('./pages/MePage')); const RulesPage = lazy(() => import('./pages/RulesPage')); const NotFound = lazy(() => import('./pages/NotFound')); const AdminApp = lazy(() => import('./pages/admin/AdminApp')); +const GamesPage = lazy(() => import('./pages/GamesPage')); function Announcement() { const text = useSession((s) => s.announcement); @@ -53,12 +55,14 @@ function Shell() { return ( <> + 불러오는 중…
}> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts index a4311d3..cd1a690 100644 --- a/apps/web/src/net/api.ts +++ b/apps/web/src/net/api.ts @@ -69,6 +69,7 @@ export const api = { createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}), room: (code: string) => call('GET', `/api/rooms/${code}`), + allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'), publicRooms: (gameId: string) => call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`), }; diff --git a/apps/web/src/pages/GamesPage.tsx b/apps/web/src/pages/GamesPage.tsx new file mode 100644 index 0000000..14ed896 --- /dev/null +++ b/apps/web/src/pages/GamesPage.tsx @@ -0,0 +1,18 @@ +import { CATALOG } from '@bg/shared/lite'; +import { GameGrid } from '../components/GameGrid'; +import { UPCOMING } from '../lib/catalog-ui'; +import { useSession } from '../store/session'; + +export default function GamesPage() { + const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG)); + return ( +
+

게임 목록

+ g.available)} /> +
+

곧 나와요

+

{UPCOMING.join(' · ')}

+
+
+ ); +} diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index c42abaf..b86d9b5 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -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 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 ( -
-
-

{siteName}

- - {me ? : '👤'} {me ? me.nickname : '내 정보'} - -
- +
{activeRoom && ( - + ▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)}) )} -
- - - 🔢 코드로 참여하기 - -
- -
-

게임 고르기

-
- {available.map((g) => ( - - - {GAME_ICON[g.id] ?? '🎲'} - - {g.nameKo} - {g.blurb} - - {g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · {g.minutes}분 - - - ))} +
+
+

친구랑 같이 하기

+

방을 만들면 코드와 링크가 바로 나와요. 친구에게 보내 주세요.

+ +
+
+

코드로 참여하기

+

받은 숫자 6자리를 넣으면 바로 들어가요.

+
-
-

곧 나와요

-

{UPCOMING.join(' · ')}

-
+
+
+

게임 고르기

+ +

곧 나와요: {UPCOMING.join(' · ')}

+
+ +
{picker && ( setPicker(false)} label="게임 고르기"> diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 1e802ae..78bfb38 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -1,8 +1,7 @@ /** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { create } from 'zustand'; import { useNavigate, useParams } from 'react-router'; -import { CHAT_MAX, EMOTE_LABEL, EMOTES, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite'; +import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite'; import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; import { GameNotice, gameName } from '../components/GameNotice'; @@ -13,6 +12,8 @@ import { useSession } from '../store/session'; import { loadGameUI } from '../games/registry'; import type { GameUI } from '../games/types'; import { sounds } from '../lib/feedback'; +import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel'; +import { useWide } from '../lib/media'; export default function RoomPage() { const { code = '' } = useParams(); @@ -143,22 +144,27 @@ function Room({ code, me }: { code: string; me: PublicUser }) { return ( <> - - {g?.nameKo} · - - } - onBack={() => nav('/')} - right={ - <> +
+
+ + + {g.nameKo} + + 방 🔗} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> + + setChatOpen(true)} /> - - - } - /> + + + +
+
{needsRefresh && (
새 버전이 있어요. @@ -173,7 +179,15 @@ function Room({ code, me }: { code: string; me: PublicUser }) { )} {room.status === 'finished' && result && game && } - setChatOpen(false)} /> + + {chatOpen && ( + setChatOpen(false)} label="채팅"> + + + + )} {menu && ( setMenu(false)} label="메뉴">

메뉴

@@ -248,33 +262,35 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; const [askStart, setAskStart] = useState(false); - return ( -
-
-

친구에게 이 코드를 알려 주세요

-

- -

-
- - - - -
-
- -
-

- 자리 ({seated}/{room.maxPlayers}) -

+ const wide = useWide(); + const invite = ( +
+

친구에게 이 코드를 알려 주세요

+

+ +

+
+ + + + +
+
+ ); + const seats = ( +
+

+ 자리 ({seated}/{room.maxPlayers}) +

+
4 ? '1fr 1fr' : '1fr', gap: 10 }}> {room.seats.map((s, i) => s ? ( -
+
-
+
{room.hostId === s.user.id && 👑 } {s.user.nickname} {s.user.id === me.id && (나)} @@ -288,59 +304,83 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {isHost && s.user.id !== me.id && }
) : ( - ), )} - {room.spectators.length > 0 &&

관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}

} - {mySeat >= 0 && room.allowSpectators && ( -
+ {room.spectators.length > 0 &&

관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}

} + {mySeat >= 0 && room.allowSpectators && ( + + )} +
+ ); + const settingsCard = ( +
+
+
+

게임 설정

+

{ui.optionsSummary(room.options)}

+
+ {isHost && !wide && ( + )} -
- -
-
-
-

게임 설정

-

{ui.optionsSummary(room.options)}

-
- {isHost && ( -
+

{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}

+ {room.lockedOptions.length > 0 &&

🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.

} + + {isHost && wide && } +
+ ); + const action = ( +
+
+ {isHost ? ( + + ) : mySeat >= 0 ? ( + room.seats[mySeat]!.ready ? ( + - )} -
-

- {room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'} -

- {room.lockedOptions.length > 0 &&

🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.

} - -
- -
-
- {isHost ? ( - - ) : mySeat >= 0 ? ( - room.seats[mySeat]!.ready ? ( - - ) : ( - - ) ) : ( - + ) + ) : ( + + )} +
+
+ ); + + return ( +
+
+
+ {invite} + {wide ? settingsCard : seats} + {wide && ( +
+

규칙 요약

+ +
)} + {!wide && settingsCard} +
+
+ {wide && seats} + {action} + {wide && }
@@ -388,13 +428,30 @@ function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) { } function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) { + return ( + +

게임 설정

+ +
+ ); +} + +/** Host settings: game options + room options. Inline on PC, in a sheet on narrow screens. */ +function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) { const [options, setOptions] = useState(room.options); const [visibility, setVisibility] = useState(room.visibility); const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators); const [chatEnabled, setChatEnabled] = useState(room.chatEnabled); + // Follow server-side changes (e.g. admin locks) when not editing. + useEffect(() => setOptions(room.options), [room.options]); + const dirty = + JSON.stringify(options) !== JSON.stringify(room.options) || + visibility !== room.visibility || + allowSpectators !== room.allowSpectators || + chatEnabled !== room.chatEnabled; const save = () => { socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled }); - onClose(); + onDone?.(); }; const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
@@ -407,26 +464,35 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl
); return ( - -

게임 설정

+
{room.lockedOptions.length > 0 &&

🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.

}
- 방 공개 - {seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나(공개)', '친구만')} - 관전 - {seg(allowSpectators, setAllowSpectators, '관전 허용', '관전 안 됨')} - 채팅 - {seg(chatEnabled, setChatEnabled, '채팅 켜기', '채팅 끄기')} -
- - +
+
+ 방 공개 + {seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')} +
+
+ 관전 + {seg(allowSpectators, setAllowSpectators, '허용', '안 됨')} +
+
+ 채팅 + {seg(chatEnabled, setChatEnabled, '켜기', '끄기')} +
- +
+ + {!inline && ( + + )} +
+
); } @@ -435,37 +501,54 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui: GameUI; me: PublicUser; players: Record; pending: boolean; slow: boolean }) { const game = useRoom((s) => s.game)!; const tapConfirm = useSession((s) => s.settings.tapConfirm); + const wide = useWide(); const myTurn = ui.isMyTurn(game.view, me.id); const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined; + const props = { + view: game.view, + me: me.id, + players, + act: (a: unknown) => socket.act(a), + pending, + deadline: game.deadline, + active: game.active, + tapConfirm, + }; + const status = ( +
+ {room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)} + {slow && · 연결 확인 중…} + {!isPlayer && ( + + 관전 중 + + )} +
+ ); return ( -
-
- {room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)} - {slow && · 연결 확인 중…} - {!isPlayer && 관전 중} +
+
+ {!wide && status} +
- socket.act(a)} - pending={pending} - deadline={game.deadline} - active={game.active} - tapConfirm={tapConfirm} - /> -
+ +
); } @@ -539,87 +622,13 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi // ---------------------------------------------------------------- chat -/** Unread counter shared between the top-bar button and the chat sheet. */ -const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 })); - function ChatButton({ onOpen }: { onOpen: () => void }) { const total = useRoom((s) => s.chat.length); const seen = useChatSeen((s) => s.seen); const unread = Math.max(0, total - seen); return ( - ); } - -function ChatDock({ room, me, open, onClose }: { room: RoomView; me: PublicUser; open: boolean; onClose: () => void }) { - const chat = useRoom((s) => s.chat); - const emotes = useRoom((s) => s.emotes); - const setOpen = (v: boolean) => !v && onClose(); - const [text, setText] = useState(''); - const listRef = useRef(null); - const [hiddenUsers, setHiddenUsers] = useState>(new Set()); - useEffect(() => { - if (open) { - useChatSeen.setState({ seen: chat.length }); - listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); - } - }, [open, chat.length]); - const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? ''; - return ( - <> -
- {emotes.map((e) => ( -
- {nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''} -
- ))} -
- {open && ( - setOpen(false)} label="채팅"> -

채팅

-
- {EMOTES.map((id) => ( - - ))} -
-
- {chat.length === 0 &&

아직 메시지가 없어요.

} - {chat - .filter((m) => !m.from || !hiddenUsers.has(m.from.id)) - .map((m) => ( -
- {m.from?.nickname ?? '알림'}{' '} - {m.from && m.from.id !== me.id && ( - - )} -
{m.text}
-
- ))} -
- {room.chatEnabled ? ( -
{ - e.preventDefault(); - if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) }); - setText(''); - }} - > - setText(e.target.value)} maxLength={CHAT_MAX} placeholder="메시지 입력" aria-label="메시지" enterKeyHint="send" /> - -
- ) : ( -

이 방은 채팅을 쓰지 않아요. 위의 반응 버튼은 쓸 수 있어요.

- )} -
- )} - - ); -} diff --git a/apps/web/src/pages/admin/AdminApp.tsx b/apps/web/src/pages/admin/AdminApp.tsx index 70e64a9..3edfd37 100644 --- a/apps/web/src/pages/admin/AdminApp.tsx +++ b/apps/web/src/pages/admin/AdminApp.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router'; import { formatRoomCode } from '@bg/shared/lite'; -import { ConfirmSheet, Sheet, TopBar } from '../../components/ui'; +import { ConfirmSheet, Sheet } from '../../components/ui'; import { apiCall, discordLoginUrl, type Role } from '../../net/api'; import { useSession } from '../../store/session'; import { toast } from '../../store/toast'; @@ -53,24 +53,23 @@ export default function AdminApp() { ['audit', '기록', true], ]; return ( - <> - nav('/me')} /> -
-
+