+++ app/(protected)/(basic)/_components/admin-header.module.scss
... | ... | @@ -0,0 +1,167 @@ |
| 1 | +// Basic 레이아웃 헤더 — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// header (7028:7614 · 컴포넌트 7001:10471) | |
| 3 | +// | |
| 4 | +// 시안에서 디자인시스템 컴포넌트로 정의된 조각은 아이콘 4종과 badge-dot뿐이다. 로고 영역· | |
| 5 | +// 아이콘 버튼·프로필 pill은 header 안의 로컬 프레임이라 @fox로 승격하지 않고 여기서 조립한다. | |
| 6 | +// 색·간격·radius·타이포는 전부 @fox 토큰을 거친다. | |
| 7 | + | |
| 8 | +@use "@fox/styles/abstracts" as fox; | |
| 9 | + | |
| 10 | +.header { | |
| 11 | + // 시안 높이 64는 테두리를 포함한 값이다(Figma stroke는 안쪽에 그린다). | |
| 12 | + box-sizing: border-box; | |
| 13 | + display: flex; | |
| 14 | + flex-shrink: 0; | |
| 15 | + align-items: center; | |
| 16 | + justify-content: space-between; | |
| 17 | + // 시안 64px. Figma가 변수로 export하지 않는다. | |
| 18 | + block-size: 6.4rem; | |
| 19 | + padding-inline: fox.padding(8); | |
| 20 | + border-block-end: fox.border(1) solid fox.color(border-neutral-subtler); | |
| 21 | + background: fox.color(surface-neutral-default); | |
| 22 | +} | |
| 23 | + | |
| 24 | +// ── 로고 ──────────────────────────────────────────────────────────────────── | |
| 25 | +// 시안은 28px 정사각 심볼 + 텍스트지만, 에듀페이 로고는 170×62 워드마크다. 높이만 28px로 | |
| 26 | +// 맞추고 폭은 비율대로 둔다(사용자 확정). | |
| 27 | +.logo { | |
| 28 | + display: flex; | |
| 29 | + align-items: center; | |
| 30 | + gap: fox.gap(3); | |
| 31 | +} | |
| 32 | + | |
| 33 | +.logoMark { | |
| 34 | + display: block; | |
| 35 | + block-size: 2.8rem; | |
| 36 | + inline-size: auto; | |
| 37 | +} | |
| 38 | + | |
| 39 | +.title { | |
| 40 | + color: fox.color(font-neutral-default); | |
| 41 | + font-family: fox.font-family(title); | |
| 42 | + // 시안 24px / letter-spacing -0.6px(= -0.025em). Figma가 변수로 export하지 않는다. | |
| 43 | + font-size: 2.4rem; | |
| 44 | + font-weight: fox.font-weight(bold); | |
| 45 | + line-height: 1.5; | |
| 46 | + letter-spacing: -0.025em; | |
| 47 | + white-space: nowrap; | |
| 48 | +} | |
| 49 | + | |
| 50 | +// ── 우측 기능 영역 ────────────────────────────────────────────────────────── | |
| 51 | +.fnc { | |
| 52 | + display: flex; | |
| 53 | + align-items: center; | |
| 54 | + justify-content: flex-end; | |
| 55 | + gap: fox.gap(5); | |
| 56 | +} | |
| 57 | + | |
| 58 | +.btnGroup { | |
| 59 | + display: flex; | |
| 60 | + align-items: center; | |
| 61 | + gap: fox.gap(2); | |
| 62 | +} | |
| 63 | + | |
| 64 | +// ── 프로필 ────────────────────────────────────────────────────────────────── | |
| 65 | +// 메뉴가 이 버튼 기준으로 떠야 해서 위치 기준을 만든다. | |
| 66 | +.profile { | |
| 67 | + position: relative; | |
| 68 | +} | |
| 69 | + | |
| 70 | +.profileButton { | |
| 71 | + display: flex; | |
| 72 | + align-items: center; | |
| 73 | + gap: fox.gap(2); | |
| 74 | + padding-block: fox.padding(3); | |
| 75 | + padding-inline: fox.padding(4); | |
| 76 | + border: none; | |
| 77 | + border-radius: fox.radius(max); | |
| 78 | + // 시안 높이 44 = 상하 여백 8 + 아바타 28. Figma stroke가 안쪽이라 테두리가 높이를 늘리지 | |
| 79 | + // 않는다 — `border`를 쓰면 46이 되므로 레이아웃에 관여하지 않는 inset 그림자로 같은 선을 | |
| 80 | + // 그린다. | |
| 81 | + box-shadow: inset 0 0 0 fox.border(1) fox.color(border-neutral-subtle); | |
| 82 | + background: fox.color(action-default); | |
| 83 | + cursor: pointer; | |
| 84 | +} | |
| 85 | + | |
| 86 | +.avatar { | |
| 87 | + display: inline-flex; | |
| 88 | + flex-shrink: 0; | |
| 89 | + align-items: center; | |
| 90 | + justify-content: center; | |
| 91 | + // 시안 28px 원형 + 내부 20px. Figma가 변수로 export하지 않는다. | |
| 92 | + inline-size: 2.8rem; | |
| 93 | + block-size: 2.8rem; | |
| 94 | + border-radius: fox.radius(max); | |
| 95 | + background: fox.theme(primary-10); | |
| 96 | + color: fox.color(icon-neutral-default); | |
| 97 | +} | |
| 98 | + | |
| 99 | +// 시안은 이 자리에 프로필 사진을 넣지만 관리자 사진을 받는 경로가 없어 사람 아이콘으로 채운다. | |
| 100 | +.avatar > svg { | |
| 101 | + inline-size: 2rem; | |
| 102 | + block-size: 2rem; | |
| 103 | +} | |
| 104 | + | |
| 105 | +.profileName { | |
| 106 | + color: fox.color(font-neutral-subtle); | |
| 107 | + font-family: fox.font-family(body); | |
| 108 | + font-size: fox.font-size(label-md); | |
| 109 | + font-weight: fox.font-weight(medium); | |
| 110 | + line-height: 1; | |
| 111 | + letter-spacing: -0.025em; | |
| 112 | + white-space: nowrap; | |
| 113 | +} | |
| 114 | + | |
| 115 | +.caret { | |
| 116 | + display: inline-flex; | |
| 117 | + flex-shrink: 0; | |
| 118 | + inline-size: fox.icon(3); | |
| 119 | + block-size: fox.icon(3); | |
| 120 | + color: fox.color(icon-neutral-default); | |
| 121 | +} | |
| 122 | + | |
| 123 | +.caret > svg { | |
| 124 | + inline-size: 100%; | |
| 125 | + block-size: 100%; | |
| 126 | +} | |
| 127 | + | |
| 128 | +// ── 프로필 메뉴 ───────────────────────────────────────────────────────────── | |
| 129 | +// ⚠️ 시안에 없는 부분이다. 시안의 pill은 CaretDown만 있고 열린 모습이 그려져 있지 않아, | |
| 130 | +// 판때기는 FoxSelectOption(떠 있는 목록)이 쓰는 것과 같은 토큰 조합으로 맞췄다. | |
| 131 | +.menu { | |
| 132 | + position: absolute; | |
| 133 | + inset-block-start: calc(100% + #{fox.gap(2)}); | |
| 134 | + inset-inline-end: 0; | |
| 135 | + z-index: 1; | |
| 136 | + min-inline-size: 100%; | |
| 137 | + padding-block: fox.padding(2); | |
| 138 | + border: fox.border(1) solid fox.color(border-neutral-default); | |
| 139 | + border-radius: fox.form(radius-md); | |
| 140 | + background: fox.color(surface-neutral-default); | |
| 141 | + // 그림자 색은 FoxSelectOption과 같은 값을 쓴다 — 시안이 테마 토큰이 아니라 원시값에 | |
| 142 | + // 물려 놓아서 다크 모드에서도 검은 그림자다. | |
| 143 | + box-shadow: fox.shadow(location1) fox.shadow(location1) fox.shadow(blur1) 0 | |
| 144 | + fox.primitive(alpha-black10); | |
| 145 | +} | |
| 146 | + | |
| 147 | +.menuItem { | |
| 148 | + display: flex; | |
| 149 | + inline-size: 100%; | |
| 150 | + align-items: center; | |
| 151 | + padding-block: fox.padding(3); | |
| 152 | + padding-inline: fox.padding(4); | |
| 153 | + border: none; | |
| 154 | + background: none; | |
| 155 | + color: fox.color(font-neutral-default); | |
| 156 | + font-family: fox.font-family(body); | |
| 157 | + font-size: fox.font-size(label-md); | |
| 158 | + font-weight: fox.font-weight(regular); | |
| 159 | + line-height: 1; | |
| 160 | + letter-spacing: -0.025em; | |
| 161 | + white-space: nowrap; | |
| 162 | + cursor: pointer; | |
| 163 | + | |
| 164 | + &:hover { | |
| 165 | + background: fox.color(action-default-hover); | |
| 166 | + } | |
| 167 | +} |
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
... | ... | @@ -1,57 +1,115 @@ |
| 1 |
+'use client'; |
|
| 2 |
+ |
|
| 3 |
+import Image from 'next/image'; |
|
| 4 |
+import { useEffect, useRef, useState } from 'react';
|
|
| 1 | 5 |
import { logout } from '@/app/(protected)/_actions';
|
| 2 |
-import { ThemeToggleButton } from '@/app/(protected)/(basic)/_components/theme-toggle-button';
|
|
| 3 |
-import { Header } from '@/components/ui/header';
|
|
| 6 |
+import {
|
|
| 7 |
+ FoxBellSimpleIcon, |
|
| 8 |
+ FoxCaretDownIcon, |
|
| 9 |
+ FoxMagnifyingGlassIcon, |
|
| 10 |
+ FoxUserIcon, |
|
| 11 |
+} from '@fox/core/icons'; |
|
| 12 |
+import styles from './admin-header.module.scss'; |
|
| 13 |
+import { HeaderIconButton } from './header-icon-button';
|
|
| 4 | 14 |
|
| 5 | 15 |
interface AdminHeaderProps {
|
| 6 |
- onOpenSidebar: () => void; |
|
| 7 |
- onToggleSidebarCollapse: () => void; |
|
| 16 |
+ /** 프로필에 표시할 이름. 세션의 관리자 이름을 상위에서 받아온다. */ |
|
| 17 |
+ adminName: string; |
|
| 8 | 18 |
} |
| 9 | 19 |
|
| 10 | 20 |
/** |
| 11 |
- * 헤더 배치 — 비주얼 셸(Header)은 design 공용 컴포넌트를 그대로 쓰고, 사이드바 토글 버튼(공용 |
|
| 12 |
- * 컴포넌트가 제공하지 않는 상호작용)만 `leading` 슬롯에 주입한다. 테마 토글과 로그아웃은 |
|
| 13 |
- * `children`(우측 슬롯)에 주입한다 — 테마 토글이 로그아웃 왼쪽에 오도록 DOM 순서를 그대로 |
|
| 14 |
- * 시각 순서로 사용한다(Tab 포커스 순서도 동일하게 따라간다). |
|
| 21 |
+ * Basic 레이아웃 헤더 — 시안 header(7028:7614)를 그대로 옮겼다. 좌측 로고, 우측에 아이콘 |
|
| 22 |
+ * 버튼 3개와 프로필 pill이 온다. |
|
| 23 |
+ * |
|
| 24 |
+ * 시안과 다른 점 셋(전부 사용자 확정): |
|
| 25 |
+ * - 로고: 시안은 28px 정사각 심볼이지만 에듀페이 로고가 170×62 워드마크라 높이만 맞췄다. |
|
| 26 |
+ * - 사이드바 토글 버튼을 뺐다 — 시안에 없다. |
|
| 27 |
+ * - 프로필 pill을 눌러 로그아웃한다. 시안에 로그아웃 자리가 없는데 다른 경로도 없어서, |
|
| 28 |
+ * CaretDown이 이미 "열린다"고 알리고 있는 이 자리에 메뉴를 붙였다. |
|
| 29 |
+ * |
|
| 30 |
+ * 검색·알림·사용자 버튼은 시안에 있으나 연결할 기능이 아직 없어 `onAction` 없이 둔다. |
|
| 31 |
+ * 시안의 모양을 유지하려고 비활성으로 만들지는 않았다 — 기능이 정해지면 핸들러만 붙인다. |
|
| 15 | 32 |
*/ |
| 16 |
-export function AdminHeader({
|
|
| 17 |
- onOpenSidebar, |
|
| 18 |
- onToggleSidebarCollapse, |
|
| 19 |
-}: AdminHeaderProps) {
|
|
| 20 |
- return ( |
|
| 21 |
- <Header |
|
| 22 |
- title="에듀페이 관리자" |
|
| 23 |
- leading={
|
|
| 24 |
- <> |
|
| 25 |
- {/* 모바일 전용 — 사이드바를 오버레이로 연다 */}
|
|
| 26 |
- <button |
|
| 27 |
- type="button" |
|
| 28 |
- aria-label="사이드바 열기" |
|
| 29 |
- onClick={onOpenSidebar}
|
|
| 30 |
- className="border border-foreground/25 px-2 py-1 text-sm text-foreground md:hidden" |
|
| 31 |
- > |
|
| 32 |
- 메뉴 |
|
| 33 |
- </button> |
|
| 34 |
- {/* 데스크톱 전용 — 사이드바를 접거나 펼친다 */}
|
|
| 35 |
- <button |
|
| 36 |
- type="button" |
|
| 37 |
- aria-label="사이드바 접기/펼치기" |
|
| 38 |
- onClick={onToggleSidebarCollapse}
|
|
| 39 |
- className="hidden border border-foreground/25 px-2 py-1 text-sm text-foreground md:inline-flex" |
|
| 40 |
- > |
|
| 41 |
- 메뉴 |
|
| 42 |
- </button> |
|
| 43 |
- </> |
|
| 33 |
+export function AdminHeader({ adminName }: AdminHeaderProps) {
|
|
| 34 |
+ const [isMenuOpen, setIsMenuOpen] = useState(false); |
|
| 35 |
+ const profileRef = useRef<HTMLDivElement>(null); |
|
| 36 |
+ |
|
| 37 |
+ // 바깥을 누르거나 Esc를 누르면 닫는다 — 메뉴가 열린 동안에만 듣는다. |
|
| 38 |
+ useEffect(() => {
|
|
| 39 |
+ if (!isMenuOpen) {
|
|
| 40 |
+ return; |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ function handlePointerDown(event: MouseEvent) {
|
|
| 44 |
+ if (!profileRef.current?.contains(event.target as Node)) {
|
|
| 45 |
+ setIsMenuOpen(false); |
|
| 44 | 46 |
} |
| 45 |
- > |
|
| 46 |
- <ThemeToggleButton /> |
|
| 47 |
- <form action={logout}>
|
|
| 48 |
- <button |
|
| 49 |
- type="submit" |
|
| 50 |
- className="border border-foreground/25 px-3 py-1.5 text-sm text-foreground" |
|
| 51 |
- > |
|
| 52 |
- 로그아웃 |
|
| 53 |
- </button> |
|
| 54 |
- </form> |
|
| 55 |
- </Header> |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ function handleKeyDown(event: KeyboardEvent) {
|
|
| 50 |
+ if (event.key === 'Escape') {
|
|
| 51 |
+ setIsMenuOpen(false); |
|
| 52 |
+ } |
|
| 53 |
+ } |
|
| 54 |
+ |
|
| 55 |
+ document.addEventListener('mousedown', handlePointerDown);
|
|
| 56 |
+ document.addEventListener('keydown', handleKeyDown);
|
|
| 57 |
+ return () => {
|
|
| 58 |
+ document.removeEventListener('mousedown', handlePointerDown);
|
|
| 59 |
+ document.removeEventListener('keydown', handleKeyDown);
|
|
| 60 |
+ }; |
|
| 61 |
+ }, [isMenuOpen]); |
|
| 62 |
+ |
|
| 63 |
+ return ( |
|
| 64 |
+ <header className={styles.header}>
|
|
| 65 |
+ <div className={styles.logo}>
|
|
| 66 |
+ <Image |
|
| 67 |
+ src="/images/logo-foxpay.svg" |
|
| 68 |
+ alt="에듀페이" |
|
| 69 |
+ width={170}
|
|
| 70 |
+ height={62}
|
|
| 71 |
+ className={styles.logoMark}
|
|
| 72 |
+ priority |
|
| 73 |
+ /> |
|
| 74 |
+ <span className={styles.title}>에듀페이 관리자</span>
|
|
| 75 |
+ </div> |
|
| 76 |
+ |
|
| 77 |
+ <div className={styles.fnc}>
|
|
| 78 |
+ <div className={styles.btnGroup}>
|
|
| 79 |
+ <HeaderIconButton icon={<FoxMagnifyingGlassIcon />} label="검색" />
|
|
| 80 |
+ <HeaderIconButton icon={<FoxBellSimpleIcon />} label="알림" hasBadge />
|
|
| 81 |
+ <HeaderIconButton icon={<FoxUserIcon />} label="사용자" />
|
|
| 82 |
+ </div> |
|
| 83 |
+ |
|
| 84 |
+ <div className={styles.profile} ref={profileRef}>
|
|
| 85 |
+ <button |
|
| 86 |
+ type="button" |
|
| 87 |
+ className={styles.profileButton}
|
|
| 88 |
+ aria-haspopup="menu" |
|
| 89 |
+ aria-expanded={isMenuOpen}
|
|
| 90 |
+ onClick={() => setIsMenuOpen((open) => !open)}
|
|
| 91 |
+ > |
|
| 92 |
+ {/* 시안은 프로필 사진 자리지만 관리자 사진을 받는 경로가 없어 사람 아이콘으로 채운다. */}
|
|
| 93 |
+ <span className={styles.avatar}>
|
|
| 94 |
+ <FoxUserIcon weight="fill" /> |
|
| 95 |
+ </span> |
|
| 96 |
+ <span className={styles.profileName}>{adminName}</span>
|
|
| 97 |
+ <span className={styles.caret}>
|
|
| 98 |
+ <FoxCaretDownIcon /> |
|
| 99 |
+ </span> |
|
| 100 |
+ </button> |
|
| 101 |
+ |
|
| 102 |
+ {isMenuOpen && (
|
|
| 103 |
+ <div className={styles.menu} role="menu">
|
|
| 104 |
+ <form action={logout}>
|
|
| 105 |
+ <button type="submit" role="menuitem" className={styles.menuItem}>
|
|
| 106 |
+ 로그아웃 |
|
| 107 |
+ </button> |
|
| 108 |
+ </form> |
|
| 109 |
+ </div> |
|
| 110 |
+ )} |
|
| 111 |
+ </div> |
|
| 112 |
+ </div> |
|
| 113 |
+ </header> |
|
| 56 | 114 |
); |
| 57 | 115 |
} |
--- app/(protected)/(basic)/_components/admin-shell.tsx
+++ app/(protected)/(basic)/_components/admin-shell.tsx
... | ... | @@ -7,6 +7,8 @@ |
| 7 | 7 |
|
| 8 | 8 |
interface AdminShellProps {
|
| 9 | 9 |
children: React.ReactNode; |
| 10 |
+ /** 헤더 프로필에 표시할 관리자 이름 — 세션을 읽는 상위 Server Component가 넘긴다. */ |
|
| 11 |
+ adminName: string; |
|
| 10 | 12 |
} |
| 11 | 13 |
|
| 12 | 14 |
/** |
... | ... | @@ -14,15 +16,12 @@ |
| 14 | 16 |
* 소유한다. page 콘텐츠(children)는 상위 Server Component가 렌더한 결과를 props로 그대로 |
| 15 | 17 |
* 통과시켜 RSC를 유지한다 — 이 컴포넌트 자신은 children의 내용을 알거나 가공하지 않는다. |
| 16 | 18 |
*/ |
| 17 |
-export function AdminShell({ children }: AdminShellProps) {
|
|
| 19 |
+export function AdminShell({ children, adminName }: AdminShellProps) {
|
|
| 18 | 20 |
const sidebar = useSidebar(); |
| 19 | 21 |
|
| 20 | 22 |
return ( |
| 21 | 23 |
<div className="flex min-h-screen flex-col"> |
| 22 |
- <AdminHeader |
|
| 23 |
- onOpenSidebar={sidebar.openMobile}
|
|
| 24 |
- onToggleSidebarCollapse={sidebar.toggleDesktopCollapse}
|
|
| 25 |
- /> |
|
| 24 |
+ <AdminHeader adminName={adminName} />
|
|
| 26 | 25 |
<div className="flex flex-1"> |
| 27 | 26 |
<AdminSidebar |
| 28 | 27 |
isMobileOpen={sidebar.isMobileOpen}
|
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
... | ... | @@ -1,12 +1,21 @@ |
| 1 |
+import { getSessionAdmin } from '@/lib/auth/dal';
|
|
| 1 | 2 |
import { AdminShell } from './_components/admin-shell';
|
| 2 | 3 |
|
| 3 | 4 |
// Basic 레이아웃 — 헤더·사이드바·풋터가 있는 일반 관리자 화면 진입점. 보안 강제는 상위 |
| 4 | 5 |
// `(protected)/layout.tsx`가 담당하므로 여기서는 크롬 조립(AdminShell)만 책임진다. page |
| 5 | 6 |
// 콘텐츠는 Server Component로 렌더된 그대로 AdminShell의 children으로 통과한다. |
| 6 |
-export default function Layout({
|
|
| 7 |
+// |
|
| 8 |
+// 헤더 프로필에 쓸 이름만 세션에서 읽는다. **인증 판단은 하지 않는다** — 레이아웃은 Partial |
|
| 9 |
+// Rendering 때문에 인증 체크 지점으로 부적합하다는 상위 레이아웃의 판단을 그대로 따르므로, |
|
| 10 |
+// redirect하는 `verifySession()`이 아니라 null을 돌려주는 `getSessionAdmin()`을 쓴다. |
|
| 11 |
+export default async function Layout({
|
|
| 7 | 12 |
children, |
| 8 | 13 |
}: Readonly<{
|
| 9 | 14 |
children: React.ReactNode; |
| 10 | 15 |
}>) {
|
| 11 |
- return <AdminShell>{children}</AdminShell>;
|
|
| 16 |
+ const admin = await getSessionAdmin(); |
|
| 17 |
+ |
|
| 18 |
+ return ( |
|
| 19 |
+ <AdminShell adminName={admin?.name ?? '관리자'}>{children}</AdminShell>
|
|
| 20 |
+ ); |
|
| 12 | 21 |
} |
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?