Skip to content

Commit f48c83e

Browse files
committed
fix(web): 모바일 네비게이션 메뉴 추가 + 부트스트랩 401 레이스 제거
1) 모바일 네비 갭 md 미만에서 마케팅 네비(기능·동작 방식·FAQ)와 (로그인 사용자의) 워크스페이스 링크가 헤더에서 사라졌다. 햄버거 토글 + 드롭다운 메뉴를 추가해 해당 항목을 담았다. 시작하기 CTA 는 그대로 노출 유지. aria-expanded/controls, Esc 닫기, 링크 클릭 시 닫기까지 접근성 반영. 2) 부트스트랩 401 레이스 앱 로드 시 AuthProvider 가 토큰 없이 /api/users/me 를 먼저 쏴 401 → refresh → 재시도하던 낭비가 있었다(매 로드 401 1건, 백엔드 WARN 로그 노이즈). client 에 ensureAccessToken() 을 추가해 refresh 로 토큰을 먼저 확보한 뒤 사용자 정보를 부르도록 부트스트랩을 변경. 세션이 없으면 refresh 401 로 비로그인 처리. typecheck·lint·build·vitest(182) 전부 통과. 모바일 메뉴 렌더/토글은 로컬 빌드로 확인.
1 parent 8607ddd commit f48c83e

4 files changed

Lines changed: 132 additions & 4 deletions

File tree

frontend/src/features/auth/model/AuthProvider.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
type ReactNode,
88
} from 'react'
99
import { useQueryClient } from '@tanstack/react-query'
10-
import { isApiError, setAuthSideEffects, tokenStore } from '@/shared/api'
10+
import {
11+
ensureAccessToken,
12+
isApiError,
13+
setAuthSideEffects,
14+
tokenStore,
15+
} from '@/shared/api'
1116
import { fetchCurrentUser, logout as logoutApi } from '../api/auth'
1217
import {
1318
AuthContext,
@@ -83,6 +88,13 @@ export function AuthProvider({ children }: AuthProviderProps) {
8388

8489
void (async () => {
8590
try {
91+
// 토큰을 refresh 로 먼저 확보한 뒤 사용자 정보를 부른다.
92+
// (토큰 없이 /users/me 를 쏴 401 → refresh → 재시도하던 낭비 제거)
93+
const token = await ensureAccessToken()
94+
if (!token) {
95+
clearAuth()
96+
return
97+
}
8698
await refreshUser()
8799
} catch {
88100
clearAuth()

frontend/src/shared/api/client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ function refreshOnce(): Promise<string> {
120120
return refreshing
121121
}
122122

123+
// 앱 부트스트랩용: 토큰이 없으면 refresh 로 먼저 확보한다.
124+
// 이렇게 해야 첫 인증 요청(/api/users/me)이 토큰 없이 나가 401 을 유발하고
125+
// 재시도되는 낭비가 사라진다. 세션이 없으면(refresh 401) null 을 돌려준다.
126+
export async function ensureAccessToken(): Promise<string | null> {
127+
const existing = tokenStore.get()
128+
if (existing) return existing
129+
try {
130+
return await refreshOnce()
131+
} catch (err) {
132+
// 일시적 장애(SYS_DEPENDENCY_DOWN)는 그대로 던져 상위에서 구분 처리.
133+
if (err instanceof ApiError && err.code === 'SYS_DEPENDENCY_DOWN') throw err
134+
// 그 외(세션 없음 등)는 비로그인으로 취급.
135+
return null
136+
}
137+
}
138+
123139
apiClient.interceptors.response.use(
124140
(response) => response,
125141
async (error: AxiosError<ApiErrorBody>) => {

frontend/src/shared/api/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export { apiClient, setAuthSideEffects, type ApiResponse } from './client'
1+
export {
2+
apiClient,
3+
setAuthSideEffects,
4+
ensureAccessToken,
5+
type ApiResponse,
6+
} from './client'
27
export { tokenStore } from './token-store'
38
export {
49
ApiError,

frontend/src/widgets/site-nav/ui/SiteNav.tsx

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useId, useState } from 'react'
22
import { Link } from 'react-router-dom'
33
import { useAuth, useLogout } from '@/features/auth'
44
import { ColorModeToggle } from '@/shared/ui'
@@ -9,8 +9,38 @@ const items = [
99
{ to: '/#faq', label: 'FAQ' },
1010
]
1111

12+
function MenuIcon({ open }: { open: boolean }) {
13+
return (
14+
<svg
15+
width="20"
16+
height="20"
17+
viewBox="0 0 24 24"
18+
fill="none"
19+
stroke="currentColor"
20+
strokeWidth="2"
21+
strokeLinecap="round"
22+
aria-hidden
23+
>
24+
{open ? (
25+
<>
26+
<path d="M6 6l12 12" />
27+
<path d="M18 6L6 18" />
28+
</>
29+
) : (
30+
<>
31+
<path d="M4 7h16" />
32+
<path d="M4 12h16" />
33+
<path d="M4 17h16" />
34+
</>
35+
)}
36+
</svg>
37+
)
38+
}
39+
1240
export function SiteNav() {
1341
const [scrolled, setScrolled] = useState(false)
42+
const [open, setOpen] = useState(false)
43+
const menuId = useId()
1444
const { status, user } = useAuth()
1545
const { logout, loggingOut } = useLogout()
1646

@@ -21,11 +51,21 @@ export function SiteNav() {
2151
return () => window.removeEventListener('scroll', onScroll)
2252
}, [])
2353

54+
// Esc 로 닫기.
55+
useEffect(() => {
56+
if (!open) return
57+
const onKey = (e: KeyboardEvent) => {
58+
if (e.key === 'Escape') setOpen(false)
59+
}
60+
window.addEventListener('keydown', onKey)
61+
return () => window.removeEventListener('keydown', onKey)
62+
}, [open])
63+
2464
return (
2565
<header
2666
className={[
2767
'sticky top-0 w-full transition-colors duration-normal ease-standard',
28-
scrolled
68+
scrolled || open
2969
? 'border-b border-border bg-surface-raised/85 backdrop-blur-md'
3070
: 'border-b border-transparent bg-transparent',
3171
].join(' ')}
@@ -90,8 +130,63 @@ export function SiteNav() {
90130
</Link>
91131
</>
92132
)}
133+
134+
{/* 모바일 메뉴 토글 — 데스크톱 네비/보조 링크가 숨겨지는 구간을 보완 */}
135+
<button
136+
type="button"
137+
onClick={() => setOpen((v) => !v)}
138+
aria-expanded={open}
139+
aria-controls={menuId}
140+
aria-label={open ? '메뉴 닫기' : '메뉴 열기'}
141+
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-fg-muted transition-colors duration-fast hover:text-fg-strong md:hidden"
142+
>
143+
<MenuIcon open={open} />
144+
</button>
93145
</div>
94146
</div>
147+
148+
{/* 모바일 드롭다운 메뉴 */}
149+
{open ? (
150+
<nav
151+
id={menuId}
152+
aria-label="Mobile"
153+
className="border-t border-border bg-surface-raised/95 backdrop-blur-md md:hidden"
154+
>
155+
<div className="mx-auto flex max-w-content flex-col gap-1 px-6 py-3">
156+
{items.map((it) => (
157+
<Link
158+
key={it.to}
159+
to={it.to}
160+
onClick={() => setOpen(false)}
161+
className="rounded-md px-3 py-2.5 text-button text-fg-muted transition-colors duration-fast hover:bg-surface hover:text-fg-strong"
162+
>
163+
{it.label}
164+
</Link>
165+
))}
166+
<div className="my-1 h-px bg-border" />
167+
{status === 'authenticated' ? (
168+
<Link
169+
to="/workspace"
170+
onClick={() => setOpen(false)}
171+
className="flex items-center gap-2 rounded-md px-3 py-2.5 text-button text-fg-strong transition-colors duration-fast hover:bg-surface"
172+
>
173+
{user?.avatarUrl ? (
174+
<img src={user.avatarUrl} alt="" aria-hidden className="h-6 w-6 rounded-full" />
175+
) : null}
176+
<span>{user?.displayName ?? '워크스페이스'}</span>
177+
</Link>
178+
) : (
179+
<Link
180+
to="/login"
181+
onClick={() => setOpen(false)}
182+
className="rounded-md px-3 py-2.5 text-button text-fg-strong transition-colors duration-fast hover:bg-surface"
183+
>
184+
로그인
185+
</Link>
186+
)}
187+
</div>
188+
</nav>
189+
) : null}
95190
</header>
96191
)
97192
}

0 commit comments

Comments
 (0)