docs-admin
어드민 문서 자동 뷰어 - 모든 가이드를 한 곳에서 관리
When & Why to Use This Skill
The Admin Document Auto Viewer is a powerful Claude skill designed to centralize and automate the management of internal documentation and guides. It features an automated markdown scanner that categorizes content (e.g., setup, development, SEO) and renders it through a professional admin dashboard. By integrating real-time search, database synchronization, and a clean UI, it transforms static markdown files into a dynamic, searchable, and accessible internal knowledge base for teams.
Use Cases
- Centralized Developer Portals: Automatically aggregate technical setup guides, API documentation, and deployment protocols into a single, unified admin interface.
- Searchable SOP Management: Create a searchable repository for Standard Operating Procedures (SOPs) and internal manuals, allowing team members to find answers instantly via the real-time search component.
- Automated Documentation Sync: Keep web-based documentation perfectly in sync with local markdown files using the automated API synchronization and scheduling features.
- Onboarding & Training: Provide a structured learning path for new hires by organizing 'Beginner' and 'Setup' guides directly within the platform's admin dashboard.
| name | docs-admin |
|---|---|
| description | 어드민 문서 자동 뷰어 - 모든 가이드를 한 곳에서 관리 |
어드민 문서 자동 뷰어
모든 KreatSaaS 가이드를 어드민 대시보드에서 한 곳에서 관리하고 열람
1. 자동 문서 로더
문서 스캔 유틸리티
// src/lib/docs/scanner.ts
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
export type DocCategory =
| 'beginner' // 초보자 가이드
| 'setup' // 설치 가이드
| 'development' // 개발 가이드
| 'deployment' // 배포 가이드
| 'enhancement' // 고도화 가이드
| 'seo' // SEO 가이드
| 'admin' // 어드민 가이드
| 'api'; // API 가이드
export interface DocMeta {
slug: string;
title: string;
description: string;
category: DocCategory;
order: number;
lastUpdated: string;
triggers?: string[];
}
export interface DocFile extends DocMeta {
content: string;
}
// KreatSaaS 스킬 디렉토리에서 모든 문서 스캔
export async function scanDocs(skillsDir: string): Promise<DocMeta[]> {
const docs: DocMeta[] = [];
try {
const skillFolders = await fs.readdir(skillsDir);
for (const folder of skillFolders) {
const skillPath = path.join(skillsDir, folder, 'SKILL.md');
try {
const content = await fs.readFile(skillPath, 'utf-8');
const { data, content: body } = matter(content);
docs.push({
slug: folder,
title: data.name || folder,
description: data.description || '',
category: getCategoryFromSlug(folder),
order: getCategoryOrder(folder),
lastUpdated: (await fs.stat(skillPath)).mtime.toISOString(),
triggers: data.triggers || [],
});
} catch {
// 파일이 없으면 스킵
}
}
} catch (error) {
console.error('Error scanning docs:', error);
}
return docs.sort((a, b) => a.order - b.order);
}
// 문서 내용 가져오기
export async function getDoc(skillsDir: string, slug: string): Promise<DocFile | null> {
const skillPath = path.join(skillsDir, slug, 'SKILL.md');
try {
const content = await fs.readFile(skillPath, 'utf-8');
const { data, content: body } = matter(content);
const stat = await fs.stat(skillPath);
return {
slug,
title: data.name || slug,
description: data.description || '',
category: getCategoryFromSlug(slug),
order: getCategoryOrder(slug),
lastUpdated: stat.mtime.toISOString(),
triggers: data.triggers || [],
content: body,
};
} catch {
return null;
}
}
function getCategoryFromSlug(slug: string): DocCategory {
const categoryMap: Record<string, DocCategory> = {
'beginner-guide': 'beginner',
'setup-guide': 'setup',
'enhancement-guide': 'enhancement',
'seo-guide': 'seo',
'admin-guide': 'admin',
'docs-admin': 'admin',
'saas-design': 'development',
'llm-auto-update': 'development',
};
return categoryMap[slug] || 'development';
}
function getCategoryOrder(slug: string): number {
const orderMap: Record<string, number> = {
'beginner-guide': 1,
'setup-guide': 2,
'saas-design': 3,
'admin-guide': 4,
'enhancement-guide': 5,
'seo-guide': 6,
'llm-auto-update': 7,
'docs-admin': 8,
};
return orderMap[slug] || 99;
}
필요 패키지
npm install gray-matter
npm install react-markdown remark-gfm rehype-highlight
2. 어드민 문서 페이지
문서 목록 페이지
// src/app/admin/docs/page.tsx
import Link from 'next/link';
import { scanDocs, DocCategory } from '@/lib/docs/scanner';
import { Book, Code, Rocket, Settings, Search, Zap } from 'lucide-react';
const categoryInfo: Record<DocCategory, { icon: any; label: string; color: string }> = {
beginner: { icon: Book, label: '초보자 가이드', color: 'bg-green-100 text-green-800' },
setup: { icon: Settings, label: '설치 가이드', color: 'bg-blue-100 text-blue-800' },
development: { icon: Code, label: '개발 가이드', color: 'bg-purple-100 text-purple-800' },
deployment: { icon: Rocket, label: '배포 가이드', color: 'bg-orange-100 text-orange-800' },
enhancement: { icon: Zap, label: '고도화 가이드', color: 'bg-yellow-100 text-yellow-800' },
seo: { icon: Search, label: 'SEO 가이드', color: 'bg-pink-100 text-pink-800' },
admin: { icon: Settings, label: '어드민 가이드', color: 'bg-red-100 text-red-800' },
api: { icon: Code, label: 'API 가이드', color: 'bg-indigo-100 text-indigo-800' },
};
export default async function DocsPage() {
const skillsDir = process.env.KREATSAAS_SKILLS_DIR || './skills';
const docs = await scanDocs(skillsDir);
// 카테고리별 그룹화
const groupedDocs = docs.reduce((acc, doc) => {
if (!acc[doc.category]) {
acc[doc.category] = [];
}
acc[doc.category].push(doc);
return acc;
}, {} as Record<DocCategory, typeof docs>);
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">문서 가이드</h1>
<span className="text-sm text-gray-500">
총 {docs.length}개 문서
</span>
</div>
{Object.entries(groupedDocs).map(([category, categoryDocs]) => {
const info = categoryInfo[category as DocCategory];
const Icon = info.icon;
return (
<div key={category} className="space-y-4">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-lg ${info.color}`}>
<Icon className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold">{info.label}</h2>
<span className="text-sm text-gray-400">({categoryDocs.length})</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryDocs.map((doc) => (
<Link
key={doc.slug}
href={`/admin/docs/${doc.slug}`}
className="block p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow"
>
<h3 className="font-medium text-gray-900 mb-2">{doc.title}</h3>
<p className="text-sm text-gray-500 line-clamp-2 mb-3">
{doc.description}
</p>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>업데이트: {new Date(doc.lastUpdated).toLocaleDateString('ko-KR')}</span>
{doc.triggers && doc.triggers.length > 0 && (
<span className="bg-gray-100 px-2 py-1 rounded">
{doc.triggers[0]}
</span>
)}
</div>
</Link>
))}
</div>
</div>
);
})}
</div>
);
}
문서 상세 페이지
// src/app/admin/docs/[slug]/page.tsx
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getDoc, scanDocs } from '@/lib/docs/scanner';
import { MarkdownRenderer } from '@/components/admin/MarkdownRenderer';
import { ArrowLeft, Edit, Clock, Tag } from 'lucide-react';
export async function generateStaticParams() {
const skillsDir = process.env.KREATSAAS_SKILLS_DIR || './skills';
const docs = await scanDocs(skillsDir);
return docs.map((doc) => ({ slug: doc.slug }));
}
export default async function DocDetailPage({
params,
}: {
params: { slug: string };
}) {
const skillsDir = process.env.KREATSAAS_SKILLS_DIR || './skills';
const doc = await getDoc(skillsDir, params.slug);
if (!doc) {
notFound();
}
return (
<div className="max-w-4xl mx-auto">
{/* 헤더 */}
<div className="mb-8">
<Link
href="/admin/docs"
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeft className="w-4 h-4" />
문서 목록으로
</Link>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{doc.title}</h1>
<p className="text-gray-600 mb-4">{doc.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{new Date(doc.lastUpdated).toLocaleDateString('ko-KR')}
</span>
{doc.triggers && doc.triggers.length > 0 && (
<span className="flex items-center gap-1">
<Tag className="w-4 h-4" />
{doc.triggers.join(', ')}
</span>
)}
</div>
</div>
{/* 콘텐츠 */}
<div className="bg-white rounded-xl shadow-sm p-8">
<MarkdownRenderer content={doc.content} />
</div>
{/* 하단 네비게이션 */}
<div className="mt-8 pt-8 border-t flex justify-between">
<Link
href="/admin/docs"
className="text-blue-600 hover:text-blue-800"
>
← 목록으로
</Link>
<button className="flex items-center gap-2 text-gray-500 hover:text-gray-700">
<Edit className="w-4 h-4" />
수정 요청
</button>
</div>
</div>
);
}
마크다운 렌더러
// src/components/admin/MarkdownRenderer.tsx
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
type Props = {
content: string;
};
export function MarkdownRenderer({ content }: Props) {
return (
<ReactMarkdown
className="prose prose-lg max-w-none"
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
// 코드 블록 스타일링
pre: ({ children }) => (
<pre className="bg-gray-900 rounded-lg overflow-x-auto">
{children}
</pre>
),
code: ({ inline, className, children }) => {
if (inline) {
return (
<code className="bg-gray-100 text-red-600 px-1 py-0.5 rounded text-sm">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
// 테이블 스타일링
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
{children}
</table>
</div>
),
// 체크박스 스타일링
input: ({ type, checked }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 rounded"
/>
);
}
return <input type={type} />;
},
// 링크 새 탭에서 열기
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}
3. 사이드바에 문서 메뉴 추가
// src/components/admin/Sidebar.tsx 수정
const menuItems = [
{ href: '/admin', icon: LayoutDashboard, label: '대시보드' },
{ href: '/admin/users', icon: Users, label: '사용자 관리' },
{ href: '/admin/analytics', icon: BarChart3, label: '통계' },
{ href: '/admin/subscriptions', icon: CreditCard, label: '구독 관리' },
{ href: '/admin/content', icon: FileText, label: '콘텐츠' },
{ href: '/admin/docs', icon: Book, label: '문서 가이드' }, // 추가
{ href: '/admin/settings', icon: Settings, label: '설정' },
];
4. 문서 자동 업데이트 API
문서 동기화 API
// src/app/api/admin/docs/sync/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withPermission } from '@/lib/admin/auth';
import { scanDocs, getDoc } from '@/lib/docs/scanner';
import { prisma } from '@/lib/prisma';
// 문서를 DB에 동기화
export const POST = withPermission('settings:write', async (req) => {
const skillsDir = process.env.KREATSAAS_SKILLS_DIR || './skills';
const docs = await scanDocs(skillsDir);
const results = [];
for (const docMeta of docs) {
const doc = await getDoc(skillsDir, docMeta.slug);
if (!doc) continue;
// DB에 upsert
const result = await prisma.doc.upsert({
where: { slug: doc.slug },
create: {
slug: doc.slug,
title: doc.title,
description: doc.description,
category: doc.category,
content: doc.content,
order: doc.order,
triggers: doc.triggers || [],
lastUpdated: new Date(doc.lastUpdated),
},
update: {
title: doc.title,
description: doc.description,
category: doc.category,
content: doc.content,
order: doc.order,
triggers: doc.triggers || [],
lastUpdated: new Date(doc.lastUpdated),
},
});
results.push(result);
}
return NextResponse.json({
success: true,
synced: results.length,
docs: results.map((r) => ({ slug: r.slug, title: r.title })),
});
});
문서 검색 API
// src/app/api/admin/docs/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withPermission } from '@/lib/admin/auth';
import { prisma } from '@/lib/prisma';
export const GET = withPermission('admin:access', async (req) => {
const { searchParams } = new URL(req.url);
const query = searchParams.get('q') || '';
const category = searchParams.get('category');
const docs = await prisma.doc.findMany({
where: {
AND: [
query
? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
{ triggers: { has: query } },
],
}
: {},
category ? { category } : {},
],
},
select: {
slug: true,
title: true,
description: true,
category: true,
lastUpdated: true,
},
orderBy: { order: 'asc' },
});
return NextResponse.json(docs);
});
5. 문서 DB 스키마
// prisma/schema.prisma 추가
model Doc {
id String @id @default(cuid())
slug String @unique
title String
description String
category String
content String @db.Text
order Int @default(99)
triggers String[]
lastUpdated DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([title])
}
6. 실시간 문서 검색 컴포넌트
// src/components/admin/DocSearch.tsx
'use client';
import { useState, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { Search, X } from 'lucide-react';
import Link from 'next/link';
type SearchResult = {
slug: string;
title: string;
description: string;
category: string;
};
export function DocSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery.length < 2) {
setResults([]);
return;
}
async function search() {
const res = await fetch(`/api/admin/docs/search?q=${encodeURIComponent(debouncedQuery)}`);
const data = await res.json();
setResults(data);
}
search();
}, [debouncedQuery]);
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder="문서 검색... (제목, 키워드)"
className="w-full pl-10 pr-10 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{query && (
<button
onClick={() => {
setQuery('');
setResults([]);
}}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="w-5 h-5 text-gray-400" />
</button>
)}
</div>
{/* 검색 결과 */}
{isOpen && results.length > 0 && (
<div className="absolute top-full mt-2 w-full bg-white rounded-lg shadow-lg border z-50 max-h-96 overflow-y-auto">
{results.map((result) => (
<Link
key={result.slug}
href={`/admin/docs/${result.slug}`}
onClick={() => {
setIsOpen(false);
setQuery('');
}}
className="block p-4 hover:bg-gray-50 border-b last:border-b-0"
>
<div className="font-medium text-gray-900">{result.title}</div>
<div className="text-sm text-gray-500 line-clamp-1">
{result.description}
</div>
<div className="text-xs text-gray-400 mt-1">
{result.category}
</div>
</Link>
))}
</div>
)}
{/* 검색 결과 없음 */}
{isOpen && query.length >= 2 && results.length === 0 && (
<div className="absolute top-full mt-2 w-full bg-white rounded-lg shadow-lg border z-50 p-4 text-center text-gray-500">
검색 결과가 없습니다
</div>
)}
</div>
);
}
7. 문서 자동 업데이트 설정
환경변수
# .env.local
KREATSAAS_SKILLS_DIR=./skills
DOCS_AUTO_SYNC=true
DOCS_SYNC_INTERVAL=3600000 # 1시간마다
자동 동기화 스케줄러
// src/lib/docs/scheduler.ts
import cron from 'node-cron';
import { syncDocs } from './sync';
export function startDocsSyncScheduler() {
// 매시간 문서 동기화
cron.schedule('0 * * * *', async () => {
console.log('[Docs] Starting automatic sync...');
try {
const result = await syncDocs();
console.log(`[Docs] Synced ${result.synced} documents`);
} catch (error) {
console.error('[Docs] Sync failed:', error);
}
});
console.log('[Docs] Scheduler started - syncing every hour');
}
8. 문서 관리 체크리스트
┌─────────────────────────────────────────────────────────────┐
│ 📚 문서 관리 체크리스트 │
├─────────────────────────────────────────────────────────────┤
│ │
│ □ gray-matter 패키지 설치 │
│ □ react-markdown, remark-gfm 설치 │
│ □ rehype-highlight 설치 │
│ □ Doc 모델 DB 마이그레이션 │
│ □ /admin/docs 페이지 구현 │
│ □ /admin/docs/[slug] 상세 페이지 구현 │
│ □ MarkdownRenderer 컴포넌트 구현 │
│ □ 사이드바에 문서 메뉴 추가 │
│ □ 문서 검색 API 구현 │
│ □ 문서 동기화 API 구현 │
│ □ 자동 동기화 스케줄러 설정 (선택) │
│ │
└─────────────────────────────────────────────────────────────┘