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.tsx의metadata.title확인 - 컴포넌트가 안 뜸 →
page.tsx가 우리 컴포넌트 렌더링 중인지 확인
여기까지가 “환경 + 기본 골격”입니다.
다음 편(2/6)에서는 테마 교체(다크/브랜드 컬러) + shadcn/ui 구성 + 타이포그래피 + 접근성 팁까지 실전으로 붙입니다.
답글 남기기