임동욱 임동욱 07-27
feat: Basic 레이아웃 셸과 사이드바 상태 훅 추가
Co-Authored-By: Claude Opus 5 
@ae211640b924dec47a6c59212d50257cecef3bfc
app/(protected)/(basic)/_components/admin-header.tsx
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
@@ -1,11 +1,39 @@
 import { logout } from '@/app/(protected)/_actions';
 
-export function AdminHeader() {
+interface AdminHeaderProps {
+  onOpenSidebar: () => void;
+  onToggleSidebarCollapse: () => void;
+}
+
+export function AdminHeader({
+  onOpenSidebar,
+  onToggleSidebarCollapse,
+}: AdminHeaderProps) {
   return (
     <header className="flex items-center justify-between border-b border-foreground/15 bg-background px-6 py-4">
-      <span className="text-base font-semibold text-foreground">
-        에듀페이 관리자
-      </span>
+      <div className="flex items-center gap-3">
+        {/* 모바일 전용 — 사이드바를 오버레이로 연다 */}
+        <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>
+        <span className="text-base font-semibold text-foreground">
+          에듀페이 관리자
+        </span>
+      </div>
       <form action={logout}>
         <button
           type="submit"
 
app/(protected)/(basic)/_components/admin-shell.tsx (added)
+++ app/(protected)/(basic)/_components/admin-shell.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useSidebar } from '@/app/(protected)/(basic)/_hooks/use-sidebar';
+import { AdminFooter } from './admin-footer';
+import { AdminHeader } from './admin-header';
+import { AdminSidebar } from './admin-sidebar';
+
+interface AdminShellProps {
+  children: React.ReactNode;
+}
+
+/**
+ * Basic 레이아웃의 상호작용 경계 — 헤더·사이드바·풋터를 조립하고 사이드바 상태(useSidebar)를
+ * 소유한다. page 콘텐츠(children)는 상위 Server Component가 렌더한 결과를 props로 그대로
+ * 통과시켜 RSC를 유지한다 — 이 컴포넌트 자신은 children의 내용을 알거나 가공하지 않는다.
+ */
+export function AdminShell({ children }: AdminShellProps) {
+  const sidebar = useSidebar();
+
+  return (
+    <div className="flex min-h-screen flex-col">
+      <AdminHeader
+        onOpenSidebar={sidebar.openMobile}
+        onToggleSidebarCollapse={sidebar.toggleDesktopCollapse}
+      />
+      <div className="flex flex-1">
+        <AdminSidebar
+          isMobileOpen={sidebar.isMobileOpen}
+          isDesktopCollapsed={sidebar.isDesktopCollapsed}
+          onCloseMobile={sidebar.closeMobile}
+        />
+        <div className="flex flex-1 flex-col">
+          <main className="flex flex-1 flex-col">{children}</main>
+          <AdminFooter />
+        </div>
+      </div>
+    </div>
+  );
+}
 
app/(protected)/(basic)/_components/admin-sidebar.tsx (added)
+++ app/(protected)/(basic)/_components/admin-sidebar.tsx
@@ -0,0 +1,74 @@
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+interface SidebarNavItem {
+  label: string;
+  href: string;
+}
+
+// 현재 정의된 화면은 홈뿐이다 — 신규 화면이 추가될 때마다 이 목록에 항목을 늘린다.
+const SIDEBAR_NAV_ITEMS: SidebarNavItem[] = [{ label: '홈', href: '/' }];
+
+interface AdminSidebarProps {
+  isMobileOpen: boolean;
+  isDesktopCollapsed: boolean;
+  onCloseMobile: () => void;
+}
+
+/**
+ * 사이드바 View — 내비 목록을 그린다. 데스크톱 push/접힘과 모바일 오버레이+백드롭은 전부
+ * Tailwind 반응형 클래스로 처리한다(CSS-first) — JS로 브레이크포인트를 판별하지 않는다.
+ */
+export function AdminSidebar({
+  isMobileOpen,
+  isDesktopCollapsed,
+  onCloseMobile,
+}: AdminSidebarProps) {
+  const pathname = usePathname();
+
+  return (
+    <>
+      {isMobileOpen && (
+        <button
+          type="button"
+          aria-label="사이드바 닫기"
+          onClick={onCloseMobile}
+          className="fixed inset-0 z-30 bg-foreground/50 md:hidden"
+        />
+      )}
+      <aside
+        className={`fixed inset-y-0 left-0 z-40 flex flex-col overflow-hidden border-r border-foreground/15 bg-background transition-all duration-200 md:relative md:z-auto md:translate-x-0 ${
+          isMobileOpen ? 'w-64 translate-x-0' : 'w-64 -translate-x-full'
+        } ${isDesktopCollapsed ? 'md:w-16' : 'md:w-64'}`}
+      >
+        <nav className="flex flex-1 flex-col gap-1 p-4">
+          {SIDEBAR_NAV_ITEMS.map((item) => {
+            const isActive = pathname === item.href;
+
+            return (
+              <Link
+                key={item.href}
+                href={item.href}
+                aria-current={isActive ? 'page' : undefined}
+                className={`rounded-md px-3 py-2 text-sm whitespace-nowrap ${
+                  isActive
+                    ? 'bg-foreground/10 text-foreground'
+                    : 'text-foreground/70'
+                }`}
+              >
+                <span className={isDesktopCollapsed ? 'md:hidden' : undefined}>
+                  {item.label}
+                </span>
+                {isDesktopCollapsed && (
+                  <span aria-hidden className="hidden md:inline">
+                    {item.label.slice(0, 1)}
+                  </span>
+                )}
+              </Link>
+            );
+          })}
+        </nav>
+      </aside>
+    </>
+  );
+}
 
app/(protected)/(basic)/_hooks/use-sidebar.ts (added)
+++ app/(protected)/(basic)/_hooks/use-sidebar.ts
@@ -0,0 +1,54 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { useCallback, useState } from 'react';
+
+export interface SidebarState {
+  isMobileOpen: boolean;
+  isDesktopCollapsed: boolean;
+  openMobile: () => void;
+  closeMobile: () => void;
+  toggleDesktopCollapse: () => void;
+}
+
+/**
+ * Basic 레이아웃 사이드바의 화면 로컬 상태(ViewModel 대응) — 모바일 오버레이 열림 여부와
+ * 데스크톱 접힘 여부만 값으로 들고 있는다. 실제로 어느 상태가 화면에 반영되는지(모바일 오버레이 vs
+ * 데스크톱 push)는 Tailwind 반응형 클래스가 판단한다 — matchMedia로 브레이크포인트를 직접 판별하면
+ * 서버 렌더와 최초 클라이언트 렌더가 어긋나 hydration 불일치를 일으킬 수 있어 이 훅에서는 다루지
+ * 않는다.
+ */
+export function useSidebar(): SidebarState {
+  const pathname = usePathname();
+  const [isMobileOpen, setIsMobileOpen] = useState(false);
+  const [isDesktopCollapsed, setIsDesktopCollapsed] = useState(false);
+
+  // 라우트가 바뀌면 모바일 오버레이는 항상 닫는다 (링크 클릭 후 열려있는 채로 남는 것 방지).
+  // effect로 동기화하지 않는다 — 렌더 중 상태를 조정하는 React 공식 패턴(state 값 변화를
+  // 렌더 중 감지해 즉시 setState)을 사용해 effect발 cascading render를 피한다.
+  const [prevPathname, setPrevPathname] = useState(pathname);
+  if (pathname !== prevPathname) {
+    setPrevPathname(pathname);
+    setIsMobileOpen(false);
+  }
+
+  const openMobile = useCallback(() => {
+    setIsMobileOpen(true);
+  }, []);
+
+  const closeMobile = useCallback(() => {
+    setIsMobileOpen(false);
+  }, []);
+
+  const toggleDesktopCollapse = useCallback(() => {
+    setIsDesktopCollapsed((prev) => !prev);
+  }, []);
+
+  return {
+    isMobileOpen,
+    isDesktopCollapsed,
+    openMobile,
+    closeMobile,
+    toggleDesktopCollapse,
+  };
+}
app/(protected)/(basic)/layout.tsx
--- app/(protected)/(basic)/layout.tsx
+++ app/(protected)/(basic)/layout.tsx
@@ -1,16 +1,12 @@
-import { AdminHeader } from './_components/admin-header';
+import { AdminShell } from './_components/admin-shell';
 
-// Basic 레이아웃 — 헤더가 있는 일반 관리자 화면 진입점. 보안 강제는 상위 `(protected)/layout.tsx`가
-// 담당하므로 여기서는 크롬 조립만 책임진다.
+// Basic 레이아웃 — 헤더·사이드바·풋터가 있는 일반 관리자 화면 진입점. 보안 강제는 상위
+// `(protected)/layout.tsx`가 담당하므로 여기서는 크롬 조립(AdminShell)만 책임진다. page
+// 콘텐츠는 Server Component로 렌더된 그대로 AdminShell의 children으로 통과한다.
 export default function Layout({
   children,
 }: Readonly<{
   children: React.ReactNode;
 }>) {
-  return (
-    <div className="flex min-h-screen flex-col">
-      <AdminHeader />
-      <main className="flex flex-1 flex-col">{children}</main>
-    </div>
-  );
+  return <AdminShell>{children}</AdminShell>;
 }
Add a comment
List