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

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

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

View File

@@ -0,0 +1,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>
);
}

View File

@@ -1,26 +1,36 @@
/** 홈: 큰 버튼 두 개 + 진행 중 게임 + 게임 카드 (docs/06 §2). */
import { useState } from 'react';
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { CATALOG } from '@bg/shared/lite';
import { Avatar, Sheet } from '../components/ui';
import { CATALOG, formatRoomCode } from '@bg/shared/lite';
import { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { CodeInput } from '../components/CodeInput';
import { GameGrid } from '../components/GameGrid';
import { gameName } from '../components/GameNotice';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
import { formatRoomCode } from '@bg/shared/lite';
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
export function Home() {
const { me, activeRoom, siteName } = useSession();
const { me, activeRoom } = useSession();
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
const nav = useNavigate();
const [picker, setPicker] = useState(false);
const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false);
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [codeKey, setCodeKey] = useState(0);
const available = catalog.filter((g) => g.available);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
useEffect(() => {
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const create = (gameId: string) =>
withName(async () => {
setBusy(true);
@@ -33,53 +43,71 @@ export function Home() {
setBusy(false);
}
});
const joinCode = async (code: string) => {
try {
await api.room(code);
nav(`/r/${code}`);
} catch (e) {
toast((e as Error).message, 'error');
setCodeKey((k) => k + 1);
}
};
return (
<main className="page">
<header className="row" style={{ justifyContent: 'space-between' }}>
<h1>{siteName}</h1>
<Link to="/me" className="btn btn-ghost" aria-label="내 정보">
{me ? <Avatar user={me} size={36} /> : '👤'} <span className="small">{me ? me.nickname : '내 정보'}</span>
</Link>
</header>
<main className="page" style={{ gap: 28 }}>
{activeRoom && (
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block">
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block" style={{ minHeight: 60, fontSize: '1.15rem' }}>
▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)})
</Link>
)}
<div className="stack">
<button className="btn btn-primary btn-block" style={{ minHeight: 72, fontSize: '1.25rem' }} disabled={busy} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}>
+ 방 만들기
</button>
<Link to="/j" className="btn btn-secondary btn-block" style={{ minHeight: 72, fontSize: '1.25rem' }}>
🔢 코드로 참여하기
</Link>
</div>
<section className="stack">
<h2>게임 고르기</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
{available.map((g) => (
<Link key={g.id} to={`/g/${g.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: '2.4rem' }} aria-hidden>
{GAME_ICON[g.id] ?? '🎲'}
</span>
<strong style={{ fontSize: '1.15rem' }}>{g.nameKo}</strong>
<span className="muted small">{g.blurb}</span>
<span className="small">
{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · {g.minutes}분
</span>
</Link>
))}
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 20 }}>
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
<h2>친구랑 같이 하기</h2>
<p className="muted">방을 만들면 코드와 링크가 바로 나와요. 친구에게 보내 주세요.</p>
<button
className="btn btn-primary btn-block"
style={{ minHeight: 72, fontSize: '1.3rem' }}
disabled={busy}
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}
>
+ 방 만들기
</button>
</div>
<div className="card stack" style={{ padding: 24 }}>
<h2>코드로 참여하기</h2>
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
</div>
</section>
<section className="stack">
<h2>곧 나와요</h2>
<p className="muted">{UPCOMING.join(' · ')}</p>
</section>
<div className="two-col" style={{ gridTemplateColumns: undefined }}>
<section className="stack" style={{ gap: 16 }}>
<h2>게임 고르기</h2>
<GameGrid games={available} />
<p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>
</section>
<aside className="stack" style={{ gap: 12 }}>
<h2>지금 열린 방</h2>
{rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden>
{GAME_ICON[r.gameId] ?? '🎲'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span>
<div className="muted small">
{r.seated}/{r.max}명 · {formatRoomCode(r.code)}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</aside>
</div>
{picker && (
<Sheet onClose={() => setPicker(false)} label="게임 고르기">

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);

View File

@@ -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>
);