웹: 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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
apps/web/src/components/ChatPanel.tsx
Normal file
106
apps/web/src/components/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
24
apps/web/src/components/GameGrid.tsx
Normal file
24
apps/web/src/components/GameGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/SiteHeader.tsx
Normal file
51
apps/web/src/components/SiteHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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<OmokView>) {
|
||||
function OmokBoard({ view: v, act, pending, tapConfirm }: BoardProps<OmokView>) {
|
||||
const n = v.size;
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) / 2, y: (n - 1) / 2 });
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(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 (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
onPointerMove={(e) => {
|
||||
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) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.38} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
<text x={i} y={-0.6}>
|
||||
{COLS[i]}
|
||||
</text>
|
||||
<text x={-0.62} y={i}>
|
||||
{n - i}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||
{v.forbidden.map((p) => (
|
||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{v.board.map((c, i) => (c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />))}
|
||||
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
|
||||
{[...winSet].map((i) => (
|
||||
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
|
||||
))}
|
||||
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.46} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{`${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-hand panel on PC (below the board on narrow screens). */
|
||||
function OmokSide({ view: v, me, players, act, pending, deadline }: BoardProps<OmokView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(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 (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
<PlayerBar view={v} color={opp ?? 'white'} players={players} deadline={deadline} me={me} />
|
||||
<div className="board-wrap">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default' }}
|
||||
onPointerUp={(e) => {
|
||||
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) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||
{v.forbidden.map((p) => (
|
||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{v.board.map((c, i) =>
|
||||
c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />,
|
||||
)}
|
||||
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
|
||||
{[...winSet].map((i) => (
|
||||
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
|
||||
))}
|
||||
{preview && v.myColor && <Stone color={v.myColor} cx={preview.x} cy={preview.y} r={0.46} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} className="kbd-cursor" />}
|
||||
</svg>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{forbidden.has(preview.y * n + preview.x) ? '여기는 둘 수 없어요' : `${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlayerBar view={v} color={v.myColor ?? 'black'} players={players} deadline={deadline} me={me} />
|
||||
|
||||
<>
|
||||
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` (${undoLeft}번 남음)` : ''}
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부 제안
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
@@ -208,13 +230,14 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
</div>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<span>상대의 답을 기다리는 중이에요…</span>
|
||||
<button className="btn btn-ghost" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
@@ -232,10 +255,29 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && (
|
||||
<ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
</div>
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: OmokView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {label(v.size, m.pt)}
|
||||
{m.auto ? '*' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,6 +477,7 @@ function Rules({ options }: { options?: OmokOptions }) {
|
||||
|
||||
const ui: GameUI<OmokView, OmokOptions> = {
|
||||
Board: OmokBoard,
|
||||
Side: OmokSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
|
||||
@@ -18,7 +18,10 @@ export interface OptionsProps<O = unknown> {
|
||||
}
|
||||
|
||||
export interface GameUI<V = any, O = any> {
|
||||
/** The board / table only (center of the game screen). */
|
||||
Board: ComponentType<BoardProps<V>>;
|
||||
/** Players, clocks, action buttons, move history (right panel on PC). */
|
||||
Side: ComponentType<BoardProps<V>>;
|
||||
statusText(view: V, me: string | null, players: Record<string, PublicUser>): string;
|
||||
isMyTurn(view: V, me: string | null): boolean;
|
||||
Options: ComponentType<OptionsProps<O>>;
|
||||
|
||||
17
apps/web/src/lib/media.ts
Normal file
17
apps/web/src/lib/media.ts
Normal file
@@ -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)');
|
||||
@@ -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 (
|
||||
<>
|
||||
<ConnectionBanner />
|
||||
<SiteHeader />
|
||||
<Announcement />
|
||||
<Toasts />
|
||||
<Suspense fallback={<div className="page muted">불러오는 중…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/j" element={<JoinPage />} />
|
||||
<Route path="/games" element={<GamesPage />} />
|
||||
<Route path="/r/:code" element={<RoomPage />} />
|
||||
<Route path="/g/:gameId" element={<GamePage />} />
|
||||
<Route path="/rules/:gameId" element={<RulesPage />} />
|
||||
|
||||
@@ -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<RoomPreview>('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`),
|
||||
};
|
||||
|
||||
18
apps/web/src/pages/GamesPage.tsx
Normal file
18
apps/web/src/pages/GamesPage.tsx
Normal file
@@ -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 (
|
||||
<main className="page">
|
||||
<h1>게임 목록</h1>
|
||||
<GameGrid games={catalog.filter((g) => g.available)} />
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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="게임 고르기">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TopBar
|
||||
title={
|
||||
<>
|
||||
{g?.nameKo} · <CodeLabel code={code} />
|
||||
</>
|
||||
}
|
||||
onBack={() => nav('/')}
|
||||
right={
|
||||
<>
|
||||
<div className="room-bar">
|
||||
<div className="room-bar-inner">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로">
|
||||
← 홈
|
||||
</button>
|
||||
<strong className="rb-game" style={{ fontSize: '1.1rem' }}>
|
||||
{g.nameKo}
|
||||
</strong>
|
||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
<button className="icon-btn" onClick={() => setMenu(true)} aria-label="메뉴">
|
||||
⋯<span className="lbl">메뉴</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||
📖 규칙
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMenu(true)} aria-label="메뉴">
|
||||
⋯ 메뉴
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{needsRefresh && (
|
||||
<div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}>
|
||||
<span>새 버전이 있어요.</span>
|
||||
@@ -173,7 +179,15 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
<Lobby room={room} ui={ui} me={me} />
|
||||
)}
|
||||
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
|
||||
<ChatDock room={room} me={me} open={chatOpen} onClose={() => setChatOpen(false)} />
|
||||
<EmoteBubbles room={room} />
|
||||
{chatOpen && (
|
||||
<Sheet onClose={() => setChatOpen(false)} label="채팅">
|
||||
<ChatPanel room={room} me={me} />
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
|
||||
닫기
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
{menu && (
|
||||
<Sheet onClose={() => setMenu(false)} label="메뉴">
|
||||
<h2>메뉴</h2>
|
||||
@@ -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 (
|
||||
<main className="page">
|
||||
<section className="card stack" style={{ textAlign: 'center' }} aria-label="초대하기">
|
||||
<p className="muted">친구에게 이 코드를 알려 주세요</p>
|
||||
<p style={{ fontSize: '3rem', fontWeight: 800, lineHeight: 1.1 }}>
|
||||
<CodeLabel code={room.code} />
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CopyButton text={room.code} label="🔢 코드 복사" />
|
||||
<CopyButton text={url} label="🔗 링크 복사" />
|
||||
<ShareButton title={`${gName} 같이 해요`} text={shareText} url={url} />
|
||||
<button className="btn btn-secondary" onClick={() => setQr(true)}>
|
||||
📷 QR 보기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="stack" aria-label="자리">
|
||||
<h2>
|
||||
자리 ({seated}/{room.maxPlayers})
|
||||
</h2>
|
||||
const wide = useWide();
|
||||
const invite = (
|
||||
<section className="card stack" style={{ textAlign: 'center' }} aria-label="초대하기">
|
||||
<p className="muted">친구에게 이 코드를 알려 주세요</p>
|
||||
<p style={{ fontSize: '3.2rem', fontWeight: 800, lineHeight: 1.1 }}>
|
||||
<CodeLabel code={room.code} />
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
|
||||
<CopyButton text={room.code} label="🔢 코드 복사" />
|
||||
<CopyButton text={url} label="🔗 링크 복사" />
|
||||
<ShareButton title={`${gName} 같이 해요`} text={shareText} url={url} />
|
||||
<button className="btn btn-secondary" onClick={() => setQr(true)}>
|
||||
📷 QR 보기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const seats = (
|
||||
<section className="stack" aria-label="자리">
|
||||
<h2>
|
||||
자리 ({seated}/{room.maxPlayers})
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
|
||||
{room.seats.map((s, i) =>
|
||||
s ? (
|
||||
<div key={i} className="card row" style={{ gap: 12 }}>
|
||||
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={s.user} size={44} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
|
||||
{s.user.nickname}
|
||||
{s.user.id === me.id && <span className="muted small"> (나)</span>}
|
||||
@@ -288,59 +304,83 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
|
||||
</div>
|
||||
) : (
|
||||
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 72, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
|
||||
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
|
||||
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
|
||||
{mySeat >= 0 && room.allowSpectators && (
|
||||
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'unseat' })}>
|
||||
관전으로 바꾸기
|
||||
</div>
|
||||
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
|
||||
{mySeat >= 0 && room.allowSpectators && (
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => socket.send({ t: 'unseat' })}>
|
||||
관전으로 바꾸기
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
const settingsCard = (
|
||||
<section className="card stack">
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3>게임 설정</h3>
|
||||
<p className="muted">{ui.optionsSummary(room.options)}</p>
|
||||
</div>
|
||||
{isHost && !wide && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSettings(true)}>
|
||||
설정 바꾸기
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack">
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3>게임 설정</h3>
|
||||
<p className="muted">{ui.optionsSummary(room.options)}</p>
|
||||
</div>
|
||||
{isHost && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSettings(true)}>
|
||||
설정 바꾸기
|
||||
</div>
|
||||
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||
<GameNotice gameId={room.gameId} />
|
||||
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
|
||||
</section>
|
||||
);
|
||||
const action = (
|
||||
<div className="bottom-bar inline-wide">
|
||||
<div className="bottom-bar-inner">
|
||||
{isHost ? (
|
||||
<button className="btn btn-primary" style={{ minHeight: 64, fontSize: '1.2rem' }} disabled={need > 0} onClick={() => (notReady > 0 ? setAskStart(true) : socket.send({ t: 'start' }))}>
|
||||
{need > 0 ? `${need}명 더 필요해요` : '▶ 게임 시작'}
|
||||
</button>
|
||||
) : mySeat >= 0 ? (
|
||||
room.seats[mySeat]!.ready ? (
|
||||
<button className="btn btn-secondary" style={{ minHeight: 64 }} onClick={() => socket.send({ t: 'ready', ready: false })}>
|
||||
준비 취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}
|
||||
</p>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||
<GameNotice gameId={room.gameId} />
|
||||
</section>
|
||||
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
{isHost ? (
|
||||
<button className="btn btn-primary" disabled={need > 0} onClick={() => (notReady > 0 ? setAskStart(true) : socket.send({ t: 'start' }))}>
|
||||
{need > 0 ? `${need}명 더 필요해요` : '▶ 게임 시작'}
|
||||
</button>
|
||||
) : mySeat >= 0 ? (
|
||||
room.seats[mySeat]!.ready ? (
|
||||
<button className="btn btn-secondary" onClick={() => socket.send({ t: 'ready', ready: false })}>
|
||||
준비 취소
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-success" onClick={() => socket.send({ t: 'ready', ready: true })}>
|
||||
✓ 준비하기
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!room.seats.some((s) => s === null)} onClick={() => socket.send({ t: 'seat' })}>
|
||||
{room.seats.some((s) => s === null) ? '자리에 앉기' : '자리가 다 찼어요 (관전 중)'}
|
||||
<button className="btn btn-success" style={{ minHeight: 64, fontSize: '1.2rem' }} onClick={() => socket.send({ t: 'ready', ready: true })}>
|
||||
✓ 준비하기
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button className="btn btn-primary" style={{ minHeight: 64 }} disabled={!room.seats.some((s) => s === null)} onClick={() => socket.send({ t: 'seat' })}>
|
||||
{room.seats.some((s) => s === null) ? '자리에 앉기' : '자리가 다 찼어요 (관전 중)'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="two-col">
|
||||
<div className="stack" style={{ gap: 20 }}>
|
||||
{invite}
|
||||
{wide ? settingsCard : seats}
|
||||
{wide && (
|
||||
<section className="card stack">
|
||||
<h3>규칙 요약</h3>
|
||||
<ui.Rules options={room.options} />
|
||||
</section>
|
||||
)}
|
||||
{!wide && settingsCard}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 16, position: wide ? 'sticky' : undefined, top: 140 }}>
|
||||
{wide && seats}
|
||||
{action}
|
||||
{wide && <ChatPanel room={room} me={me} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,13 +428,30 @@ function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) {
|
||||
}
|
||||
|
||||
function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) {
|
||||
return (
|
||||
<Sheet onClose={onClose} label="게임 설정">
|
||||
<h2>게임 설정</h2>
|
||||
<SettingsForm room={room} ui={ui} onDone={onClose} />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) => (
|
||||
<div className="segmented">
|
||||
@@ -407,26 +464,35 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Sheet onClose={onClose} label="게임 설정">
|
||||
<h2>게임 설정</h2>
|
||||
<div className="stack" style={{ gap: 14 }}>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
|
||||
<ui.Options value={options} onChange={setOptions} />
|
||||
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
|
||||
<strong>방 공개</strong>
|
||||
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나(공개)', '친구만')}
|
||||
<strong>관전</strong>
|
||||
{seg(allowSpectators, setAllowSpectators, '관전 허용', '관전 안 됨')}
|
||||
<strong>채팅</strong>
|
||||
{seg(chatEnabled, setChatEnabled, '채팅 켜기', '채팅 끄기')}
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>방 공개</strong>
|
||||
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>관전</strong>
|
||||
{seg(allowSpectators, setAllowSpectators, '허용', '안 됨')}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>채팅</strong>
|
||||
{seg(chatEnabled, setChatEnabled, '켜기', '끄기')}
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={inline && !dirty} onClick={save}>
|
||||
{inline ? (dirty ? '설정 저장' : '저장됨') : '저장'}
|
||||
</button>
|
||||
{!inline && (
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onDone}>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, PublicUser>; 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 = (
|
||||
<div
|
||||
className="card"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
fontWeight: 700,
|
||||
fontSize: '1.15rem',
|
||||
background: myTurn ? 'var(--primary)' : 'var(--surface)',
|
||||
color: myTurn ? 'var(--primary-text)' : 'var(--text)',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)}
|
||||
{slow && <span className="small"> · 연결 확인 중…</span>}
|
||||
{!isPlayer && (
|
||||
<span className="badge" style={{ marginLeft: 8 }}>
|
||||
관전 중
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<main className="page" style={{ gap: 12 }}>
|
||||
<div
|
||||
className="card"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontWeight: 700,
|
||||
fontSize: '1.1rem',
|
||||
background: myTurn ? 'var(--primary)' : 'var(--surface)',
|
||||
color: myTurn ? 'var(--primary-text)' : 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)}
|
||||
{slow && <span className="small"> · 연결 확인 중…</span>}
|
||||
{!isPlayer && <span className="badge" style={{ marginLeft: 8 }}>관전 중</span>}
|
||||
<div className="game-layout">
|
||||
<div className="game-main">
|
||||
{!wide && status}
|
||||
<ui.Board {...props} />
|
||||
</div>
|
||||
<ui.Board
|
||||
view={game.view}
|
||||
me={me.id}
|
||||
players={players}
|
||||
act={(a) => socket.act(a)}
|
||||
pending={pending}
|
||||
deadline={game.deadline}
|
||||
active={game.active}
|
||||
tapConfirm={tapConfirm}
|
||||
/>
|
||||
</main>
|
||||
<aside className="game-side" aria-label="게임 정보">
|
||||
{wide && status}
|
||||
<ui.Side {...props} />
|
||||
{wide && <ChatPanel room={room} me={me} title={false} />}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button className="icon-btn" onClick={onOpen} aria-label={`채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}>
|
||||
💬<span className="lbl">채팅</span>
|
||||
{unread > 0 && <span className="badge warning">{unread}</span>}
|
||||
<button className="btn btn-ghost btn-sm" onClick={onOpen} aria-label={`채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}>
|
||||
💬 채팅{unread > 0 && <span className="badge warning">{unread}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const [hiddenUsers, setHiddenUsers] = useState<Set<string>>(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 (
|
||||
<>
|
||||
<div style={{ position: 'fixed', right: 16, bottom: 'calc(96px + env(safe-area-inset-bottom))', zIndex: 16, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', pointerEvents: 'none' }}>
|
||||
{emotes.map((e) => (
|
||||
<div key={e.id} className="toast" style={{ pointerEvents: 'none' }}>
|
||||
{nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{open && (
|
||||
<Sheet onClose={() => setOpen(false)} label="채팅">
|
||||
<h2>채팅</h2>
|
||||
<div className="row">
|
||||
{EMOTES.map((id) => (
|
||||
<button key={id} className="btn btn-secondary btn-sm" onClick={() => socket.send({ t: 'emote', id })}>
|
||||
{EMOTE_LABEL[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div ref={listRef} className="stack" style={{ maxHeight: '40vh', overflow: 'auto', gap: 8 }} aria-live="polite">
|
||||
{chat.length === 0 && <p className="muted">아직 메시지가 없어요.</p>}
|
||||
{chat
|
||||
.filter((m) => !m.from || !hiddenUsers.has(m.from.id))
|
||||
.map((m) => (
|
||||
<div key={m.id}>
|
||||
<strong>{m.from?.nickname ?? '알림'}</strong>{' '}
|
||||
{m.from && m.from.id !== me.id && (
|
||||
<button className="btn-ghost small muted" style={{ border: 0, background: 'none', cursor: 'pointer' }} onClick={() => setHiddenUsers(new Set([...hiddenUsers, m.from!.id]))}>
|
||||
(가리기)
|
||||
</button>
|
||||
)}
|
||||
<div>{m.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{room.chatEnabled ? (
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) });
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<input className="input" style={{ flex: 1 }} value={text} onChange={(e) => setText(e.target.value)} maxLength={CHAT_MAX} placeholder="메시지 입력" aria-label="메시지" enterKeyHint="send" />
|
||||
<button className="btn btn-primary">보내기</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">이 방은 채팅을 쓰지 않아요. 위의 반응 버튼은 쓸 수 있어요.</p>
|
||||
)}
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TopBar title={`관리자 · ${sup ? '슈퍼어드민' : '어드민'}`} onBack={() => nav('/me')} />
|
||||
<nav className="row" style={{ padding: '8px 12px', gap: 6, overflowX: 'auto', flexWrap: 'nowrap', borderBottom: '1px solid var(--border)' }} aria-label="관리자 메뉴">
|
||||
<div className="admin-layout">
|
||||
<nav className="admin-side" aria-label="관리자 메뉴">
|
||||
<div className="muted small" style={{ padding: '0 16px 8px', fontWeight: 700 }}>
|
||||
관리자 · {sup ? '슈퍼어드민' : '어드민'}
|
||||
</div>
|
||||
{tabs
|
||||
.filter((t) => t[2])
|
||||
.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to ? `/admin/${to}` : "/admin"}
|
||||
end={to === ''}
|
||||
className={({ isActive }) => `btn btn-sm ${isActive ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<NavLink key={to} to={to ? `/admin/${to}` : '/admin'} end={to === ''}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ justifyContent: 'flex-start', marginTop: 8 }} onClick={() => nav('/')}>
|
||||
← 사이트로
|
||||
</button>
|
||||
</nav>
|
||||
<main className="page">
|
||||
<main className="stack" style={{ gap: 20, minWidth: 0, paddingBottom: 96 }}>
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
@@ -82,7 +81,7 @@ export default function AdminApp() {
|
||||
<Route path="audit" element={<Audit />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +258,7 @@ function GameEditor() {
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar sticky-save">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
@@ -408,7 +407,7 @@ function Site() {
|
||||
<textarea className="input" style={{ minHeight: 140, padding: 12 }} value={s.bannedText} onChange={(e) => setS({ ...s, bannedText: e.target.value })} />
|
||||
</label>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar sticky-save">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
@@ -439,17 +438,32 @@ function Users() {
|
||||
<button className="btn btn-primary">검색</button>
|
||||
</form>
|
||||
{d && <p className="muted small">전체 {d.total}명</p>}
|
||||
{d?.users.map((u) => (
|
||||
<button key={u.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => setOpen(u.id)}>
|
||||
<div>
|
||||
<strong>{u.nickname}</strong> {u.role !== 'user' && <span className="badge">{u.role === 'superadmin' ? '슈퍼어드민' : '어드민'}</span>}
|
||||
<div className="muted small">
|
||||
{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'} · 마지막 접속 {time(u.lastSeenAt)}
|
||||
</div>
|
||||
</div>
|
||||
{u.banned && <span className="badge warning">제한</span>}
|
||||
</button>
|
||||
))}
|
||||
{d && (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>닉네임</th>
|
||||
<th className="wide-only">로그인</th>
|
||||
<th>권한</th>
|
||||
<th className="wide-only">마지막 접속</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.users.map((u) => (
|
||||
<tr key={u.id} className="clickable" onClick={() => setOpen(u.id)} tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setOpen(u.id)}>
|
||||
<td>
|
||||
<strong>{u.nickname}</strong>
|
||||
</td>
|
||||
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
|
||||
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
|
||||
<td className="wide-only small">{time(u.lastSeenAt)}</td>
|
||||
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{open && <UserSheet id={open} onClose={() => (setOpen(null), reload())} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,11 +58,11 @@ function Field({ schema, value, onChange, path, locked, onToggleLock, root }: Pr
|
||||
onToggleLock={onToggleLock}
|
||||
/>
|
||||
));
|
||||
if (root) return <div className="stack" style={{ gap: 14 }}>{inner}</div>;
|
||||
if (root) return <div className="schema-grid">{inner}</div>;
|
||||
return (
|
||||
<fieldset className="stack" style={{ border: '2px solid var(--border)', borderRadius: 12, padding: 12, gap: 12 }}>
|
||||
<fieldset className="stack schema-group" style={{ border: '2px solid var(--border)', borderRadius: 12, padding: 12, gap: 12 }}>
|
||||
<legend style={{ fontWeight: 700, padding: '0 6px' }}>{title}</legend>
|
||||
{lock}
|
||||
{lock && <div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end' }}>{lock}</div>}
|
||||
{inner}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,16 @@ export const DEFAULT_SETTINGS: Required<Settings> = {
|
||||
sound: true,
|
||||
vibrate: true,
|
||||
colorBlind: false,
|
||||
tapConfirm: true,
|
||||
// Two-step placing is the default only on touch devices; on PC one click places (docs/07 §3.5).
|
||||
tapConfirm: typeof matchMedia !== 'undefined' ? matchMedia('(pointer: coarse)').matches : true,
|
||||
};
|
||||
|
||||
/** Settings that belong to this device and are never synced to the account. */
|
||||
const DEVICE_ONLY = ['tapConfirm'] as const;
|
||||
const withoutDeviceOnly = (s: Settings): Settings => {
|
||||
const out: Record<string, unknown> = { ...s };
|
||||
for (const k of DEVICE_ONLY) delete out[k];
|
||||
return out as Settings;
|
||||
};
|
||||
|
||||
function loadLocal(): Required<Settings> {
|
||||
@@ -67,7 +76,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[], siteName: '같이 놀자 보드게임', announcement: '', maintenance: false })),
|
||||
api.me().catch(() => ({ me: null }) as Awaited<ReturnType<typeof api.me>>),
|
||||
]);
|
||||
const settings = { ...get().settings, ...(me.settings ?? {}) };
|
||||
const settings = { ...get().settings, ...withoutDeviceOnly(me.settings ?? {}) };
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
document.title = cfg.siteName;
|
||||
@@ -94,7 +103,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
set({ settings });
|
||||
if (get().me) api.updateMe({ settings }).catch(() => {});
|
||||
if (get().me) api.updateMe({ settings: withoutDeviceOnly(settings) }).catch(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -528,3 +528,432 @@ label.field {
|
||||
min-width: min(100%, 320px);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
PC-first layout (docs/07-ui-ux.md §3). Breakpoints: ≥1100 PC, 720–1099 tablet, <720 phone.
|
||||
===================================================================== */
|
||||
.page {
|
||||
max-width: 1280px;
|
||||
padding: 24px 24px 48px;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.page {
|
||||
padding: 16px 16px calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: 64px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.site-header-inner {
|
||||
max-width: 1280px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.site-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.site-nav a {
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-sub);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.site-nav a:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.site-nav a.active {
|
||||
color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
.header-join {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.header-join input {
|
||||
width: 150px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg);
|
||||
padding: 0 12px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.header-join input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.header-join .btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
.header-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.header-user:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.site-header {
|
||||
height: 56px;
|
||||
}
|
||||
.site-header-inner {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-join {
|
||||
display: none;
|
||||
}
|
||||
.site-logo .full {
|
||||
display: none;
|
||||
}
|
||||
.site-logo {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Room bar under the site header */
|
||||
.room-bar {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 25;
|
||||
height: 52px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.room-bar-inner {
|
||||
max-width: 1600px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.room-bar {
|
||||
top: 56px;
|
||||
}
|
||||
.room-bar-inner {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two-column page (lobby, home) */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 400px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.two-col {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Game screen: board + right panel, fits the viewport height on PC */
|
||||
.game-layout {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 64px - 52px);
|
||||
min-height: 560px;
|
||||
}
|
||||
.game-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.game-side {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
.game-side .chat-panel {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.game-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
max-width: min(100%, calc(100vh - 64px - 52px - 120px));
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.board-wrap {
|
||||
max-width: min(100%, 720px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat panel (inline on PC) */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 12px;
|
||||
}
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.game-side .chat-list {
|
||||
max-height: none;
|
||||
}
|
||||
.chat-line {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.chat-line strong {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.emote-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.emote-row button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.emote-row button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* Bottom bar only floats on phones; on wider screens it renders inline. */
|
||||
@media (min-width: 720px) {
|
||||
.bottom-bar.inline-wide {
|
||||
position: static;
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.bottom-bar.inline-wide .bottom-bar-inner {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover affordances (mouse) */
|
||||
@media (hover: hover) {
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.game-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
.game-card {
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.phone-only {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.phone-only {
|
||||
display: initial;
|
||||
}
|
||||
.wide-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin: sidebar layout */
|
||||
.admin-layout {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
.admin-side {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-side a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-side a:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.admin-side a.active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.admin-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 12px;
|
||||
}
|
||||
.admin-side {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.admin-side a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface-2);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.data-table tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.data-table tr.clickable:hover td {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* Page sub-header (TopBar) sits under the site header and scrolls with the page. */
|
||||
.topbar {
|
||||
position: static;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
background: transparent;
|
||||
border-bottom: 0;
|
||||
padding: 12px 16px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.bottom-bar.sticky-save {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: auto;
|
||||
right: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
}
|
||||
.bottom-bar.sticky-save .bottom-bar-inner {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
.room-bar-inner > *,
|
||||
.room-bar-inner .btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.room-bar-inner {
|
||||
gap: 4px;
|
||||
}
|
||||
.room-bar .rb-game {
|
||||
display: none;
|
||||
}
|
||||
.room-bar .btn-sm {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.schema-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.schema-grid > .schema-group {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.schema-group > .schema-grid,
|
||||
.schema-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
.schema-group > legend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user