[카테고리:] 미분류

  • Next.js + Tailwind v4 + shadcn/ui + Recharts 1편

    0) 시스템 준비

    # 필수 도구
    sudo apt update
    sudo apt install -y git curl build-essential
    
    # Node 설치 (nvm 권장)
    curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
    source ~/.bashrc
    nvm install 22
    node -v
    
    # 패키지 매니저
    corepack enable   # pnpm / yarn 사용 가능
    

    1) Next.js 앱 만들기

    # 프로젝트 생성
    pnpm create next-app potatonet-admin \
      --ts --eslint --app --src-dir --tailwind --import-alias "@/*"
    
    cd potatonet-admin
    

    위 옵션으로 App Router, TypeScript, Tailwind 기본 구성까지 잡힙니다.

    2) 의존성 설치

    # UI/아이콘/차트
    pnpm add lucide-react recharts
    
    # shadcn/ui (컴포넌트 생성기)
    npx shadcn@latest init -d
    npx shadcn@latest add button card input badge label switch sheet dropdown-menu select
    
    # Tailwind v4 플러그인(문서용)
    pnpm add -D @tailwindcss/typography
    

    3) Tailwind v4 설정 (PostCSS 방식)

    postcss.config.mjs

    export default {
      plugins: {
        "@tailwindcss/postcss": {},
        autoprefixer: {},
      },
    }
    

    src/app/globals.css

    @import "tailwindcss";
    @plugin "@tailwindcss/typography";
    
    /* 선택: 라이트/다크 모두 지원 */
    :root { color-scheme: light dark; }
    

    src/app/layout.tsx — 글로벌 CSS + 탭 제목(브랜드)

    export const metadata = {
      title: "potatonet",           // ← 크롬 탭에 표시될 제목
      description: "potatonet admin",
    };
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="ko">
          <body>{children}</body>
        </html>
      );
    }
    

    metadata.title만 바꿔도 “Create Next App”가 potatonet으로 바뀝니다.

    4) 데모 컴포넌트 연결

    컴포넌트 파일 생성

    mkdir -p src/components
    

    src/components/AdminThemeDemo.tsx

    최소 버전(그룹 메뉴 + 다크토글이 있는 껍데기). 다음 편에서 실제 테이블/차트/시트까지 확장합니다.

    "use client";
    import React, { useState } from "react";
    import { Button } from "@/components/ui/button";
    import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
    import { Badge } from "@/components/ui/badge";
    import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
    import { Label } from "@/components/ui/label";
    import { Switch } from "@/components/ui/switch";
    import { ChevronDown, Menu, Moon, Sun, Users, Activity, Shield, ListChecks, Database, FileText } from "lucide-react";
    
    type RouteKey =
      | "dashboard"
      | "ingest-requests" | "ingest-crawl-logs" | "ingest-deepweb"
      | "assets-screenshots" | "assets-screenshot-hashes" | "assets-files"
      | "analysis-domain" | "analysis-file" | "analysis-ip" | "analysis-js" | "analysis-risk-models"
      | "adj-compare" | "adj-mismatch" | "adj-fp-suspects" | "adj-fp-final-edits" | "adj-fp-reports" | "adj-phishing"
      | "intel-url-db" | "intel-domain-db" | "intel-bkb-db"
      | "ops-users" | "ops-admins" | "ops-initial-passwords" | "ops-billing" | "ops-bookmarks" | "ops-search-history" | "ops-login-stats" | "ops-search-stats"
      | "audit-user-access" | "audit-admin-access" | "audit-user-auth" | "audit-admin-auth" | "audit-system-ops"
      | "settings";
    
    interface MenuItem { key: RouteKey; label: string; icon: React.ComponentType<any> }
    interface MenuGroup { label: string; items: MenuItem[] }
    
    const menuGroups: MenuGroup[] = [
      { label: "개요", items: [{ key: "dashboard", label: "대시보드", icon: Activity }] },
      {
        label: "수집·크롤링",
        items: [
          { key: "ingest-requests", label: "웹 요청 관리", icon: Database },
          { key: "ingest-crawl-logs", label: "크롤링 로그 관리", icon: Database },
          { key: "ingest-deepweb", label: "딥웹 관리", icon: Database },
        ],
      },
      {
        label: "스크린샷 · 파일 자산",
        items: [
          { key: "assets-screenshots", label: "웹 스크린샷 관리", icon: FileText },
          { key: "assets-screenshot-hashes", label: "스크린샷 해시 관리", icon: FileText },
          { key: "assets-files", label: "파일 관리", icon: FileText },
        ],
      },
      {
        label: "분석 · 탐지",
        items: [
          { key: "analysis-domain", label: "도메인 분석 관리", icon: ListChecks },
          { key: "analysis-file", label: "파일 분석 관리", icon: ListChecks },
          { key: "analysis-ip", label: "IP 분석 관리", icon: ListChecks },
          { key: "analysis-js", label: "JS 분석 관리", icon: ListChecks },
          { key: "analysis-risk-models", label: "위험도 모델 관리", icon: ListChecks },
        ],
      },
      {
        label: "판정 · 검수",
        items: [
          { key: "adj-compare", label: "판정 결과 비교", icon: Database },
          { key: "adj-mismatch", label: "판정 상이 관리", icon: Database },
          { key: "adj-fp-suspects", label: "오탐 의심 관리", icon: Database },
          { key: "adj-fp-final-edits", label: "오탐(Final) 수정 기록", icon: Database },
          { key: "adj-fp-reports", label: "오탐 신고 관리", icon: Database },
          { key: "adj-phishing", label: "피싱 관리", icon: Database },
        ],
      },
      {
        label: "인텔리전스 DB",
        items: [
          { key: "intel-url-db", label: "URL DB 관리", icon: Database },
          { key: "intel-domain-db", label: "도메인 DB 관리", icon: Database },
          { key: "intel-bkb-db", label: "BKB DB 관리", icon: Database },
        ],
      },
      {
        label: "서비스 운영",
        items: [
          { key: "ops-users", label: "사용자 관리", icon: Users },
          { key: "ops-admins", label: "관리자 관리", icon: Users },
          { key: "ops-initial-passwords", label: "초기 비밀번호 관리", icon: Users },
          { key: "ops-billing", label: "과금 관리", icon: Users },
          { key: "ops-bookmarks", label: "북마크 관리", icon: Users },
          { key: "ops-search-history", label: "검색 기록 관리", icon: Users },
          { key: "ops-login-stats", label: "로그인 통계", icon: Users },
          { key: "ops-search-stats", label: "사용자 검색 기록 통계", icon: Users },
        ],
      },
      {
        label: "접근 기록 · 감사",
        items: [
          { key: "audit-user-access", label: "사용자 접근이력 로그", icon: Shield },
          { key: "audit-admin-access", label: "관리자 접근이력 로그", icon: Shield },
          { key: "audit-user-auth", label: "사용자 로그인/로그아웃 로그", icon: Shield },
          { key: "audit-admin-auth", label: "관리자 로그인/로그아웃 로그", icon: Shield },
          { key: "audit-system-ops", label: "시스템 운영 로그", icon: Shield },
        ],
      },
    ];
    
    export default function AdminThemeDemo() {
      const [dark, setDark] = useState(true);
      const [collapsed, setCollapsed] = useState(false);
      const [active, setActive] = useState<RouteKey>("dashboard");
    
      return (
        <div className={dark ? "dark" : ""}>
          <div className="min-h-screen bg-gray-50 text-gray-900 transition-colors dark:bg-neutral-950 dark:text-neutral-100">
            {/* Top bar */}
            <header className="sticky top-0 z-40 border-b bg-white/70 backdrop-blur supports-[backdrop-filter]:bg-white/60 dark:border-neutral-800 dark:bg-neutral-950/60">
              <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
                <div className="flex items-center gap-2">
                  <Button variant="ghost" size="icon" className="md:hidden" onClick={() => setCollapsed((c) => !c)}>
                    <Menu className="h-5 w-5" />
                  </Button>
                  <div className="flex items-center gap-2">
                    <div className="h-7 w-7 rounded-xl bg-gradient-to-br from-indigo-500 to-fuchsia-500" />
                    <span className="font-semibold tracking-tight">Admin Demo</span>
                  </div>
                  <Badge variant="secondary" className="ml-2 hidden md:inline-flex">Prototype</Badge>
                </div>
    
                <div className="flex items-center gap-2">
                  <Button variant="ghost" size="icon" onClick={() => setDark((d) => !d)}>
                    {dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
                  </Button>
    
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="outline" className="gap-2 dark:border-neutral-800">
                        <div className="h-6 w-6 rounded-full bg-gradient-to-br from-sky-500 to-violet-500" />
                        Admin <ChevronDown className="h-4 w-4 opacity-70" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end" className="w-56">
                      <DropdownMenuLabel>My Account</DropdownMenuLabel>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem>Profile</DropdownMenuItem>
                      <DropdownMenuItem>Billing</DropdownMenuItem>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem className="text-red-600">Sign out</DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </div>
              </div>
            </header>
    
            {/* Layout */}
            <div className="mx-auto grid max-w-7xl grid-cols-12 gap-4 px-4 py-6">
              {/* Sidebar */}
              <aside className={`col-span-12 md:col-span-3 lg:col-span-2 ${collapsed ? "hidden md:block" : "block"}`}>
                <nav className="sticky top-[60px] space-y-4 rounded-2xl border bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
                  {menuGroups.map((g) => (
                    <div key={g.label} className="space-y-1">
                      <div className="px-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-neutral-400">{g.label}</div>
                      {g.items.map((m) => (
                        <button
                          key={m.key}
                          onClick={() => setActive(m.key)}
                          className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-100 dark:hover:bg-neutral-800 ${active===m.key?"bg-gray-100 font-medium dark:bg-neutral-800":""}`}
                        >
                          <m.icon className="h-4 w-4" />
                          <span>{m.label}</span>
                        </button>
                      ))}
                    </div>
                  ))}
    
                  <Card className="dark:border-neutral-800 dark:bg-neutral-900">
                    <CardHeader>
                      <CardTitle className="text-base">Quick Links</CardTitle>
                      <CardDescription>Common admin actions</CardDescription>
                    </CardHeader>
                    <CardContent className="flex flex-col gap-2">
                      <Button variant="secondary" className="justify-start">Create user</Button>
                      <Button variant="secondary" className="justify-start">New report</Button>
                      <Button variant="secondary" className="justify-start">Export CSV</Button>
                    </CardContent>
                  </Card>
                </nav>
              </aside>
    
              {/* Content */}
              <main className="col-span-12 md:col-span-9 lg:col-span-10">
                <Placeholder />
              </main>
            </div>
          </div>
        </div>
      );
    }
    
    function Placeholder(){
      return (
        <div className="space-y-4">
          <Card className="dark:border-neutral-800 dark:bg-neutral-900">
            <CardHeader>
              <CardTitle>시작하기</CardTitle>
              <CardDescription>다음 편에서 테이블/차트/시트까지 붙입니다.</CardDescription>
            </CardHeader>
            <CardContent className="prose dark:prose-invert">
              <p>좌측 그룹형 사이드바는 즉시 동작합니다. 본문은 플레이스홀더입니다.</p>
            </CardContent>
          </Card>
        </div>
      );
    }
    

    홈 페이지에 연결

    src/app/page.tsx

    import AdminThemeDemo from "@/components/AdminThemeDemo";
    export default function Page(){ return <AdminThemeDemo />; }
    

    5) 실행 (포트 7401)

    pnpm approve-builds          # Tailwind v4(oxide) 빌드 승인 — 처음 한 번
    pnpm dev -- -H 0.0.0.0 -p 7401
    # 브라우저: http://서버IP:7401  (개발용)
    

    프로덕션은 pnpm build && pnpm start -p 7401 (배포/Nginx는 시리즈 5편에서!)

    6) 자주 막히는 포인트

    • 스타일이 하나도 안 붙음globals.css 임포트 누락, PostCSS 설정 확인, pnpm approve-builds 미실행
    • Create Next App 제목이 안 바뀜layout.tsxmetadata.title 확인
    • 컴포넌트가 안 뜸page.tsx가 우리 컴포넌트 렌더링 중인지 확인

    여기까지가 “환경 + 기본 골격”입니다.
    다음 편(2/6)에서는 테마 교체(다크/브랜드 컬러) + shadcn/ui 구성 + 타이포그래피 + 접근성 팁까지 실전으로 붙입니다.