Skip to content

Commit d3333e2

Browse files
SOIVclaude
andcommitted
feat(web): Phase 1.5.4 Main Home 잔여 항목 완료
- 글로벌 네비게이션 상세 규격 확정: 메뉴 순서(Workspace→Modules→Footer), 아이콘 확정, Sign Out 아이콘 →으로 통일 - 모바일 Drawer: 768px 이하에서 햄버거(☰) 버튼 + 슬라이드인 사이드바 오버레이, 배경 클릭·항목 클릭 시 자동 닫힘 - 키보드 탐색: shell-nav-list 내 ArrowUp/ArrowDown으로 항목 간 포커스 순환 - 딥 링크 라우팅: 비인증 상태에서 #admin/#marketplace 진입 시 redirectAfterLogin 저장 → 로그인 후 원래 route로 자동 복귀 - 첫 로그인 온보딩 배너: 최초 1회 환영 배너(dismissible), localStorage 기반 영구 dismiss Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 65ca760 commit d3333e2

6 files changed

Lines changed: 285 additions & 37 deletions

File tree

apps/web/src/components/AppShell.tsx

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactNode } from "react";
1+
import { type ReactNode, useState } from "react";
22
import "../styles/shell.css";
33

44
export type RouteKey = "login" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password";
@@ -18,6 +18,21 @@ interface AppShellProps {
1818
// 추후 모듈 로더에서 주입될 목록 (현재는 mock)
1919
const INSTALLED_MODULES: { id: string; label: string; icon: string }[] = [];
2020

21+
// nav list 내 ArrowUp/ArrowDown 키보드 탐색
22+
function handleNavKeyDown(e: React.KeyboardEvent<HTMLUListElement>) {
23+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
24+
const items = Array.from(
25+
e.currentTarget.querySelectorAll<HTMLButtonElement>('button.shell-nav-item'),
26+
);
27+
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
28+
if (idx === -1) return;
29+
e.preventDefault();
30+
const next = e.key === 'ArrowDown'
31+
? items[(idx + 1) % items.length]
32+
: items[(idx - 1 + items.length) % items.length];
33+
next?.focus();
34+
}
35+
2136
export function AppShell({
2237
installMode,
2338
route,
@@ -30,25 +45,52 @@ export function AppShell({
3045
children,
3146
}: AppShellProps) {
3247
const userInitial = currentUser?.email.charAt(0).toUpperCase() ?? "?";
48+
const [isMobileOpen, setIsMobileOpen] = useState(false);
49+
50+
const closeMobileMenu = () => setIsMobileOpen(false);
51+
const navAndClose = (target: RouteKey) => { onNavigate(target); closeMobileMenu(); };
52+
3353
return (
3454
<div className="shell">
55+
{/* ── 모바일 오버레이 ──────────────────────────────── */}
56+
{isMobileOpen && (
57+
<div
58+
className="shell-drawer-overlay"
59+
onClick={closeMobileMenu}
60+
aria-hidden="true"
61+
/>
62+
)}
63+
3564
{/* ── Sidebar ─────────────────────────────────────── */}
36-
<aside className="shell-sidebar" aria-label="사이드바 네비게이션">
65+
<aside
66+
className="shell-sidebar"
67+
data-mobile-open={isMobileOpen ? "" : undefined}
68+
aria-label="사이드바 네비게이션"
69+
>
3770
<div className="shell-brand">
3871
<div className="shell-brand-logo" aria-hidden="true">FS</div>
3972
<span className="shell-brand-name">Fieldstack</span>
73+
{/* 모바일: 닫기 버튼 */}
74+
<button
75+
type="button"
76+
className="shell-drawer-close"
77+
onClick={closeMobileMenu}
78+
aria-label="메뉴 닫기"
79+
>
80+
81+
</button>
4082
</div>
4183

4284
<nav className="shell-nav" aria-label="주 메뉴">
4385
{/* Workspace */}
4486
<p className="shell-nav-label" aria-hidden="true">Workspace</p>
45-
<ul className="shell-nav-list">
87+
<ul className="shell-nav-list" onKeyDown={handleNavKeyDown}>
4688
<li>
4789
<button
4890
type="button"
4991
className="shell-nav-item"
5092
aria-current={route === "home" ? "page" : undefined}
51-
onClick={() => onNavigate("home")}
93+
onClick={() => navAndClose("home")}
5294
>
5395
<span className="shell-nav-icon" aria-hidden="true"></span>
5496
Home
@@ -59,7 +101,7 @@ export function AppShell({
59101
type="button"
60102
className="shell-nav-item"
61103
aria-current={route === "marketplace" ? "page" : undefined}
62-
onClick={() => onNavigate("marketplace")}
104+
onClick={() => navAndClose("marketplace")}
63105
>
64106
<span className="shell-nav-icon" aria-hidden="true"></span>
65107
Marketplace
@@ -69,14 +111,14 @@ export function AppShell({
69111

70112
{/* Modules */}
71113
<p className="shell-nav-label" aria-hidden="true">Modules</p>
72-
<ul className="shell-nav-list" aria-label="설치된 모듈">
114+
<ul className="shell-nav-list" aria-label="설치된 모듈" onKeyDown={handleNavKeyDown}>
73115
{INSTALLED_MODULES.length > 0 ? (
74116
INSTALLED_MODULES.map((mod) => (
75117
<li key={mod.id}>
76118
<button
77119
type="button"
78120
className="shell-nav-item"
79-
onClick={() => { window.location.hash = mod.id; }}
121+
onClick={() => { window.location.hash = mod.id; closeMobileMenu(); }}
80122
>
81123
<span className="shell-nav-icon" aria-hidden="true">{mod.icon}</span>
82124
{mod.label}
@@ -105,7 +147,7 @@ export function AppShell({
105147
DEV BYPASS
106148
</div>
107149
)}
108-
<button type="button" className="shell-nav-item" onClick={onOpenSettings}>
150+
<button type="button" className="shell-nav-item" onClick={() => { onOpenSettings(); closeMobileMenu(); }}>
109151
<span className="shell-nav-icon" aria-hidden="true"></span>
110152
Settings
111153
</button>
@@ -114,7 +156,7 @@ export function AppShell({
114156
type="button"
115157
className="shell-nav-item"
116158
aria-current={route === "admin" ? "page" : undefined}
117-
onClick={() => onNavigate("admin")}
159+
onClick={() => navAndClose("admin")}
118160
>
119161
<span className="shell-nav-icon" aria-hidden="true"></span>
120162
Admin
@@ -123,16 +165,31 @@ export function AppShell({
123165
<button
124166
type="button"
125167
className="shell-nav-item shell-nav-item-danger"
126-
onClick={onLogout}
168+
onClick={() => { onLogout(); closeMobileMenu(); }}
127169
>
128-
<span className="shell-nav-icon" aria-hidden="true"></span>
170+
<span className="shell-nav-icon" aria-hidden="true"></span>
129171
Sign Out
130172
</button>
131173
</div>
132174
</aside>
133175

134176
{/* ── Body ─────────────────────────────────────────── */}
135177
<div className="shell-body">
178+
{/* 모바일 상단 바 */}
179+
<div className="shell-mobile-topbar">
180+
<button
181+
type="button"
182+
className="shell-hamburger"
183+
onClick={() => setIsMobileOpen(true)}
184+
aria-label="메뉴 열기"
185+
aria-expanded={isMobileOpen}
186+
>
187+
<span aria-hidden="true"></span>
188+
</button>
189+
<div className="shell-brand-logo" aria-hidden="true">FS</div>
190+
<span className="shell-brand-name">Fieldstack</span>
191+
</div>
192+
136193
{notice && (
137194
<div className="shell-notice" role="status" aria-live="polite">
138195
<span className="shell-notice-dot" aria-hidden="true" />

apps/web/src/main.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const MOCK_ACCOUNTS: { email: string; password: string; isAdmin: boolean }[] = [
7777
{ email: "user@fieldstack.dev", password: "User1234!", isAdmin: false },
7878
];
7979

80-
// ─── Session Storage Keys ─────────────────────────────────────
80+
// ─── Storage Keys ─────────────────────────────────────────────
8181
const SS = {
8282
auth: "fs_auth",
8383
admin: "fs_admin",
@@ -86,6 +86,18 @@ const SS = {
8686
mustChangePw: "fs_must_change_pw",
8787
} as const;
8888

89+
const LS = {
90+
theme: "fs_theme",
91+
firstVisitShown: "fs_first_visit_shown",
92+
} as const;
93+
94+
// 딥 링크: 비인증 상태에서 진입한 app route 반환
95+
function getDeepLinkTarget(): RouteKey | null {
96+
const hash = window.location.hash.replace("#", "");
97+
const appRoutes: RouteKey[] = ["home", "marketplace", "admin"];
98+
return (appRoutes as string[]).includes(hash) ? (hash as RouteKey) : null;
99+
}
100+
89101
// ─── App Root ─────────────────────────────────────────────────
90102
function App({ installMode }: { installMode: InstallMode }) {
91103
const [theme, setTheme] = useState<ThemeSetting>(loadTheme);
@@ -127,6 +139,18 @@ function App({ installMode }: { installMode: InstallMode }) {
127139
const [mustChangePassword, setMustChangePassword] = useState(
128140
() => sessionStorage.getItem(SS.mustChangePw) === "true",
129141
);
142+
// 딥 링크: 비인증 상태에서 진입한 app route (로그인 후 복귀)
143+
const [redirectAfterLogin, setRedirectAfterLogin] = useState<RouteKey | null>(
144+
() => (sessionStorage.getItem(SS.auth) === "true" ? null : getDeepLinkTarget()),
145+
);
146+
// 첫 방문 온보딩 배너
147+
const [isFirstVisit, setIsFirstVisit] = useState(false);
148+
149+
const onDismissFirstVisit = () => {
150+
setIsFirstVisit(false);
151+
try { localStorage.setItem(LS.firstVisitShown, "true"); } catch { /* ignore */ }
152+
};
153+
130154
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
131155
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
132156
const [notice, setNotice] = useState(
@@ -247,7 +271,14 @@ function App({ installMode }: { installMode: InstallMode }) {
247271
sessionStorage.setItem(SS.auth, "true");
248272
sessionStorage.setItem(SS.email, email);
249273
setNotice("Login successful (mock).");
250-
navigate("home");
274+
// 첫 방문 온보딩
275+
try {
276+
if (localStorage.getItem(LS.firstVisitShown) !== "true") setIsFirstVisit(true);
277+
} catch { /* ignore */ }
278+
// 딥 링크 복귀
279+
const target = redirectAfterLogin ?? "home";
280+
setRedirectAfterLogin(null);
281+
navigate(target);
251282
};
252283

253284
const onQuickLogin = () => {
@@ -276,7 +307,12 @@ function App({ installMode }: { installMode: InstallMode }) {
276307
sessionStorage.setItem(SS.auth, "true");
277308
sessionStorage.setItem(SS.email, email);
278309
setNotice("2단계 인증 완료.");
279-
navigate("home");
310+
try {
311+
if (localStorage.getItem(LS.firstVisitShown) !== "true") setIsFirstVisit(true);
312+
} catch { /* ignore */ }
313+
const target = redirectAfterLogin ?? "home";
314+
setRedirectAfterLogin(null);
315+
navigate(target);
280316
};
281317

282318
const onOtpCancel = () => {
@@ -380,6 +416,8 @@ function App({ installMode }: { installMode: InstallMode }) {
380416
{effectiveRoute === "home" && (
381417
<HomeView
382418
isAdmin={isAdmin}
419+
isFirstVisit={isFirstVisit}
420+
onDismissFirstVisit={onDismissFirstVisit}
383421
onOpenSettings={() => setIsSettingsOpen(true)}
384422
onNavigateAdmin={() => navigate("admin")}
385423
/>

apps/web/src/styles/home.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,56 @@
33
gap: 18px;
44
}
55

6+
/* ── 첫 로그인 환영 배너 ─────────────────────────────────────── */
7+
.home-welcome-banner {
8+
display: flex;
9+
align-items: flex-start;
10+
justify-content: space-between;
11+
gap: 12px;
12+
padding: 14px 16px;
13+
border-radius: 12px;
14+
border: 1px solid rgba(59, 130, 246, 0.35);
15+
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(16, 185, 129, 0.06) 100%);
16+
}
17+
18+
.home-welcome-body {
19+
display: grid;
20+
gap: 4px;
21+
}
22+
23+
.home-welcome-title {
24+
margin: 0;
25+
font-size: 14px;
26+
font-weight: 700;
27+
color: var(--text);
28+
}
29+
30+
.home-welcome-desc {
31+
margin: 0;
32+
font-size: 13px;
33+
color: var(--text-muted);
34+
line-height: 1.55;
35+
}
36+
37+
.home-welcome-dismiss {
38+
flex-shrink: 0;
39+
margin-top: 1px;
40+
border: none;
41+
background: transparent;
42+
color: var(--text-faint);
43+
font-size: 14px;
44+
cursor: pointer;
45+
padding: 2px 5px;
46+
border-radius: 5px;
47+
line-height: 1;
48+
transition: color 110ms ease, background 110ms ease;
49+
}
50+
51+
.home-welcome-dismiss:hover {
52+
color: var(--text);
53+
background: var(--bg-hover);
54+
}
55+
656
.home-hero {
757
border-radius: 14px;
858
border: 1px solid var(--border);

0 commit comments

Comments
 (0)