SHIN STORYSHIN STORY
홈포스트C#TypeScriptNext.jsNode.js시리즈
</>SHIN STORY

sdf

탐색

  • 홈
  • 모든 포스트
  • 시리즈
  • 검색

카테고리

  • C#
  • TypeScript
  • Next.js
  • Node.js
  • 알고리즘
  • 개발 도구

© 2025 Shin Blog. All rights reserved.

GitHubRSS
목록으로
Next.js#TypeScript#Next.js#Prisma

5편. 관리자 대시보드 — 포스트 CRUD 구현

SHIN

2025년 3월 20일12분 읽기0
📚

Next.js App Router로 풀스택 블로그 만들기

9편
  1. 11편. 프로젝트 소개와 기술 스택 선정
  2. 22편. 프로젝트 초기 설정과 디렉토리 구조
  3. 33편. Prisma 스키마 설계와 마이그레이션
  4. 44편. NextAuth.js로 관리자 인증 구현하기
  5. 5편. 관리자 대시보드 — 포스트 CRUD 구현현재
  6. 66편. 블로그 프론트엔드와 마크다운 렌더링
  7. 77편. 검색·카테고리·태그·시리즈 기능 구현
  8. 98편. SEO 최적화 — 메타태그·sitemap·RSS
  9. 99편. Vercel 배포와 성능 최적화

5편. 관리자 대시보드 — 포스트 CRUD 구현

API Route 구조

CODE
app/api/
├── posts/
│   ├── route.ts          GET(목록), POST(생성)
│   └── [id]/
│       └── route.ts      GET, PUT(수정), DELETE
└── upload/
    └── route.ts          POST(이미지 업로드)

포스트 생성 API

CODE
// src/app/api/posts/route.ts
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'
import { calcReadingTime } from '@/lib/utils'

export async function POST(req: Request) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const body = await req.json()
  const { title, slug, content, excerpt, coverImage,
          categoryId, tagIds, seriesId, seriesOrder,
          status, featured, pinned, scheduledAt } = body

  const post = await prisma.post.create({
    data: {
      title,
      slug,
      content,
      excerpt,
      coverImage,
      status,
      featured: featured ?? false,
      pinned: pinned ?? false,
      readingTime: calcReadingTime(content),
      publishedAt: status === 'PUBLISHED' ? new Date() : null,
      scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
      authorId: session.user!.id!,
      categoryId: categoryId || null,
      tags: tagIds?.length
        ? { create: tagIds.map((tagId: string) => ({ tagId })) }
        : undefined,
    },
  })

  // 시리즈 연결
  if (seriesId) {
    await prisma.seriesPost.create({
      data: { seriesId, postId: post.id, order: seriesOrder ?? 0 },
    })
  }

  return NextResponse.json(post, { status: 201 })
}

슬러그 자동 생성

CODE
export function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^\w\s가-힣]/g, '')        // 특수문자 제거
    .replace(/[\s_]+/g, '-')             // 공백/언더스코어 → 하이픈
    .replace(/^-+|-+$/g, '')             // 앞뒤 하이픈 제거
    .substring(0, 100)
}

포스트 편집 폼 (주요 부분)

CODE
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'

export function PostEditor({ post, categories, tags, seriesList }) {
  const [title, setTitle] = useState(post?.title ?? '')
  const [content, setContent] = useState(post?.content ?? '')
  const [status, setStatus] = useState(post?.status ?? 'DRAFT')
  const router = useRouter()

  const save = async (nextStatus = status) => {
    const url = post ? `/api/posts/${post.id}` : '/api/posts'
    const method = post ? 'PUT' : 'POST'
    const res = await fetch(url, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content, status: nextStatus, /* ...나머지 필드 */ }),
    })
    if (res.ok) router.push('/admin/posts')
  }

  return (
    <div className="grid grid-cols-3 gap-6">
      {/* 에디터 영역 */}
      <div className="col-span-2 space-y-4">
        <input
          value={title}
          onChange={e => setTitle(e.target.value)}
          placeholder="포스트 제목"
          className="text-2xl font-bold w-full bg-transparent border-b border-gray-700 pb-2"
        />
        <textarea
          value={content}
          onChange={e => setContent(e.target.value)}
          placeholder="마크다운으로 작성하세요..."
          className="w-full h-[60vh] font-mono text-sm resize-none"
        />
      </div>

      {/* 사이드 패널 */}
      <aside className="space-y-6">
        {/* 발행 상태 */}
        <select value={status} onChange={e => setStatus(e.target.value)}>
          <option value="DRAFT">초안</option>
          <option value="PUBLISHED">발행</option>
          <option value="ARCHIVED">보관</option>
        </select>

        {/* 저장 버튼 */}
        <div className="flex gap-2">
          <button onClick={() => save('DRAFT')}>임시저장</button>
          <button onClick={() => save('PUBLISHED')}>발행</button>
        </div>
      </aside>
    </div>
  )
}

이미지 업로드 API

CODE
// src/app/api/upload/route.ts
import { writeFile } from 'fs/promises'
import path from 'path'
import { auth } from '@/lib/auth'

export async function POST(req: Request) {
  const session = await auth()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const formData = await req.formData()
  const file = formData.get('file') as File
  if (!file) return Response.json({ error: 'No file' }, { status: 400 })

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)
  const ext = file.name.split('.').pop()
  const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
  const filepath = path.join(process.cwd(), 'public/uploads', filename)

  await writeFile(filepath, buffer)
  return Response.json({ url: `/uploads/${filename}` })
}

낙관적 UI로 삭제 처리

CODE
const [posts, setPosts] = useState(initialPosts)

const deletePost = async (id: string) => {
  // UI에서 즉시 제거
  setPosts(prev => prev.filter(p => p.id !== id))

  const res = await fetch(`/api/posts/${id}`, { method: 'DELETE' })
  if (!res.ok) {
    // 실패 시 롤백
    setPosts(initialPosts)
    alert('삭제에 실패했습니다.')
  }
}

다음 편에서는 블로그 프론트엔드와 마크다운 렌더링을 구현합니다.

공유
S

SHIN

.NET 개발자입니다

GitHub

이전 포스트

4편. NextAuth.js로 관리자 인증 구현하기

다음 포스트

6편. 블로그 프론트엔드와 마크다운 렌더링

같은 카테고리 포스트

9편. Vercel 배포와 성능 최적화

2025년 4월 10일· 7분

8편. SEO 최적화 — 메타태그·sitemap·RSS

2025년 4월 5일· 2분

7편. 검색·카테고리·태그·시리즈 기능 구현

2025년 3월 30일· 8분

댓글