웹: 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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user