디자인: 게임천국 느낌 아이콘, 홈 단순화(큰 버튼 3개 + 탭), 헤더 로그인/계정 메뉴, 로그인 페이지 분리

- 아이콘: 게임패드 + 천사 고리 + 반짝이, scripts/pwa-icons.ts가 favicon.svg와 PNG를 한 디자인에서 생성
- 홈: 방 만들기·코드로 참여(/j)·컴퓨터와 연습 버튼, [게임 고르기 | 지금 열린 방] 탭
- 헤더: 로그인 전 [로그인] → /login, 로그인 뒤 닉네임 → [설정][로그아웃] 메뉴
- /login: 로그인만, /me(설정)는 로그인 안 했으면 /login?next=/me로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-07 12:10:11 +09:00
parent 6390e3e64c
commit bdaaa23af6
13 changed files with 353 additions and 104 deletions

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c3aed"/><stop offset=".55" stop-color="#db2777"/><stop offset="1" stop-color="#f97316"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#bg)"/><ellipse cx="32" cy="14.5" rx="11" ry="3.6" fill="none" stroke="#fde047" stroke-width="2.6"/><path d="M20 21h24a10 10 0 0 1 9.6 7.2l3.3 11.6a5.6 5.6 0 0 1-9.5 5.3L43 40H21l-4.4 5.1a5.6 5.6 0 0 1-9.5-5.3l3.3-11.6A10 10 0 0 1 20 21z" fill="#fff" stroke="#1e1b4b" stroke-width="2.2" stroke-linejoin="round"/><path d="M19.6 26.2h2.8v3h3v2.8h-3v3h-2.8v-3h-3v-2.8h3z" fill="#1e1b4b"/><circle cx="43" cy="26.6" r="2.3" fill="#facc15" stroke="#1e1b4b" stroke-width="1"/><circle cx="46.8" cy="30.6" r="2.3" fill="#ef4444" stroke="#1e1b4b" stroke-width="1"/><circle cx="43" cy="34.6" r="2.3" fill="#22c55e" stroke="#1e1b4b" stroke-width="1"/><circle cx="39.2" cy="30.6" r="2.3" fill="#3b82f6" stroke="#1e1b4b" stroke-width="1"/><path d="M53 6.4Q53 11 57.6 11Q53 11 53 15.6Q53 11 48.4 11Q53 11 53 6.4z" fill="#fde047"/><path d="M10.5 11Q10.5 14 13.5 14Q10.5 14 10.5 17Q10.5 14 7.5 14Q10.5 14 10.5 11z" fill="#fff"/><path d="M54 50Q54 53 57 53Q54 53 54 56Q54 53 51 53Q54 53 54 50z" fill="#fff"/><path d="M11 51.8Q11 54 13.2 54Q11 54 11 56.2Q11 54 8.8 54Q11 54 11 51.8z" fill="#fde047"/></svg>

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,11 +1,11 @@
/** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */
import { useState } from 'react';
import { useEffect, useRef, 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';
import { Avatar, ConfirmSheet } from './ui';
export function SiteHeader() {
const { me, siteName, role } = useSession();
@@ -41,11 +41,71 @@ export function SiteHeader() {
<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>
{me ? (
<AccountMenu />
) : (
<Link to="/login" className="header-user header-login">
로그인
</Link>
)}
</div>
</header>
);
}
/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] [로그아웃] 메뉴. */
function AccountMenu() {
const me = useSession((s) => s.me)!;
const nav = useNavigate();
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="account-menu" ref={ref}>
<button type="button" className="header-user" aria-haspopup="menu" aria-expanded={open} aria-label={`내 계정: ${me.nickname}`} onClick={() => setOpen((o) => !o)}>
<Avatar user={me} size={36} />
<span className="header-user-name">{me.nickname}</span>
<span aria-hidden className="small muted">
▾
</span>
</button>
{open && (
<div className="account-menu-list" role="menu" aria-label="내 계정">
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/me'))}>
⚙️ 설정
</button>
<button type="button" role="menuitem" onClick={() => (setOpen(false), setConfirm(true))}>
🚪 로그아웃
</button>
</div>
)}
{confirm && (
<ConfirmSheet
title="로그아웃할까요?"
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
confirm="로그아웃"
danger={me.kind === 'guest'}
onConfirm={async () => {
await api.logout();
nav('/');
await useSession.getState().refresh();
}}
onClose={() => setConfirm(false)}
/>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ const JoinPage = lazy(() => import('./pages/JoinPage'));
const RoomPage = lazy(() => import('./pages/RoomPage'));
const GamePage = lazy(() => import('./pages/GamePage'));
const MePage = lazy(() => import('./pages/MePage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const ReplayPage = lazy(() => import('./pages/ReplayPage'));
const RulesPage = lazy(() => import('./pages/RulesPage'));
const NotFound = lazy(() => import('./pages/NotFound'));
@@ -68,6 +69,7 @@ function Shell() {
<Route path="/r/:code" element={<RoomPage />} />
<Route path="/g/:gameId" element={<GamePage />} />
<Route path="/rules/:gameId" element={<RulesPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/replay/:gamePk" element={<ReplayPage />} />
<Route path="/admin/*" element={<AdminApp />} />

View File

@@ -1,10 +1,9 @@
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
/** 홈 (docs/07-ui-ux.md §3.3): 큰 버튼 3개(방 만들기·코드로 참여·연습) + [게임 고르기 | 지금 열린 방] 탭. */
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { CATALOG, botsAllowed, 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 { InstallCard } from '../components/InstallCard';
import { gameName } from '../components/GameNotice';
@@ -21,7 +20,7 @@ export function Home() {
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 [tab, setTab] = useState<'games' | 'rooms'>('games');
const available = catalog.filter((g) => g.available);
const practiceGames = available.filter((g) => botsAllowed(g.id));
@@ -45,15 +44,6 @@ 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" style={{ gap: 28 }}>
@@ -64,62 +54,68 @@ export function Home() {
</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('room'))}
>
+ 방 만들기
</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 className="home-actions" aria-label="바로 하기">
<button className="home-action primary" aria-label="+ 방 만들기" disabled={busy} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))}>
<span className="home-action-icon" aria-hidden>
+
</span>
<span className="home-action-title">방 만들기</span>
<span className="home-action-sub">친구에게 링크 보내기</span>
</button>
<button className="home-action" aria-label="코드로 참여" onClick={() => nav('/j')}>
<span className="home-action-icon" aria-hidden>
🔢
</span>
<span className="home-action-title">코드로 참여</span>
<span className="home-action-sub">받은 숫자 6자리</span>
</button>
{practiceGames.length > 0 && (
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
<h2>혼자 연습하기</h2>
<p className="muted">컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.</p>
<button className="btn btn-secondary btn-block" style={{ minHeight: 60 }} disabled={busy} onClick={() => setPicker('practice')}>
🤖 컴퓨터와 연습하기
</button>
</div>
<button className="home-action" aria-label="컴퓨터와 연습" disabled={busy} onClick={() => setPicker('practice')}>
<span className="home-action-icon" aria-hidden>
🤖
</span>
<span className="home-action-title">컴퓨터와 연습</span>
<span className="home-action-sub">기록에 안 남아요</span>
</button>
)}
</section>
<div className="two-col" style={{ gridTemplateColumns: undefined }}>
<section className="stack" style={{ gap: 16 }}>
<h2>게임 고르기</h2>
<GameGrid games={available} />
{UPCOMING.length > 0 && <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)}
<section className="stack" style={{ gap: 16 }}>
<div className="home-tabs" role="tablist" aria-label="홈 보기">
<button role="tab" id="tab-games" aria-controls="panel-games" aria-selected={tab === 'games'} onClick={() => setTab('games')}>
🎮 게임 고르기
</button>
<button role="tab" id="tab-rooms" aria-controls="panel-rooms" aria-selected={tab === 'rooms'} onClick={() => setTab('rooms')}>
🚪 지금 열린 방{rooms.length > 0 && <span className="badge">{rooms.length}</span>}
</button>
</div>
{tab === 'games' ? (
<div role="tabpanel" id="panel-games" aria-labelledby="tab-games" className="stack" style={{ gap: 16 }}>
<GameGrid games={available} />
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</div>
) : (
<div role="tabpanel" id="panel-rooms" aria-labelledby="tab-rooms" className="stack" style={{ gap: 12, maxWidth: 720 }}>
{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>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</aside>
</div>
))}
</div>
)}
</section>
<InstallCard />

View File

@@ -0,0 +1,26 @@
/** 로그인만 있는 페이지: 닉네임으로 시작 또는 디스코드 로그인. 설정은 로그인 뒤 헤더 계정 메뉴 → 설정(/me). */
import { Navigate, useNavigate, useSearchParams } from 'react-router';
import { TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { useSession } from '../store/session';
/** Only same-site paths, so ?next= can't send people to another site. */
const safeNext = (v: string | null) => (v && v.startsWith('/') && !v.startsWith('//') ? v : '/');
export default function LoginPage() {
const nav = useNavigate();
const me = useSession((s) => s.me);
const next = safeNext(useSearchParams()[0].get('next'));
if (me) return <Navigate to={next} replace />;
return (
<>
<TopBar title="로그인" onBack={() => nav('/')} />
<main className="page" style={{ maxWidth: 480 }}>
<section className="card stack" style={{ padding: 24 }}>
<h1>로그인</h1>
<NicknameForm next={next} onDone={() => nav(next, { replace: true })} />
</section>
</main>
</>
);
}

View File

@@ -1,9 +1,8 @@
/** 계정 페이지 (docs/05 §7). */
/** 설정(계정) 페이지 (docs/05 §7). 헤더 계정 메뉴 → 설정으로 들어와요. */
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { Link, Navigate, useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { gameName } from '../components/GameNotice';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
@@ -67,24 +66,12 @@ export default function MePage() {
</section>
);
if (!me) {
return (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack">
<h2>시작하기</h2>
<NicknameForm next="/me" />
</section>
{display}
</main>
</>
);
}
// 로그인 안 했으면 로그인 페이지로 (로그인 페이지에는 로그인만 있어요).
if (!me) return <Navigate to="/login?next=/me" replace />;
return (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<TopBar title="설정" onBack={() => nav('/')} />
<main className="page">
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
<Avatar user={me} size={88} />
@@ -177,8 +164,8 @@ export default function MePage() {
danger={me.kind === 'guest'}
onConfirm={async () => {
await api.logout();
await useSession.getState().refresh();
nav('/');
await useSession.getState().refresh();
}}
onClose={() => setConfirm(null)}
/>
@@ -189,8 +176,8 @@ export default function MePage() {
confirm="모두 로그아웃"
onConfirm={async () => {
await api.logoutAll();
await useSession.getState().refresh();
nav('/');
await useSession.getState().refresh();
}}
onClose={() => setConfirm(null)}
/>
@@ -203,8 +190,8 @@ export default function MePage() {
danger
onConfirm={async () => {
await api.deleteMe();
await useSession.getState().refresh();
nav('/');
await useSession.getState().refresh();
}}
onClose={() => setConfirm(null)}
/>

View File

@@ -626,6 +626,60 @@ label.field {
.header-user:hover {
background: var(--surface-2);
}
button.header-user {
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.header-login {
padding: 0 18px;
min-height: 42px;
border: 2px solid var(--primary);
color: var(--primary);
}
.header-login:hover {
background: var(--primary-soft);
}
.header-user-name {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-menu {
position: relative;
}
.account-menu-list {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 168px;
padding: 6px;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
z-index: 40;
}
.account-menu-list button {
min-height: var(--tap);
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.account-menu-list button:hover,
.account-menu-list button:focus-visible {
background: var(--surface-2);
}
@media (max-width: 1099px) {
.site-nav {
display: none;
@@ -648,6 +702,9 @@ label.field {
.site-logo {
flex: 1;
}
.header-user-name {
max-width: 96px;
}
}
/* Room bar under the site header */
@@ -1034,3 +1091,97 @@ label.field {
border-top: 0;
padding-top: 0;
}
/* Home: 큰 버튼 3개 + 탭 (docs/07 §3.3) */
.home-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.home-action {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-height: 132px;
padding: 20px 22px;
border: 2px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
box-shadow: var(--shadow);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.home-action:disabled {
opacity: 0.6;
cursor: default;
}
.home-action.primary {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-text);
}
.home-action-icon {
font-size: 2.2rem;
line-height: 1;
margin-bottom: auto;
}
.home-action-title {
font-size: 1.3rem;
font-weight: 800;
}
.home-action-sub {
font-size: 0.95rem;
color: var(--text-sub);
}
.home-action.primary .home-action-sub {
color: inherit;
}
@media (hover: hover) {
.home-action:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
}
@media (max-width: 719px) {
.home-actions {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.home-action.primary {
grid-column: 1 / -1;
min-height: 96px;
}
.home-action {
min-height: 108px;
padding: 16px;
}
}
.home-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--border);
}
.home-tabs button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--tap);
padding: 0 18px;
margin-bottom: -2px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: var(--text-sub);
font: inherit;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
}
.home-tabs button[aria-selected='true'] {
color: var(--primary);
border-bottom-color: var(--primary);
}