diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index 6b07bf1..edb5cdf 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1 +1 @@ - + diff --git a/apps/web/public/icons/apple-touch-icon.png b/apps/web/public/icons/apple-touch-icon.png index 336f8ac..dc85ce8 100644 Binary files a/apps/web/public/icons/apple-touch-icon.png and b/apps/web/public/icons/apple-touch-icon.png differ diff --git a/apps/web/public/icons/icon-192.png b/apps/web/public/icons/icon-192.png index c79a74d..7976f29 100644 Binary files a/apps/web/public/icons/icon-192.png and b/apps/web/public/icons/icon-192.png differ diff --git a/apps/web/public/icons/icon-512.png b/apps/web/public/icons/icon-512.png index bad89da..168c8e8 100644 Binary files a/apps/web/public/icons/icon-512.png and b/apps/web/public/icons/icon-512.png differ diff --git a/apps/web/public/icons/icon-maskable-512.png b/apps/web/public/icons/icon-maskable-512.png index e930484..14198e6 100644 Binary files a/apps/web/public/icons/icon-maskable-512.png and b/apps/web/public/icons/icon-maskable-512.png differ diff --git a/apps/web/src/components/SiteHeader.tsx b/apps/web/src/components/SiteHeader.tsx index c651fb1..74d346b 100644 --- a/apps/web/src/components/SiteHeader.tsx +++ b/apps/web/src/components/SiteHeader.tsx @@ -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() { setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" /> - - {me ? : 👤} - {me ? me.nickname : '시작하기'} - + {me ? ( + + ) : ( + + 로그인 + + )} ); } + +/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] [로그아웃] 메뉴. */ +function AccountMenu() { + const me = useSession((s) => s.me)!; + const nav = useNavigate(); + const [open, setOpen] = useState(false); + const [confirm, setConfirm] = useState(false); + const ref = useRef(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 ( +
+ + {open && ( +
+ + +
+ )} + {confirm && ( + { + await api.logout(); + nav('/'); + await useSession.getState().refresh(); + }} + onClose={() => setConfirm(false)} + /> + )} +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 248a6e1..4bae2cc 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 214f038..34374f7 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -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 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 (
@@ -64,62 +54,68 @@ export function Home() { )} -
-
-

친구랑 같이 하기

-

방을 만들면 코드와 링크가 바로 나와요. 친구에게 보내 주세요.

- -
-
-

코드로 참여하기

-

받은 숫자 6자리를 넣으면 바로 들어가요.

- -
+
+ + {practiceGames.length > 0 && ( -
-

혼자 연습하기

-

컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.

- -
+ )}
-
-
-

게임 고르기

- - {UPCOMING.length > 0 &&

곧 나와요: {UPCOMING.join(' · ')}

} -
- -
+ ))} + + )} +
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 = `${DEFS}${ART}\n`; +const fullBleed = `${DEFS}${ART}`; + +writeFileSync(join(PUBLIC, 'favicon.svg'), rounded); +console.log('✓ favicon.svg'); const ICONS: [file: string, size: number, svg: string][] = [ ['icon-192.png', 192, rounded],