임동욱 임동욱 08-18
merge: basic 레이아웃 헤더 시안 구현
@698f3fd83d80909000487e9456b7825c3ea15281
 
app/(protected)/(basic)/_components/admin-header.module.scss (added)
+++ app/(protected)/(basic)/_components/admin-header.module.scss
@@ -0,0 +1,167 @@
+// Basic 레이아웃 헤더 — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                            header (7028:7614 · 컴포넌트 7001:10471)
+//
+// 시안에서 디자인시스템 컴포넌트로 정의된 조각은 아이콘 4종과 badge-dot뿐이다. 로고 영역·
+// 아이콘 버튼·프로필 pill은 header 안의 로컬 프레임이라 @fox로 승격하지 않고 여기서 조립한다.
+// 색·간격·radius·타이포는 전부 @fox 토큰을 거친다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.header {
+  // 시안 높이 64는 테두리를 포함한 값이다(Figma stroke는 안쪽에 그린다).
+  box-sizing: border-box;
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: space-between;
+  // 시안 64px. Figma가 변수로 export하지 않는다.
+  block-size: 6.4rem;
+  padding-inline: fox.padding(8);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+  background: fox.color(surface-neutral-default);
+}
+
+// ── 로고 ────────────────────────────────────────────────────────────────────
+// 시안은 28px 정사각 심볼 + 텍스트지만, 에듀페이 로고는 170×62 워드마크다. 높이만 28px로
+// 맞추고 폭은 비율대로 둔다(사용자 확정).
+.logo {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(3);
+}
+
+.logoMark {
+  display: block;
+  block-size: 2.8rem;
+  inline-size: auto;
+}
+
+.title {
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(title);
+  // 시안 24px / letter-spacing -0.6px(= -0.025em). Figma가 변수로 export하지 않는다.
+  font-size: 2.4rem;
+  font-weight: fox.font-weight(bold);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+  white-space: nowrap;
+}
+
+// ── 우측 기능 영역 ──────────────────────────────────────────────────────────
+.fnc {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: fox.gap(5);
+}
+
+.btnGroup {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+}
+
+// ── 프로필 ──────────────────────────────────────────────────────────────────
+// 메뉴가 이 버튼 기준으로 떠야 해서 위치 기준을 만든다.
+.profile {
+  position: relative;
+}
+
+.profileButton {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+  padding-block: fox.padding(3);
+  padding-inline: fox.padding(4);
+  border: none;
+  border-radius: fox.radius(max);
+  // 시안 높이 44 = 상하 여백 8 + 아바타 28. Figma stroke가 안쪽이라 테두리가 높이를 늘리지
+  // 않는다 — `border`를 쓰면 46이 되므로 레이아웃에 관여하지 않는 inset 그림자로 같은 선을
+  // 그린다.
+  box-shadow: inset 0 0 0 fox.border(1) fox.color(border-neutral-subtle);
+  background: fox.color(action-default);
+  cursor: pointer;
+}
+
+.avatar {
+  display: inline-flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  // 시안 28px 원형 + 내부 20px. Figma가 변수로 export하지 않는다.
+  inline-size: 2.8rem;
+  block-size: 2.8rem;
+  border-radius: fox.radius(max);
+  background: fox.theme(primary-10);
+  color: fox.color(icon-neutral-default);
+}
+
+// 시안은 이 자리에 프로필 사진을 넣지만 관리자 사진을 받는 경로가 없어 사람 아이콘으로 채운다.
+.avatar > svg {
+  inline-size: 2rem;
+  block-size: 2rem;
+}
+
+.profileName {
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(label-md);
+  font-weight: fox.font-weight(medium);
+  line-height: 1;
+  letter-spacing: -0.025em;
+  white-space: nowrap;
+}
+
+.caret {
+  display: inline-flex;
+  flex-shrink: 0;
+  inline-size: fox.icon(3);
+  block-size: fox.icon(3);
+  color: fox.color(icon-neutral-default);
+}
+
+.caret > svg {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+// ── 프로필 메뉴 ─────────────────────────────────────────────────────────────
+// ⚠️ 시안에 없는 부분이다. 시안의 pill은 CaretDown만 있고 열린 모습이 그려져 있지 않아,
+// 판때기는 FoxSelectOption(떠 있는 목록)이 쓰는 것과 같은 토큰 조합으로 맞췄다.
+.menu {
+  position: absolute;
+  inset-block-start: calc(100% + #{fox.gap(2)});
+  inset-inline-end: 0;
+  z-index: 1;
+  min-inline-size: 100%;
+  padding-block: fox.padding(2);
+  border: fox.border(1) solid fox.color(border-neutral-default);
+  border-radius: fox.form(radius-md);
+  background: fox.color(surface-neutral-default);
+  // 그림자 색은 FoxSelectOption과 같은 값을 쓴다 — 시안이 테마 토큰이 아니라 원시값에
+  // 물려 놓아서 다크 모드에서도 검은 그림자다.
+  box-shadow: fox.shadow(location1) fox.shadow(location1) fox.shadow(blur1) 0
+    fox.primitive(alpha-black10);
+}
+
+.menuItem {
+  display: flex;
+  inline-size: 100%;
+  align-items: center;
+  padding-block: fox.padding(3);
+  padding-inline: fox.padding(4);
+  border: none;
+  background: none;
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(label-md);
+  font-weight: fox.font-weight(regular);
+  line-height: 1;
+  letter-spacing: -0.025em;
+  white-space: nowrap;
+  cursor: pointer;
+
+  &:hover {
+    background: fox.color(action-default-hover);
+  }
+}
app/(protected)/(basic)/_components/admin-header.tsx
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
@@ -1,57 +1,115 @@
+'use client';
+
+import Image from 'next/image';
+import { useEffect, useRef, useState } from 'react';
 import { logout } from '@/app/(protected)/_actions';
-import { ThemeToggleButton } from '@/app/(protected)/(basic)/_components/theme-toggle-button';
-import { Header } from '@/components/ui/header';
+import {
+  FoxBellSimpleIcon,
+  FoxCaretDownIcon,
+  FoxMagnifyingGlassIcon,
+  FoxUserIcon,
+} from '@fox/core/icons';
+import styles from './admin-header.module.scss';
+import { HeaderIconButton } from './header-icon-button';
 
 interface AdminHeaderProps {
-  onOpenSidebar: () => void;
-  onToggleSidebarCollapse: () => void;
+  /** 프로필에 표시할 이름. 세션의 관리자 이름을 상위에서 받아온다. */
+  adminName: string;
 }
 
 /**
- * 헤더 배치 — 비주얼 셸(Header)은 design 공용 컴포넌트를 그대로 쓰고, 사이드바 토글 버튼(공용
- * 컴포넌트가 제공하지 않는 상호작용)만 `leading` 슬롯에 주입한다. 테마 토글과 로그아웃은
- * `children`(우측 슬롯)에 주입한다 — 테마 토글이 로그아웃 왼쪽에 오도록 DOM 순서를 그대로
- * 시각 순서로 사용한다(Tab 포커스 순서도 동일하게 따라간다).
+ * Basic 레이아웃 헤더 — 시안 header(7028:7614)를 그대로 옮겼다. 좌측 로고, 우측에 아이콘
+ * 버튼 3개와 프로필 pill이 온다.
+ *
+ * 시안과 다른 점 셋(전부 사용자 확정):
+ *  - 로고: 시안은 28px 정사각 심볼이지만 에듀페이 로고가 170×62 워드마크라 높이만 맞췄다.
+ *  - 사이드바 토글 버튼을 뺐다 — 시안에 없다.
+ *  - 프로필 pill을 눌러 로그아웃한다. 시안에 로그아웃 자리가 없는데 다른 경로도 없어서,
+ *    CaretDown이 이미 "열린다"고 알리고 있는 이 자리에 메뉴를 붙였다.
+ *
+ * 검색·알림·사용자 버튼은 시안에 있으나 연결할 기능이 아직 없어 `onAction` 없이 둔다.
+ * 시안의 모양을 유지하려고 비활성으로 만들지는 않았다 — 기능이 정해지면 핸들러만 붙인다.
  */
-export function AdminHeader({
-  onOpenSidebar,
-  onToggleSidebarCollapse,
-}: AdminHeaderProps) {
-  return (
-    <Header
-      title="에듀페이 관리자"
-      leading={
-        <>
-          {/* 모바일 전용 — 사이드바를 오버레이로 연다 */}
-          <button
-            type="button"
-            aria-label="사이드바 열기"
-            onClick={onOpenSidebar}
-            className="border border-foreground/25 px-2 py-1 text-sm text-foreground md:hidden"
-          >
-            메뉴
-          </button>
-          {/* 데스크톱 전용 — 사이드바를 접거나 펼친다 */}
-          <button
-            type="button"
-            aria-label="사이드바 접기/펼치기"
-            onClick={onToggleSidebarCollapse}
-            className="hidden border border-foreground/25 px-2 py-1 text-sm text-foreground md:inline-flex"
-          >
-            메뉴
-          </button>
-        </>
+export function AdminHeader({ adminName }: AdminHeaderProps) {
+  const [isMenuOpen, setIsMenuOpen] = useState(false);
+  const profileRef = useRef<HTMLDivElement>(null);
+
+  // 바깥을 누르거나 Esc를 누르면 닫는다 — 메뉴가 열린 동안에만 듣는다.
+  useEffect(() => {
+    if (!isMenuOpen) {
+      return;
+    }
+
+    function handlePointerDown(event: MouseEvent) {
+      if (!profileRef.current?.contains(event.target as Node)) {
+        setIsMenuOpen(false);
       }
-    >
-      <ThemeToggleButton />
-      <form action={logout}>
-        <button
-          type="submit"
-          className="border border-foreground/25 px-3 py-1.5 text-sm text-foreground"
-        >
-          로그아웃
-        </button>
-      </form>
-    </Header>
+    }
+
+    function handleKeyDown(event: KeyboardEvent) {
+      if (event.key === 'Escape') {
+        setIsMenuOpen(false);
+      }
+    }
+
+    document.addEventListener('mousedown', handlePointerDown);
+    document.addEventListener('keydown', handleKeyDown);
+    return () => {
+      document.removeEventListener('mousedown', handlePointerDown);
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [isMenuOpen]);
+
+  return (
+    <header className={styles.header}>
+      <div className={styles.logo}>
+        <Image
+          src="/images/logo-foxpay.svg"
+          alt="에듀페이"
+          width={170}
+          height={62}
+          className={styles.logoMark}
+          priority
+        />
+        <span className={styles.title}>에듀페이 관리자</span>
+      </div>
+
+      <div className={styles.fnc}>
+        <div className={styles.btnGroup}>
+          <HeaderIconButton icon={<FoxMagnifyingGlassIcon />} label="검색" />
+          <HeaderIconButton icon={<FoxBellSimpleIcon />} label="알림" hasBadge />
+          <HeaderIconButton icon={<FoxUserIcon />} label="사용자" />
+        </div>
+
+        <div className={styles.profile} ref={profileRef}>
+          <button
+            type="button"
+            className={styles.profileButton}
+            aria-haspopup="menu"
+            aria-expanded={isMenuOpen}
+            onClick={() => setIsMenuOpen((open) => !open)}
+          >
+            {/* 시안은 프로필 사진 자리지만 관리자 사진을 받는 경로가 없어 사람 아이콘으로 채운다. */}
+            <span className={styles.avatar}>
+              <FoxUserIcon weight="fill" />
+            </span>
+            <span className={styles.profileName}>{adminName}</span>
+            <span className={styles.caret}>
+              <FoxCaretDownIcon />
+            </span>
+          </button>
+
+          {isMenuOpen && (
+            <div className={styles.menu} role="menu">
+              <form action={logout}>
+                <button type="submit" role="menuitem" className={styles.menuItem}>
+                  로그아웃
+                </button>
+              </form>
+            </div>
+          )}
+        </div>
+      </div>
+    </header>
   );
 }
app/(protected)/(basic)/_components/admin-shell.tsx
--- app/(protected)/(basic)/_components/admin-shell.tsx
+++ app/(protected)/(basic)/_components/admin-shell.tsx
@@ -7,6 +7,8 @@
 
 interface AdminShellProps {
   children: React.ReactNode;
+  /** 헤더 프로필에 표시할 관리자 이름 — 세션을 읽는 상위 Server Component가 넘긴다. */
+  adminName: string;
 }
 
 /**
@@ -14,15 +16,12 @@
  * 소유한다. page 콘텐츠(children)는 상위 Server Component가 렌더한 결과를 props로 그대로
  * 통과시켜 RSC를 유지한다 — 이 컴포넌트 자신은 children의 내용을 알거나 가공하지 않는다.
  */
-export function AdminShell({ children }: AdminShellProps) {
+export function AdminShell({ children, adminName }: AdminShellProps) {
   const sidebar = useSidebar();
 
   return (
     <div className="flex min-h-screen flex-col">
-      <AdminHeader
-        onOpenSidebar={sidebar.openMobile}
-        onToggleSidebarCollapse={sidebar.toggleDesktopCollapse}
-      />
+      <AdminHeader adminName={adminName} />
       <div className="flex flex-1">
         <AdminSidebar
           isMobileOpen={sidebar.isMobileOpen}
 
app/(protected)/(basic)/_components/header-icon-button.module.scss (added)
+++ app/(protected)/(basic)/_components/header-icon-button.module.scss
@@ -0,0 +1,34 @@
+// 헤더 전용 아이콘 버튼 — FoxIconButton 위에 시안 값 두 개만 덮는다.
+//
+// 시안(header 7001:10471의 `item`)은 40px 박스 · radius 4px · 아이콘 24px · 테두리 없음인데,
+// FoxIconButton의 md는 radius 8px이고 ghost는 아이콘을 32px로 키운다. 이 조합은 btn-ico 시안에
+// 없어서 @fox에 새 계열을 만들 근거가 없다(Figma에서도 이 `item`은 btn-ico 인스턴스가 아니라
+// 헤더 안의 로컬 프레임이다). 그래서 디자인시스템은 그대로 두고 헤더 쪽에서만 덮는다.
+//
+// 셀렉터를 겹쳐 쓴 이유 — @fox 전역 스타일과 CSS 모듈은 명시도가 같아 로드 순서에 좌우된다.
+// `:global()`로 한 단계 더 얹어 순서와 무관하게 이기게 한다.
+
+@use "@fox/styles/abstracts" as fox;
+
+// 배지가 버튼 모서리에 붙어야 해서 위치 기준을 만든다 — 배지는 버튼(<button>) 밖에 둔다.
+.slot {
+  position: relative;
+  display: inline-flex;
+}
+
+.button:global(.fox-icon-button) {
+  border-radius: fox.form(radius-sm);
+}
+
+.button:global(.fox-icon-button) :global(.fox-icon-button__icon) {
+  inline-size: fox.icon(5);
+  block-size: fox.icon(5);
+}
+
+// 시안 값(top/right 5px)이나 Figma가 변수로 export하지 않는다.
+.badge {
+  position: absolute;
+  inset-block-start: 0.5rem;
+  inset-inline-end: 0.5rem;
+  pointer-events: none;
+}
 
app/(protected)/(basic)/_components/header-icon-button.tsx (added)
+++ app/(protected)/(basic)/_components/header-icon-button.tsx
@@ -0,0 +1,41 @@
+import type { ReactNode } from 'react';
+import { FoxBadgeDot } from '@fox/core/components/fox-badge-dot';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import styles from './header-icon-button.module.scss';
+
+interface HeaderIconButtonProps {
+  icon: ReactNode;
+  /** 글자가 없으므로 이 값이 접근성 이름이 된다. */
+  label: string;
+  /** 참이면 우상단에 알림 점(FoxBadgeDot)을 얹는다. */
+  hasBadge?: boolean;
+  onAction?: () => void;
+}
+
+/**
+ * 헤더 우측 아이콘 버튼(시안 header의 `item`). FoxIconButton을 그대로 쓰고 모양 차이 두 개
+ * (radius·아이콘 크기)만 CSS 모듈로 덮는다 — 근거는 module.scss 상단 참고.
+ *
+ * 배지는 `<button>` 안이 아니라 형제로 둔다. 버튼 안에 넣으면 아이콘 슬롯의 크기 규칙
+ * (`__icon > *`가 100%)에 걸려 배지까지 늘어난다.
+ */
+export function HeaderIconButton({
+  icon,
+  label,
+  hasBadge = false,
+  onAction,
+}: HeaderIconButtonProps) {
+  return (
+    <span className={styles.slot}>
+      <FoxIconButton
+        type="ghost"
+        size="md"
+        icon={icon}
+        label={label}
+        className={styles.button}
+        onAction={onAction}
+      />
+      {hasBadge && <FoxBadgeDot className={styles.badge} label="읽지 않은 알림 있음" />}
+    </span>
+  );
+}
app/(protected)/(basic)/layout.tsx
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
@@ -1,12 +1,21 @@
+import { getSessionAdmin } from '@/lib/auth/dal';
 import { AdminShell } from './_components/admin-shell';
 
 // Basic 레이아웃 — 헤더·사이드바·풋터가 있는 일반 관리자 화면 진입점. 보안 강제는 상위
 // `(protected)/layout.tsx`가 담당하므로 여기서는 크롬 조립(AdminShell)만 책임진다. page
 // 콘텐츠는 Server Component로 렌더된 그대로 AdminShell의 children으로 통과한다.
-export default function Layout({
+//
+// 헤더 프로필에 쓸 이름만 세션에서 읽는다. **인증 판단은 하지 않는다** — 레이아웃은 Partial
+// Rendering 때문에 인증 체크 지점으로 부적합하다는 상위 레이아웃의 판단을 그대로 따르므로,
+// redirect하는 `verifySession()`이 아니라 null을 돌려주는 `getSessionAdmin()`을 쓴다.
+export default async function Layout({
   children,
 }: Readonly<{
   children: React.ReactNode;
 }>) {
-  return <AdminShell>{children}</AdminShell>;
+  const admin = await getSessionAdmin();
+
+  return (
+    <AdminShell adminName={admin?.name ?? '관리자'}>{children}</AdminShell>
+  );
 }
Add a comment
List