관리자 레이아웃 컴포넌트 구현

규돌·2026년 4월 13일

이 글은 Claude Code로 요약하였습니다

Figma 디자인 분석 → 컴포넌트 구조 결정
    ↓
GNB 개발 + Storybook
    ↓
AccordionMenu 구현 (내부 전용)
    ↓
LeftPanel 개발 + Storybook
    ↓
AdminLayout 조합
    ↓
CLAUDE.md 업데이트


1. Figma 디자인 분석 → 컴포넌트 구조 결정

Figma MCP로 관리자 레이아웃 디자인을 분석한 결과, 세 개의 컴포넌트와 하나의 내부 전용 헬퍼로 구조를 결정했다.

src/shared/components/layout/
  GNB.tsx / GNB.module.css / GNB.stories.tsx
  LeftPanel.tsx / LeftPanel.module.css / LeftPanel.stories.tsx
  AdminLayout.tsx / AdminLayout.module.css / AdminLayout.stories.tsx
  AccordionMenu.tsx / AccordionMenu.module.css   ← 내부 전용 (export 안 함)
  index.ts

반응형 breakpoint:

  • PC: ≥1200px — LeftPanel이 in-flow 요소로 고정 노출
  • TAB: 800–1199px — LeftPanel이 fixed overlay로 전환
  • MO: ≤799px — GNB 간소화 + LeftPanel overlay

SVG 로고 추출 불가 문제: Figma MCP의 get_design_context는 복잡한 벡터를 래스터 이미지 URL로 반환하기 때문에 SVG를 직접 추출할 수 없다. DefaultLogo 플레이스홀더를 사용하고, 실제 SVG는 Figma에서 수동으로 내보내야 한다.


2. GNB 개발 + Storybook

2-1. 반응형 구조

GNB는 justify-content: space-between의 flex 컨테이너로, 좌·중앙·우 세 영역을 CSS 미디어 쿼리만으로 반응형 처리한다.

구간좌측중앙우측
PC (≥1200px)햄버거 + 로고—"안녕하세요," + 이름님 + 알림
TAB (800–1199px)햄버거 + 로고—이름님 + 알림
MO (≤799px)햄버거pageName알림

중앙 pageName은 position: absolute; left: 50%; transform: translateX(-50%)로 배치해 좌우 영역 너비와 무관하게 항상 정중앙에 고정된다.

// isPanelOpen 값으로 fold/unfold 아이콘 전환
<IconButton
  icon={<i className={isPanelOpen ? 'ri-menu-fold-line' : 'ri-menu-unfold-line'} />}
  onClick={onMenuToggle}
/>

2-2. Storybook 함수 props 숨기기

함수 props(onMenuToggle, onProfileClick 등)는 Storybook Controls 패널에 노출되면 노이즈가 된다. argTypes로 숨긴다.

const meta: Meta<typeof GNB> = {
  component: GNB,
  argTypes: {
    onMenuToggle: { table: { disable: true } },
    onProfileClick: { table: { disable: true } },
    onNotificationClick: { table: { disable: true } },
  },
};

3. AccordionMenu 구현 (내부 전용)

LeftPanel의 "모집 공고", "메세지", "동아리명" 섹션이 공통적으로 펼침/접힘 동작이 필요해서 추출한 내부 컴포넌트다. layout/index.ts에서 export하지 않는다.

기존 Menu 컴포넌트의 showArrowIcon + active prop을 활용해 트리거를 구성한다. active=true이면 화살표가 아래를 가리키고, false이면 오른쪽을 가리킨다.

export function AccordionMenu({ label, menuIcon, defaultOpen = false, triggerClassName, children }) {
  const [isOpen, setIsOpen] = useState(defaultOpen);

  return (
    <div className={styles.root}>
      <Menu menuIcon={menuIcon} showArrowIcon active={isOpen}
            onClick={() => setIsOpen(v => !v)} className={triggerClassName}>
        {label}
      </Menu>
      {isOpen && <div className={styles.subList}>{children}</div>}
    </div>
  );
}

triggerClassName을 외부에서 주입받아 동아리명 트리거에만 font-weight: 600을 적용한다.


4. LeftPanel 개발 + Storybook

4-1. PC vs TAB/MO 다른 슬라이드 방식

두 breakpoint에서 슬라이드 방식이 다르다.

PC (≥1200px) — width 기반 in-flow:
패널이 flex 자식 요소로 flow에 포함된다. width: 0에서 width: 280px로 트랜지션하면서 콘텐츠 영역이 자연스럽게 밀려난다.

gap 대신 패널의 margin-right를 함께 트랜지션시키는 이유: gap은 width가 0이 되어도 사라지지 않기 때문이다.

@media (min-width: 1200px) {
  .panel {
    width: 0;
    padding: 0;
    margin-right: 0;
    transition: width 0.25s ease, padding 0.25s ease, margin-right 0.25s ease;
  }
  .panelOpen {
    width: 280px;
    padding: 8px;
    margin-right: 20px;
  }
}

TAB/MO (<1200px) — translateX 기반 fixed overlay:
패널이 position: fixed로 flow에서 빠져나온다. translateX(-100%)에서 translateX(0)으로 트랜지션한다.

@media (max-width: 1199px) {
  .panel {
    position: fixed;
    top: 0; left: 0;
    height: 100dvh;
    transform: translateX(-100%);
    transition: transform 0.25s ease, box-shadow 0.25s ease;
    z-index: 100;
  }
  .panelOpen {
    transform: translateX(0);
    box-shadow: var(--shadow-modal);
  }
}

백드롭은 TAB/MO에서만 display: block으로 전환되고, 클릭 시 onClose를 호출한다.

4-2. 아코디언 자동 열림

activeMenu 값에 따라 해당 아코디언을 마운트 시 자동으로 열어둔다.

const isRecruitmentActive = (key?: string) =>
  !!key && (key === 'recruitment-new' || key.startsWith('recruitment-'));

<AccordionMenu defaultOpen={isRecruitmentActive(activeMenu)}>

4-3. Storybook에서 클릭 상호작용 구현

기본 args만으로는 메뉴 클릭 시 활성 상태가 바뀌는 걸 Storybook에서 확인할 수 없다. meta의 render에 useState를 써서 모든 스토리가 실제 인터랙션을 갖도록 한다.

const meta: Meta<typeof LeftPanel> = {
  component: LeftPanel,
  render: (args) => {
    const [activeMenu, setActiveMenu] = useState(args.activeMenu);
    return <LeftPanel {...args} activeMenu={activeMenu} onNavigate={setActiveMenu} />;
  },
};

5. AdminLayout 조합

GNB + LeftPanel + 콘텐츠 영역을 조합하는 최상위 레이아웃. isPanelOpen 상태를 내부에서 관리해 GNB의 햄버거 버튼과 LeftPanel의 닫기 버튼/백드롭을 연동한다.

export function AdminLayout({ children, ...props }) {
  const [isPanelOpen, setIsPanelOpen] = useState(true);

  return (
    <div className={styles.layout}>
      <GNB isPanelOpen={isPanelOpen} onMenuToggle={() => setIsPanelOpen(v => !v)} {...gnbProps} />
      <div className={styles.body}>
        <LeftPanel isOpen={isPanelOpen} onClose={() => setIsPanelOpen(false)} {...panelProps} />
        <main className={styles.content}>{children}</main>
      </div>
    </div>
  );
}

isPanelOpen은 PC에서도 동작한다 — true이면 280px width로 슬라이드 인, false이면 width 0으로 슬라이드 아웃되어 콘텐츠 영역이 전체 너비를 차지한다.


6. CLAUDE.md 업데이트

구현 완료 후 src/shared/components/CLAUDE.md를 실제 구현과 일치하도록 업데이트했다. props 목록, 메뉴 key 테이블, 반응형 동작, isPanelOpen 연동 방식을 정확히 반영했다.

profile
회고하기 위한 저장소

0개의 댓글