어드민 문서 자동 뷰어 - 모든 가이드를 한 곳에서 관리
Automatically scans and displays all KreatSaaS skill documentation in a unified admin dashboard. It triggers when you navigate to `/admin/docs` to provide categorized, searchable access to all guides.
/plugin marketplace add johunsang/kreatsaas/plugin install kreatsaas@kreatsaas-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
모든 KreatSaaS 가이드를 어드민 대시보드에서 한 곳에서 관리하고 열람
// 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
// 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>
);
}
// 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: '설정' },
];
// 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 })),
});
});
// 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);
});
// 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])
}
// 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>
);
}
# .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');
}
┌─────────────────────────────────────────────────────────────┐
│ 📚 문서 관리 체크리스트 │
├─────────────────────────────────────────────────────────────┤
│ │
│ □ gray-matter 패키지 설치 │
│ □ react-markdown, remark-gfm 설치 │
│ □ rehype-highlight 설치 │
│ □ Doc 모델 DB 마이그레이션 │
│ □ /admin/docs 페이지 구현 │
│ □ /admin/docs/[slug] 상세 페이지 구현 │
│ □ MarkdownRenderer 컴포넌트 구현 │
│ □ 사이드바에 문서 메뉴 추가 │
│ □ 문서 검색 API 구현 │
│ □ 문서 동기화 API 구현 │
│ □ 자동 동기화 스케줄러 설정 (선택) │
│ │
└─────────────────────────────────────────────────────────────┘
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.
Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.