Multi-Column App Shell

FreeNew

아이콘 사이드 + 네비 사이드 + 본문 + 우측 aside. 화면 폭에 따라 컬럼이 단계적으로 접힘. (메일·이슈 트래커류)

Live preview

Code

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * Multi-Column App Shell — 좁은 아이콘 사이드 + 메인 네비 사이드 + 본문 + 우측 aside.
 * 메일·이슈 트래커류 멀티컬럼 앱 셸 레이아웃.
 *
 * 구조 (데스크탑):
 *   ┌────┬───────────┬──────────────┬──────────┐
 *   │ 🔲 │  Nav      │   Content    │  Aside   │
 *   │ 아이콘│  사이드   │   (메인)     │  (보조)  │
 *   └────┴───────────┴──────────────┴──────────┘
 *
 * 반응형 축소:
 *   - lg 미만: 우측 aside 숨김
 *   - md 미만: 메인 네비 사이드 숨김
 *   - sm 미만: 아이콘 사이드도 숨기고 상단 햄버거로 토글
 */

const NAV = ["Inbox", "Sent", "Drafts", "Archive", "Spam"];
const ICONS = ["home", "mail", "users", "calendar", "settings"] as const;

export function AppShellMultiColumnTemplate() {
  const [navOpen, setNavOpen] = React.useState(false);
  const [active, setActive] = React.useState("Inbox");

  return (
    <div className="flex h-[560px] w-full overflow-hidden rounded-lg border border-border bg-muted/20">
      {/* 1. Narrow icon sidebar — sm 이상 표시 */}
      <aside className="hidden w-14 shrink-0 flex-col items-center gap-2 border-r border-border bg-card py-3 sm:flex">
        <div className="flex size-8 items-center justify-center rounded-md bg-primary text-xs font-bold text-primary-foreground">
          G
        </div>
        <div className="mt-2 flex flex-col gap-1">
          {ICONS.map((ic, i) => (
            <button
              key={ic}
              type="button"
              className={cn(
                "flex size-9 items-center justify-center rounded-lg transition-colors",
                i === 0
                  ? "bg-muted text-foreground"
                  : "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
              )}
              aria-label={ic}
            >
              <IconGlyph name={ic} />
            </button>
          ))}
        </div>
      </aside>

      {/* 2. Main nav sidebar — md 이상 표시 */}
      <nav className="hidden w-52 shrink-0 flex-col border-r border-border bg-card md:flex">
        <div className="flex h-12 items-center px-4 text-sm font-semibold">Mailbox</div>
        <ul className="flex-1 space-y-0.5 px-2 py-1">
          {NAV.map((item) => (
            <li key={item}>
              <button
                type="button"
                onClick={() => setActive(item)}
                className={cn(
                  "w-full rounded-md px-3 py-1.5 text-left text-sm font-medium transition-colors",
                  active === item
                    ? "bg-muted text-foreground"
                    : "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
                )}
              >
                {item}
              </button>
            </li>
          ))}
        </ul>
      </nav>

      {/* 3. Main content */}
      <main className="flex min-w-0 flex-1 flex-col">
        {/* 모바일 상단바 (md 미만에서 nav 토글) */}
        <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-card px-3 md:px-4">
          <button
            type="button"
            onClick={() => setNavOpen((o) => !o)}
            aria-label="Toggle navigation"
            className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden"
          >
            <Menu />
          </button>
          <span className="text-sm font-semibold">{active}</span>
        </div>

        {/* 모바일 펼침 네비 */}
        {navOpen && (
          <nav className="border-b border-border bg-card px-2 py-2 md:hidden">
            {NAV.map((item) => (
              <button
                key={item}
                type="button"
                onClick={() => {
                  setActive(item);
                  setNavOpen(false);
                }}
                className={cn(
                  "block w-full rounded-md px-3 py-2 text-left text-sm font-medium transition-colors",
                  active === item
                    ? "bg-muted text-foreground"
                    : "text-muted-foreground hover:bg-muted/60",
                )}
              >
                {item}
              </button>
            ))}
          </nav>
        )}

        <div className="flex-1 space-y-2 overflow-y-auto p-3 md:p-4">
          {Array.from({ length: 8 }).map((_, i) => (
            <div
              key={i}
              className="rounded-lg border border-border bg-card p-3"
            >
              <div className="h-3 w-1/3 rounded bg-muted" />
              <div className="mt-2 h-2 w-2/3 rounded bg-muted/60" />
            </div>
          ))}
        </div>
      </main>

      {/* 4. Right aside — lg 이상 표시 */}
      <aside className="hidden w-64 shrink-0 flex-col border-l border-border bg-card p-4 lg:flex">
        <h3 className="text-sm font-semibold">Details</h3>
        <p className="mt-1 text-xs text-muted-foreground">
          선택한 항목의 보조 정보를 표시하는 우측 컬럼입니다.
        </p>
        <div className="mt-4 space-y-2">
          {Array.from({ length: 3 }).map((_, i) => (
            <div key={i} className="h-12 rounded-lg border border-dashed border-border" />
          ))}
        </div>
      </aside>
    </div>
  );
}

function Menu() {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
      <path d="M4 6h16M4 12h16M4 18h16" />
    </svg>
  );
}

function IconGlyph({ name }: { name: (typeof ICONS)[number] }) {
  const p: Record<(typeof ICONS)[number], React.ReactNode> = {
    home: <path d="M3 9.5 12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
    mail: (
      <>
        <rect x="2" y="4" width="20" height="16" rx="2" />
        <path d="m2 7 10 6 10-6" />
      </>
    ),
    users: (
      <>
        <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
        <circle cx="9" cy="7" r="4" />
      </>
    ),
    calendar: (
      <>
        <rect x="3" y="4" width="18" height="18" rx="2" />
        <path d="M16 2v4M8 2v4M3 10h18" />
      </>
    ),
    settings: (
      <>
        <circle cx="12" cy="12" r="3" />
        <path d="M12 2v2m0 16v2M4 12H2m20 0h-2m-2.5-5.5 1.5-1.5M5 19l1.5-1.5M19 19l-1.5-1.5M6.5 6.5 5 5" />
      </>
    ),
  };
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      {p[name]}
    </svg>
  );
}