지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.
}
- {rooms.map((r) => (
-
-
- {GAME_ICON[r.gameId] ?? '🎲'}
-
-
-
{gameName(r.gameId)} · {r.host}님의 방
-
- {r.seated}/{r.max}명 · {formatRoomCode(r.code)}
+
+
+
+
+
+ {tab === 'games' ? (
+
+
+ {UPCOMING.length > 0 &&
곧 나와요: {UPCOMING.join(' · ')}
}
+
+ ) : (
+
+ {rooms.length === 0 &&
지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.
}
+ {rooms.map((r) => (
+
+
+ {GAME_ICON[r.gameId] ?? '🎲'}
+
+
+
{gameName(r.gameId)} · {r.host}님의 방
+
+ {r.seated}/{r.max}명 · {formatRoomCode(r.code)}
+
+
-
-
- ))}
-
-
+ ))}
+
+ )}
+
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..a8cc6b2
--- /dev/null
+++ b/apps/web/src/pages/LoginPage.tsx
@@ -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
;
+ return (
+ <>
+
nav('/')} />
+
+
+ 로그인
+ nav(next, { replace: true })} />
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx
index 8b985c2..ba0178c 100644
--- a/apps/web/src/pages/MePage.tsx
+++ b/apps/web/src/pages/MePage.tsx
@@ -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() {
);
- if (!me) {
- return (
- <>
- nav('/')} />
-
-
- {display}
-
- >
- );
- }
+ // 로그인 안 했으면 로그인 페이지로 (로그인 페이지에는 로그인만 있어요).
+ if (!me) return ;
return (
<>
- nav('/')} />
+ nav('/')} />
@@ -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)}
/>
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
index ae6efa1..2298598 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -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);
+}
diff --git a/docs/07-ui-ux.md b/docs/07-ui-ux.md
index 4a6896e..a1d602c 100644
--- a/docs/07-ui-ux.md
+++ b/docs/07-ui-ux.md
@@ -57,17 +57,20 @@
└──────────────────────────────────────────────────────────────────────────┘
```
- 어느 화면에서든 코드를 바로 넣어 참여할 수 있게 상단에 코드 입력칸을 둔다.
+- 오른쪽 끝: 로그인 전에는 [로그인](→ `/login`, 닉네임으로 시작·디스코드 로그인만 있음). 로그인 뒤에는 닉네임(디스코드로 가입했으면 디스코드 이름)을 보이고, 누르면 [⚙️ 설정](→ `/me`)·[🚪 로그아웃] 메뉴가 열린다. 화면·소리 설정은 `/me`에만 있다.
- 내용 폭은 최대 1280px, 가운데 정렬. 게임 화면만 화면 전체 폭을 쓴다.
### 3.3 홈 (PC)
```
┌ 상단 메뉴 ───────────────────────────────────────────────────────────────┐
-│ [+ 방 만들기 (큰 버튼)] [코드로 참여: □□□ □□□ ] [▶ 하던 게임으로] │
-├──────────────────────────────────────────────────────┬───────────────────┤
-│ 게임 카드 격자 (4~5열, 카드에 인원·시간·난이도) │ 지금 열린 공개 방 │
-│ │ (게임·인원·참여) │
-└──────────────────────────────────────────────────────┴───────────────────┘
+│ [▶ 하던 게임으로 돌아가기] (있을 때만) │
+│ [+ 방 만들기 (파란 큰 버튼)] [🔢 코드로 참여 → /j] [🤖 컴퓨터와 연습] │
+├──────────────────────────────────────────────────────────────────────────┤
+│ [🎮 게임 고르기] [🚪 지금 열린 방 (n)] ← 탭, 한 번에 하나만 보임 │
+│ 게임 카드 격자 (4~5열, 카드에 인원·시간·난이도) 또는 공개 방 목록(게임·인원·참여) │
+└──────────────────────────────────────────────────────────────────────────┘
```
+- 첫 화면에 다 넣지 않는다. 설명 글은 버튼 아래 한 줄만, 나머지는 탭이나 버튼을 눌러 들어간다.
### 3.4 대기실 (PC)
```
@@ -163,7 +166,7 @@
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)
## 11. PWA (홈 화면 추가·오프라인 안내)
-- 매니페스트 `/manifest.webmanifest`: 이름 "같이 놀자 보드게임", 짧은 이름 "같이 놀자", `lang: ko`, 시작 `/`, `display: standalone`, 테마·배경 `#F7F5F0`(`--bg`). 아이콘 `public/icons/`: 192·512(favicon 그대로, 둥근 모서리), 512 maskable(가득 채움, 판은 가운데 안전 영역 안), apple-touch-icon 180. 다시 만들기: `bun scripts/pwa-icons.ts`(Chrome으로 그림).
+- 매니페스트 `/manifest.webmanifest`: 이름 "같이 놀자 보드게임", 짧은 이름 "같이 놀자", `lang: ko`, 시작 `/`, `display: standalone`, 테마·배경 `#F7F5F0`(`--bg`). 아이콘은 "게임천국" 느낌의 게임패드 + 천사 고리 + 반짝이(보라→분홍→주황 배경). `public/icons/`: 192·512(favicon 그대로, 둥근 모서리), 512 maskable(가득 채움, 그림은 가운데 안전 영역 안), apple-touch-icon 180. favicon.svg와 PNG 모두 `bun scripts/pwa-icons.ts`가 한 디자인에서 만든다(Chrome으로 그림).
- 서비스 워커: 손으로 쓴 `apps/web/sw.js`를 빌드 때 Vite 플러그인이 빌드 ID(파일 이름 해시 기준)를 넣어 `dist/sw.js`로 낸다. 배포 빌드에서만 등록한다(개발 서버에서는 등록 안 함).
| 요청 | 처리 |
diff --git a/scripts/pwa-icons.ts b/scripts/pwa-icons.ts
index bf6f57a..f2aadda 100644
--- a/scripts/pwa-icons.ts
+++ b/scripts/pwa-icons.ts
@@ -1,20 +1,44 @@
/**
- * Renders the PWA icons (docs/07 §11) from the favicon design with headless Chrome.
- * bun scripts/pwa-icons.ts → apps/web/public/icons/*.png
- * "any" icons keep the favicon's rounded corners; maskable/apple icons are full-bleed
- * (the OS applies its own mask) with the board inside the 40% safe circle.
+ * Renders the site icon (docs/07 §11) with headless Chrome.
+ * bun scripts/pwa-icons.ts → apps/web/public/favicon.svg + apps/web/public/icons/*.png
+ * One design (아케이드 게임기 + 천사 고리 + 반짝이) is the source of every icon:
+ * "any" icons keep rounded corners; maskable/apple icons are full-bleed
+ * (the OS applies its own mask) with the art shrunk into the safe circle.
*/
import { chromium } from 'playwright-core';
-import { mkdirSync, readFileSync } from 'node:fs';
+import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const PUBLIC = join(import.meta.dir, '../apps/web/public');
const OUT = join(PUBLIC, 'icons');
mkdirSync(OUT, { recursive: true });
-const rounded = readFileSync(join(PUBLIC, 'favicon.svg'), 'utf8');
-// Same board as the favicon, shrunk to 80% so every stroke stays inside the safe circle.
-const fullBleed = ``;
+const INK = '#1e1b4b';
+const DEFS = ``;
+const star = (x: number, y: number, r: number, fill: string) =>
+ ``;
+const ART = [
+ // 천사 고리: "게임천국"
+ ``,
+ // 게임 패드
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ // 반짝이
+ star(53, 11, 4.6, '#fde047'),
+ star(10.5, 14, 3, '#fff'),
+ star(54, 53, 3, '#fff'),
+ star(11, 54, 2.2, '#fde047'),
+].join('');
+
+const rounded = `\n`;
+const fullBleed = ``;
+
+writeFileSync(join(PUBLIC, 'favicon.svg'), rounded);
+console.log('✓ favicon.svg');
const ICONS: [file: string, size: number, svg: string][] = [
['icon-192.png', 192, rounded],