feat: basic 레이아웃 사이드바를 시안대로 구현 + 권한별 메뉴를 서버에서 조회
시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) snb-area 3041:11860. 메뉴는 화면이 소유하지 않는다. 계정 권한마다 항목이 달라 매 요청 백엔드가 구성해 주는 값이라, 라우트 목록을 컴포넌트 상수로 두던 구조를 걷어내고 서버에서 조회해 내려주는 흐름으로 바꿨다. 그래서 새 화면이 생겨도 사이드바 컴포넌트는 손대지 않는다. - lib/domain/sidebar-menu.ts: section > 1depth > 2depth > 3depth + 즐겨찾기 타입. 아이콘은 컴포넌트가 아니라 이름 문자열로 받는다 — 백엔드가 넘길 수 있는 형태가 그것뿐이다. - lib/data/repositories/sidebar-menu-repository.ts: 조회 지점. 아직 API가 없어 mock을 돌려주되 권한(roleCode)을 인자로 받아 `cache()`가 권한별로 따로 기억하게 했다 — API가 생기면 이 함수 본문만 교체하면 호출부는 그대로다. - layout.tsx가 세션 권한으로 메뉴를 조회해 AdminShell을 거쳐 사이드바에 넘긴다. 클라이언트에서 불러오면 첫 페인트에 메뉴가 비었다가 채워지고 조회용 토큰도 브라우저로 내려가야 한다. - sidebar-menu-icon.tsx: 이름 → @fox 아이콘 화이트리스트. 동적 import로 하면 번들러가 1,512종을 전부 싣게 되므로 적힌 것만 번들에 넣는다. 모르는 이름이 와도 기본 아이콘으로 그려진다. - use-sidebar-nav.ts: 3단 트리용으로 다시 썼다. 펼침은 "현재 경로에서 파생된 기본값 + 사용자가 누른 값" 2층이라, 이동하면 그 가지가 저절로 열리고 직접 접으면 그 선택이 유지된다. @fox 사용 범위(사용자 확정) — 1·2·3depth 항목은 @fox에 없어 사이드바 전용으로 짜되 토큰·아이콘은 전부 @fox를 쓰고, 있는 것은 그대로 재사용했다. - 토글 버튼: FoxIconButton xsm/ghost가 24×24·radius 4·아이콘 20으로 시안과 정확히 일치한다. - 검색창: FoxInput md가 크기·radius·여백까지 시안과 같고 테두리 색만 달라(input-border는 neutral-60, 시안은 border-neutral-subtler) 그 한 줄만 덮었다. mock 메뉴는 실제로 존재하는 화면만 담았다 — 시안의 통합플랫폼 메뉴(기관 관리·역할 관리 등)를 그대로 넣으면 전부 갈 곳 없는 죽은 링크가 된다. 검증 — 브라우저 실측이 시안과 일치한다. 셸 240px·패딩 12·우측 1px, 배경 그라데이션 #f4f5f6 70% → #ecf2fe, 검색창 40/radius 8/테두리 #e6e8ea, 토글 24×24/radius 4/아이콘 20, 1depth 40/패딩 10·8/radius 8/14px ExtraBold/mix-blend multiply/아이콘 20, 활성 배경 #063a74 + 흰 글자, 2depth 판 흰 배경+1px+radius 8+패딩 8, 2depth 행 28, 별·캐럿 12(자리는 유지하고 opacity 0으로 감춤 — 시안과 같다). AdminShell이 더 이상 useSidebar를 쓰지 않는다. 헤더 구현 때 시안에 없어 걷어낸 토글의 후속이다. Co-Authored-By: Claude Opus 5
@2e1d5fe41edd8f3865935c9ecc90406e34ec9587
--- app/(protected)/(basic)/_components/admin-shell.tsx
+++ app/(protected)/(basic)/_components/admin-shell.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useSidebar } from '@/app/(protected)/(basic)/_hooks/use-sidebar';
|
|
| 3 |
+import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
|
|
| 4 | 4 |
import { AdminFooter } from './admin-footer';
|
| 5 | 5 |
import { AdminHeader } from './admin-header';
|
| 6 | 6 |
import { AdminSidebar } from './admin-sidebar';
|
... | ... | @@ -9,25 +9,25 @@ |
| 9 | 9 |
children: React.ReactNode; |
| 10 | 10 |
/** 헤더 프로필에 표시할 관리자 이름 — 세션을 읽는 상위 Server Component가 넘긴다. */ |
| 11 | 11 |
adminName: string; |
| 12 |
+ /** 계정 권한에 맞춰 서버가 구성한 사이드바 메뉴. */ |
|
| 13 |
+ menu: SidebarMenu; |
|
| 12 | 14 |
} |
| 13 | 15 |
|
| 14 | 16 |
/** |
| 15 |
- * Basic 레이아웃의 상호작용 경계 — 헤더·사이드바·풋터를 조립하고 사이드바 상태(useSidebar)를 |
|
| 16 |
- * 소유한다. page 콘텐츠(children)는 상위 Server Component가 렌더한 결과를 props로 그대로 |
|
| 17 |
- * 통과시켜 RSC를 유지한다 — 이 컴포넌트 자신은 children의 내용을 알거나 가공하지 않는다. |
|
| 17 |
+ * Basic 레이아웃의 상호작용 경계 — 헤더·사이드바·풋터를 조립한다. page 콘텐츠(children)는 |
|
| 18 |
+ * 상위 Server Component가 렌더한 결과를 props로 그대로 통과시켜 RSC를 유지한다 — 이 컴포넌트 |
|
| 19 |
+ * 자신은 children의 내용을 알거나 가공하지 않는다. |
|
| 20 |
+ * |
|
| 21 |
+ * 사이드바 열림/접힘 상태(useSidebar)는 더 이상 여기서 소유하지 않는다. 시안의 헤더·사이드바 |
|
| 22 |
+ * 어디에도 그 토글이 없어 헤더 구현 때 함께 걷어냈다 — 접힘 모양이 시안에 정의되면 그때 |
|
| 23 |
+ * 다시 붙인다. |
|
| 18 | 24 |
*/ |
| 19 |
-export function AdminShell({ children, adminName }: AdminShellProps) {
|
|
| 20 |
- const sidebar = useSidebar(); |
|
| 21 |
- |
|
| 25 |
+export function AdminShell({ children, adminName, menu }: AdminShellProps) {
|
|
| 22 | 26 |
return ( |
| 23 | 27 |
<div className="flex min-h-screen flex-col"> |
| 24 | 28 |
<AdminHeader adminName={adminName} />
|
| 25 | 29 |
<div className="flex flex-1"> |
| 26 |
- <AdminSidebar |
|
| 27 |
- isMobileOpen={sidebar.isMobileOpen}
|
|
| 28 |
- isDesktopCollapsed={sidebar.isDesktopCollapsed}
|
|
| 29 |
- onCloseMobile={sidebar.closeMobile}
|
|
| 30 |
- /> |
|
| 30 |
+ <AdminSidebar menu={menu} />
|
|
| 31 | 31 |
<div className="flex flex-1 flex-col"> |
| 32 | 32 |
<main className="flex flex-1 flex-col">{children}</main>
|
| 33 | 33 |
<AdminFooter /> |
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
... | ... | @@ -1,21 +1,31 @@ |
| 1 | 1 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 2 |
+import { fetchSidebarMenu } from '@/lib/data/repositories/sidebar-menu-repository';
|
|
| 3 |
+import type { SidebarMenu } from '@/lib/domain/sidebar-menu';
|
|
| 2 | 4 |
import { AdminShell } from './_components/admin-shell';
|
| 5 |
+ |
|
| 6 |
+const EMPTY_MENU: SidebarMenu = { favorites: [], sections: [] };
|
|
| 3 | 7 |
|
| 4 | 8 |
// Basic 레이아웃 — 헤더·사이드바·풋터가 있는 일반 관리자 화면 진입점. 보안 강제는 상위 |
| 5 | 9 |
// `(protected)/layout.tsx`가 담당하므로 여기서는 크롬 조립(AdminShell)만 책임진다. page |
| 6 | 10 |
// 콘텐츠는 Server Component로 렌더된 그대로 AdminShell의 children으로 통과한다. |
| 7 | 11 |
// |
| 8 |
-// 헤더 프로필에 쓸 이름만 세션에서 읽는다. **인증 판단은 하지 않는다** — 레이아웃은 Partial |
|
| 9 |
-// Rendering 때문에 인증 체크 지점으로 부적합하다는 상위 레이아웃의 판단을 그대로 따르므로, |
|
| 10 |
-// redirect하는 `verifySession()`이 아니라 null을 돌려주는 `getSessionAdmin()`을 쓴다. |
|
| 12 |
+// **사이드바 메뉴는 여기서 조회한다.** 계정 권한마다 항목이 달라 화면이 들고 있을 수 없는 |
|
| 13 |
+// 값이라, 세션의 권한으로 서버에서 구성해 내려보낸다 — 클라이언트가 나중에 불러오면 첫 |
|
| 14 |
+// 페인트에 메뉴가 비었다가 채워지고, 조회에 쓰는 토큰도 브라우저로 내려가야 한다. |
|
| 15 |
+// |
|
| 16 |
+// 세션이 없으면 빈 메뉴다. **인증 판단은 하지 않는다** — 레이아웃은 Partial Rendering 때문에 |
|
| 17 |
+// 인증 체크 지점으로 부적합하다는 상위 레이아웃의 판단을 그대로 따른다. |
|
| 11 | 18 |
export default async function Layout({
|
| 12 | 19 |
children, |
| 13 | 20 |
}: Readonly<{
|
| 14 | 21 |
children: React.ReactNode; |
| 15 | 22 |
}>) {
|
| 16 | 23 |
const admin = await getSessionAdmin(); |
| 24 |
+ const menu = admin ? await fetchSidebarMenu(admin.roleCode) : EMPTY_MENU; |
|
| 17 | 25 |
|
| 18 | 26 |
return ( |
| 19 |
- <AdminShell adminName={admin?.name ?? '관리자'}>{children}</AdminShell>
|
|
| 27 |
+ <AdminShell adminName={admin?.name ?? '관리자'} menu={menu}>
|
|
| 28 |
+ {children}
|
|
| 29 |
+ </AdminShell> |
|
| 20 | 30 |
); |
| 21 | 31 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?