Claude Code Plugins

Community-maintained marketplace

Feedback

어드민 문서 자동 뷰어 - 모든 가이드를 한 곳에서 관리

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name docs-admin
description 어드민 문서 자동 뷰어 - 모든 가이드를 한 곳에서 관리
triggers docs, 문서, 가이드뷰어, documentation

어드민 문서 자동 뷰어

모든 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 구현                                       │
│ □ 자동 동기화 스케줄러 설정 (선택)                          │
│                                                              │
└─────────────────────────────────────────────────────────────┘