4. 인증 시스템, 프론트에서 해야할 일은?
2025년 10월 13일
포트폴리오 사이트
Next.js 블로그 관리자 기능 구현하기: 인증, CRUD, 클립보드 이미지 업로드
1. 관리자 기능 구현 배경
블로그 시스템 구축 후, 콘텐츠 관리를 위한 관리자 기능이 필요했습니다. 요구사항:
- 인증: 관리자만 접근 가능
- 글 작성/수정/삭제
- 프로젝트 소개글 관리
- 이미지 업로드
- 스크린샷 붙여넣기로 이미지 업로드(중요)
2. 필요한 라이브러리 설치
2.1 마크다운 에디터
npm install @uiw/react-md-editor
@uiw/react-md-editor: 마크다운 에디터 (미리보기 포함)
3. 인증 시스템 구현
3.1 로그인 페이지 (app/admin/login/page.tsx)
'use client' import { useState, FormEvent } from 'react' import { useRouter } from 'next/navigation' import { login } from '@/lib/auth' export default function LoginPage() { const router = useRouter() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) const handleSubmit = async (e: FormEvent) => { e.preventDefault() setError('') setLoading(true) const { user, error: loginError } = await login(email, password) if (loginError) { setError(loginError) setLoading(false) } else if (user) { router.push('/admin/blog') } } return ( <div className="min-h-screen flex items-center justify-center bg-[#050a13] p-8"> <div className="bg-[#1b1e26] p-12 rounded-2xl border border-[rgba(3,232,249,0.2)] w-full max-w-[400px]"> <h1 className="text-[#03e8f9] text-3xl mb-8 text-center">관리자 로그인</h1> <form onSubmit={handleSubmit} className="flex flex-col gap-6"> <div className="flex flex-col gap-2"> <label htmlFor="email" className="text-white text-sm">이메일</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required disabled={loading} className="px-3 py-3 border border-[rgba(3,232,249,0.3)] rounded-lg bg-[#050a13] text-white text-base transition-[border-color] duration-300 focus:outline-none focus:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" /> </div> <div className="flex flex-col gap-2"> <label htmlFor="password" className="text-white text-sm">비밀번호</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required disabled={loading} className="px-3 py-3 border border-[rgba(3,232,249,0.3)] rounded-lg bg-[#050a13] text-white text-base transition-[border-color] duration-300 focus:outline-none focus:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" /> </div> {error && <p className="text-[#fd6413] text-sm text-center my-0">{error}</p>} <button type="submit" className="px-3 py-3 bg-[#03e8f9] text-[#050a13] border-none rounded-lg text-base font-bold cursor-pointer transition-all duration-300 hover:bg-transparent hover:text-[#03e8f9] hover:border hover:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" disabled={loading} > {loading ? '로그인 중...' : '로그인'} </button> </form> </div> </div> ) }
코드 설명:
-
상태 관리
const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false)- 이메일/비밀번호 입력값, 에러 메시지, 로딩 상태 관리
-
로그인 처리
const { user, error: loginError } = await login(email, password)login()은{ user, error }반환- 성공 시
/admin/blog로 이동
-
에러 처리
if (loginError) { setError(loginError) setLoading(false) }- 에러 메시지를 화면에 표시
3.2 AuthGuard 컴포넌트 (components/AuthGuard/AuthGuard.tsx)
관리자 페이지 접근 시 인증 확인:
'use client' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { getCurrentUser } from '@/lib/auth' import { User } from 'firebase/auth' interface AuthGuardProps { children: React.ReactNode } export default function AuthGuard({ children }: AuthGuardProps) { const router = useRouter() const [user, setUser] = useState<User | null>(null) const [loading, setLoading] = useState(true) useEffect(() => { const checkAuth = async () => { const currentUser = await getCurrentUser() if (!currentUser) { router.push('/admin/login') } else { setUser(currentUser) setLoading(false) } } checkAuth() }, [router]) if (loading) { return ( <div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#050a13', color: '#ffffff' }}> 로딩 중... </div> ) } if (!user) { return null } return <>{children}</> }
코드 설명:
-
인증 확인
const currentUser = await getCurrentUser() if (!currentUser) { router.push('/admin/login') }getCurrentUser()로 현재 사용자 확인- 미인증 시 로그인 페이지로 리다이렉트
-
로딩 상태
if (loading) { return <div>로딩 중...</div> }- 인증 확인 중 로딩 표시
-
사용
<AuthGuard> <AdminPage /> </AuthGuard>- 관리자 페이지를
AuthGuard로 감싸서 보호
- 관리자 페이지를
4. 글 작성 페이지 구현
4.1 글 작성 페이지 (app/admin/blog/write/page.tsx)
'use client' import { useState, FormEvent, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import AuthGuard from '@/components/AuthGuard' import { createPost, uploadImages, uploadImage, upsertProject } from '@/lib/blog' import { getCurrentUser } from '@/lib/auth' // 마크다운 에디터는 클라이언트에서만 로드 (SSR 방지) const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }) export default function WritePostPage() { const router = useRouter() const editorRef = useRef<HTMLDivElement>(null) const [title, setTitle] = useState('') const [content, setContent] = useState('') const [tags, setTags] = useState('') const [published, setPublished] = useState(true) const [createdAt, setCreatedAt] = useState(() => { // 현재 날짜와 시간을 YYYY-MM-DDTHH:mm 형식으로 반환 const now = new Date() const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') const hours = String(now.getHours()).padStart(2, '0') const minutes = String(now.getMinutes()).padStart(2, '0') return `${year}-${month}-${day}T${hours}:${minutes}` }) const [images, setImages] = useState<File[]>([]) const [uploading, setUploading] = useState(false) const [uploadingImage, setUploadingImage] = useState(false) const [error, setError] = useState('') // ... (이미지 업로드, 클립보드 붙여넣기 등) const generateSlug = (text: string): string => { let slug = text .trim() .replace(/\s+/g, '-') // 공백을 하이픈으로 .replace(/-+/g, '-') // 연속된 하이픈을 하나로 .replace(/^-|-$/g, '') // 앞뒤 하이픈 제거 if (!slug) { slug = `post-${Date.now()}` } return slug } const handleSubmit = async (e: FormEvent) => { e.preventDefault() setError('') setUploading(true) try { const user = await getCurrentUser() if (!user) { router.push('/admin/login') return } // 임시 ID 생성 (이미지 업로드용) const tempId = `temp_${Date.now()}` // 이미지 업로드 let imageUrls: string[] = [] if (images.length > 0) { imageUrls = await uploadImages(images, tempId) } // Slug 생성 const slug = generateSlug(title) // 태그 추출 const postTags = tags.split(',').map(t => t.trim()).filter(t => t) // 날짜 변환 (문자열 → Date → Timestamp) const createdAtDate = new Date(createdAt) // 포스트 생성 await createPost({ title, content, images: imageUrls, author: user.uid, tags: postTags, published, slug, createdAt: createdAtDate, }) // 각 태그에 대해 프로젝트 생성 또는 업데이트 (기본 설명 없음) for (const tag of postTags) { await upsertProject(tag, '') } router.push('/admin/blog') } catch (err) { const errorMessage = err instanceof Error ? err.message : '글 작성 중 오류가 발생했습니다.' setError(errorMessage) } finally { setUploading(false) } } return ( <AuthGuard> <div className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <h1 className="text-4xl text-[#03e8f9] mb-12 text-center">새 글 작성</h1> <form onSubmit={handleSubmit} className="max-w-[1000px] mx-auto flex flex-col gap-8"> {/* 제목 입력 */} <div className="flex flex-col gap-3"> <label htmlFor="title" className="text-lg font-bold text-white">제목</label> <input id="title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} required disabled={uploading} className="px-3 py-3 border border-[rgba(3,232,249,0.3)] rounded-lg bg-[#1b1e26] text-white text-base transition-[border-color] duration-300 focus:outline-none focus:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" /> </div> {/* 마크다운 에디터 */} <div className="flex flex-col gap-3"> <label>내용 (마크다운)</label> {uploadingImage && ( <p className="text-[#03e8f9] text-sm my-0 px-2 py-2 bg-[rgba(3,232,249,0.1)] rounded"> 파일 업로드 중... </p> )} <div ref={editorRef} data-color-mode="dark" > <MDEditor value={content} onChange={(value) => setContent(value || '')} height={500} /> </div> </div> {/* 태그 입력 */} <div className="flex flex-col gap-3"> <label htmlFor="tags" className="text-lg font-bold text-white">태그 (쉼표로 구분)</label> <input id="tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="예: React, Next.js, Firebase" disabled={uploading} className="px-3 py-3 border border-[rgba(3,232,249,0.3)] rounded-lg bg-[#1b1e26] text-white text-base transition-[border-color] duration-300 focus:outline-none focus:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" /> </div> {/* 작성 날짜 */} <div className="flex flex-col gap-3"> <label htmlFor="createdAt" className="text-lg font-bold text-white">작성 날짜</label> <input id="createdAt" type="datetime-local" value={createdAt} onChange={(e) => setCreatedAt(e.target.value)} disabled={uploading} className="px-3 py-3 border border-[rgba(3,232,249,0.3)] rounded-lg bg-[#1b1e26] text-white text-base transition-[border-color] duration-300 focus:outline-none focus:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" /> </div> {/* 공개/비공개 설정 */} <div className="flex items-center gap-2"> <label htmlFor="published" className="flex items-center gap-2 cursor-pointer"> <input id="published" name="published" type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} disabled={uploading} className="w-5 h-5 cursor-pointer" /> 공개하기 </label> </div> {error && <p className="text-[#fd6413] text-sm text-center my-0">{error}</p>} <div className="flex gap-4 justify-end"> <button type="button" onClick={() => router.back()} className="px-8 py-3 rounded-lg font-bold cursor-pointer transition-all duration-300 border-none text-base bg-[#1b1e26] text-white border border-[rgba(3,232,249,0.3)] hover:border-[#03e8f9] hover:text-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" disabled={uploading} > 취소 </button> <button type="submit" className="px-8 py-3 rounded-lg font-bold cursor-pointer transition-all duration-300 border-none text-base bg-[#03e8f9] text-[#050a13] hover:bg-transparent hover:text-[#03e8f9] hover:border hover:border-[#03e8f9] disabled:opacity-50 disabled:cursor-not-allowed" disabled={uploading || !title || !content} > {uploading ? '업로드 중...' : '작성하기'} </button> </div> </form> </div> </AuthGuard> ) }
코드 설명:
-
동적 임포트
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })dynamic으로 클라이언트에서만 로드ssr: false로 서버 사이드 렌더링 방지
-
작성 날짜 초기값
const [createdAt, setCreatedAt] = useState(() => { const now = new Date() // YYYY-MM-DDTHH:mm 형식으로 변환 return `${year}-${month}-${day}T${hours}:${minutes}` })datetime-local입력 형식에 맞춰 초기값 설정
-
Slug 생성
const generateSlug = (text: string): string => { let slug = text .trim() .replace(/\s+/g, '-') // 공백을 하이픈으로 .replace(/-+/g, '-') // 연속된 하이픈을 하나로 .replace(/^-|-$/g, '') // 앞뒤 하이픈 제거 if (!slug) { slug = `post-${Date.now()}` } return slug }- 제목을 URL-safe한 slug로 변환
- 빈 문자열이면 타임스탬프 사용
-
태그 처리
const postTags = tags.split(',').map(t => t.trim()).filter(t => t)- 쉼표로 분리, 공백 제거, 빈 문자열 제거
-
프로젝트 자동 생성
for (const tag of postTags) { await upsertProject(tag, '') }- 태그별로 프로젝트가 없으면 생성
5. 클립보드 이미지 붙여넣기 기능
5.1 구현 코드
// 클립보드 이미지/비디오 붙여넣기 처리 useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { const items = e.clipboardData?.items if (!items) return for (let i = 0; i < items.length; i++) { const item = items[i] const isImage = item.type.indexOf('image') !== -1 const isVideo = item.type.indexOf('video') !== -1 if (isImage || isVideo) { e.preventDefault() const file = item.getAsFile() if (!file) continue setUploadingImage(true) try { const user = await getCurrentUser() if (!user) { alert('로그인이 필요합니다.') return } // 임시 ID로 파일 업로드 const tempId = `temp_${Date.now()}` const fileUrl = await uploadImage(file, tempId) // 이미지 또는 비디오에 따라 다른 형식으로 삽입 let markdown = '' if (isImage) { markdown = `\n\n` } else { // HTML video 태그 사용 (rehype-raw가 HTML을 지원하므로) markdown = `\n<video controls width="100%">\n <source src="${fileUrl}" type="${item.type}">\n Your browser does not support the video tag.\n</video>\n` } // MDEditor 내부의 textarea에서 커서 위치 가져오기 let cursorPos = content.length if (editorRef.current) { const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement if (textarea) { cursorPos = textarea.selectionStart || content.length } } const newContent = content.slice(0, cursorPos) + markdown + content.slice(cursorPos) setContent(newContent) // 이미지 목록에도 추가 (비디오도 포함) setImages([...images, file]) // 커서 위치 업데이트 (비동기로 처리) setTimeout(() => { if (editorRef.current) { const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement if (textarea) { const newCursorPos = cursorPos + markdown.length textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() } } }, 0) } catch (err) { console.error('파일 업로드 실패:', err) alert(`${isImage ? '이미지' : '비디오'} 업로드에 실패했습니다.`) } finally { setUploadingImage(false) } } } } const editorElement = editorRef.current if (editorElement) { editorElement.addEventListener('paste', handlePaste) return () => { editorElement.removeEventListener('paste', handlePaste) } } }, [content, images])
코드 설명:
-
클립보드 이벤트 감지
const handlePaste = async (e: ClipboardEvent) => { const items = e.clipboardData?.itemspaste이벤트에서 클립보드 데이터 확인
-
이미지/비디오 확인
const isImage = item.type.indexOf('image') !== -1 const isVideo = item.type.indexOf('video') !== -1- MIME 타입으로 이미지/비디오 판별
-
기본 동작 방지
if (isImage || isVideo) { e.preventDefault() const file = item.getAsFile()- 이미지/비디오 붙여넣기 시 기본 동작 방지
getAsFile()로 File 객체 획득
-
Firebase Storage 업로드
const tempId = `temp_${Date.now()}` const fileUrl = await uploadImage(file, tempId)- 임시 ID로 업로드 (나중에 실제 포스트 ID로 변경 가능)
-
마크다운 삽입
let markdown = '' if (isImage) { markdown = `\n\n` } else { markdown = `\n<video controls width="100%">\n <source src="${fileUrl}" type="${item.type}">\n</video>\n` }- 이미지는 마크다운, 비디오는 HTML 태그로 삽입
-
커서 위치 유지
let cursorPos = content.length if (editorRef.current) { const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement if (textarea) { cursorPos = textarea.selectionStart || content.length } } const newContent = content.slice(0, cursorPos) + markdown + content.slice(cursorPos)- 현재 커서 위치에 마크다운 삽입
-
커서 위치 업데이트
setTimeout(() => { if (editorRef.current) { const textarea = editorRef.current.querySelector('textarea') as HTMLTextAreaElement if (textarea) { const newCursorPos = cursorPos + markdown.length textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() } } }, 0)- 삽입 후 커서를 마크다운 뒤로 이동
setTimeout으로 DOM 업데이트 후 실행
-
이벤트 리스너 정리
const editorElement = editorRef.current if (editorElement) { editorElement.addEventListener('paste', handlePaste) return () => { editorElement.removeEventListener('paste', handlePaste) } }- 컴포넌트 언마운트 시 이벤트 리스너 제거
6. 글 수정 페이지 구현
6.1 글 수정 페이지 (app/admin/blog/edit/[id]/EditPostClient.tsx)
글 작성과 유사하지만 기존 데이터를 불러와 수정합니다:
'use client' import { useState, useEffect, FormEvent, useRef } from 'react' import { useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import AuthGuard from '@/components/AuthGuard' import { getPost, updatePost, uploadImages, uploadImage } from '@/lib/blog' import { getCurrentUser } from '@/lib/auth' const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }) interface EditPostClientProps { postId: string } export default function EditPostClient({ postId }: EditPostClientProps) { const router = useRouter() const editorRef = useRef<HTMLDivElement>(null) const [title, setTitle] = useState('') const [content, setContent] = useState('') const [tags, setTags] = useState('') const [published, setPublished] = useState(true) const [createdAt, setCreatedAt] = useState('') const [images, setImages] = useState<File[]>([]) const [existingImages, setExistingImages] = useState<string[]>([]) const [uploading, setUploading] = useState(false) const [uploadingImage, setUploadingImage] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const loadPost = async () => { try { const post = await getPost(postId) if (post) { setTitle(post.title) setContent(post.content) setTags(post.tags?.join(', ') || '') setPublished(post.published) setExistingImages(post.images || []) // createdAt 날짜를 datetime-local 형식으로 변환 const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') setCreatedAt(`${year}-${month}-${day}T${hours}:${minutes}`) } } catch { setError('포스트를 불러오는 중 오류가 발생했습니다.') } finally { setLoading(false) } } useEffect(() => { loadPost() }, [postId]) const handleSubmit = async (e: FormEvent) => { e.preventDefault() setError('') setUploading(true) try { const user = await getCurrentUser() if (!user) { router.push('/admin/login') return } // 새 이미지 업로드 let newImageUrls: string[] = [] if (images.length > 0) { newImageUrls = await uploadImages(images, postId) } // 기존 이미지와 새 이미지 합치기 const allImageUrls = [...existingImages, ...newImageUrls] // 날짜 변환 (문자열 → Date) const createdAtDate = new Date(createdAt) // 포스트 업데이트 await updatePost(postId, { title, content, images: allImageUrls, tags: tags.split(',').map(t => t.trim()).filter(t => t), published, createdAt: createdAtDate, }) router.push('/admin/blog') } catch (err) { const errorMessage = err instanceof Error ? err.message : '글 수정 중 오류가 발생했습니다.' setError(errorMessage) } finally { setUploading(false) } } // ... (나머지 코드는 글 작성 페이지와 유사) }
코드 설명:
-
기존 데이터 로드
const loadPost = async () => { const post = await getPost(postId) if (post) { setTitle(post.title) setContent(post.content) // ... } }- 포스트 ID로 기존 데이터 조회 후 상태 설정
-
날짜 형식 변환
const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number)- Firestore Timestamp를
datetime-local형식으로 변환
- Firestore Timestamp를
-
기존 이미지와 새 이미지 병합
const allImageUrls = [...existingImages, ...newImageUrls]- 기존 이미지 URL과 새로 업로드한 이미지 URL 병합
7. 글 삭제 기능
7.1 블로그 관리 페이지 (app/admin/blog/page.tsx)
'use client' import { useEffect, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import AuthGuard from '@/components/AuthGuard' import { getAllPosts, deletePost } from '@/lib/blog' import { logout } from '@/lib/auth' import { BlogPost } from '@/types/blog' export default function AdminBlogPage() { const router = useRouter() const [posts, setPosts] = useState<BlogPost[]>([]) const [loading, setLoading] = useState(true) useEffect(() => { loadPosts() }, []) const loadPosts = async () => { try { const allPosts = await getAllPosts() setPosts(allPosts) } catch (error) { console.error('포스트 로드 실패:', error) setPosts([]) } finally { setLoading(false) } } const handleDelete = async (post: BlogPost) => { if (!post.id) return if (!confirm(`"${post.title}" 글을 정말 삭제하시겠습니까?`)) { return } try { await deletePost(post.id, post.images || []) loadPosts() } catch (error) { console.error('삭제 실패:', error) alert('삭제 중 오류가 발생했습니다.') } } const handleLogout = async () => { await logout() router.push('/admin/login') } return ( <AuthGuard> <div className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto mb-12 flex justify-between items-center flex-wrap gap-4"> <h1 className="text-4xl text-[#03e8f9]">블로그 관리</h1> <div className="flex gap-4"> <Link href="/admin/blog/write">새 글 작성</Link> <Link href="/admin/blog/projects">프로젝트 관리</Link> <button onClick={handleLogout}>로그아웃</button> </div> </div> {loading ? ( <p className="text-center text-xl opacity-60 py-16">로딩 중...</p> ) : posts.length === 0 ? ( <p className="text-center text-xl opacity-60 py-16">작성된 글이 없습니다.</p> ) : ( <div className="max-w-[1200px] mx-auto flex flex-col gap-6"> {posts.map((post) => ( <div key={post.id} className="bg-[#1b1e26] p-8 rounded-2xl border border-[rgba(3,232,249,0.2)] flex justify-between items-center gap-8"> <div className="flex-1"> <h2 className="text-2xl mb-2 text-white">{post.title}</h2> <p className="text-sm text-[#03e8f9] mb-2"> {(() => { const date = post.createdAt instanceof Date ? post.createdAt : 'toDate' in post.createdAt ? (post.createdAt as { toDate: () => Date }).toDate() : new Date(post.createdAt as string | number) return date.toLocaleDateString('ko-KR') })()} </p> <span className={`inline-block px-3 py-1 rounded text-sm font-bold ${ post.published ? 'bg-[rgba(3,232,249,0.2)] text-[#03e8f9]' : 'bg-[rgba(253,100,19,0.2)] text-[#fd6413]' }`}> {post.published ? '공개' : '비공개'} </span> </div> <div className="flex gap-4"> <Link href={`/admin/blog/edit/${post.id}`}>수정</Link> <button onClick={() => handleDelete(post)}>삭제</button> </div> </div> ))} </div> )} </div> </AuthGuard> ) }
코드 설명:
-
삭제 확인
if (!confirm(`"${post.title}" 글을 정말 삭제하시겠습니까?`)) { return }confirm()으로 삭제 확인
-
삭제 실행
await deletePost(post.id, post.images || [])- Firestore 문서와 Storage 이미지 삭제
-
목록 새로고침
loadPosts()- 삭제 후 목록 갱신
8. 프로젝트 관리 페이지
8.1 프로젝트 관리 페이지 (app/admin/blog/projects/page.tsx)
'use client' import { useEffect, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import AuthGuard from '@/components/AuthGuard' import { getAllProjects, getAllTags, upsertProject } from '@/lib/blog' import { logout } from '@/lib/auth' import { Project } from '@/types/blog' export default function AdminProjectsPage() { const router = useRouter() const [projects, setProjects] = useState<Project[]>([]) const [tags, setTags] = useState<string[]>([]) const [loading, setLoading] = useState(true) const [editingId, setEditingId] = useState<string | null>(null) const [editDescription, setEditDescription] = useState('') useEffect(() => { loadData() }, []) const loadData = async () => { try { const [allProjects, allTags] = await Promise.all([ getAllProjects(), getAllTags(), ]) setProjects(allProjects) setTags(allTags) } catch (error) { console.error('데이터 로드 실패:', error) } finally { setLoading(false) } } const handleEdit = (project: Project | null, tag: string) => { setEditingId(project?.id || tag) setEditDescription(project?.description || '') } const handleSave = async (tag: string) => { try { await upsertProject(tag, editDescription) setEditingId(null) setEditDescription('') loadData() } catch (error) { console.error('저장 실패:', error) alert('저장 중 오류가 발생했습니다.') } } const handleCancel = () => { setEditingId(null) setEditDescription('') } // 프로젝트가 있는 태그와 없는 태그를 구분 const projectMap = new Map(projects.map(p => [p.tag, p])) const allProjectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, })) return ( <AuthGuard> <div className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto mb-12 flex justify-between items-center flex-wrap gap-4"> <h1 className="text-4xl text-[#03e8f9] m-0">프로젝트 관리</h1> <div className="flex gap-4"> <Link href="/admin/blog">블로그 관리로</Link> <button onClick={handleLogout}>로그아웃</button> </div> </div> {loading ? ( <p className="text-center text-xl text-[#03e8f9] py-16">로딩 중...</p> ) : ( <div className="max-w-[1200px] mx-auto grid grid-cols-[repeat(auto-fill,minmax(400px,1fr))] gap-8"> {allProjectTags.map(({ tag, project }) => ( <div key={tag} className="bg-[#1b1e26] p-8 rounded-2xl border border-[rgba(3,232,249,0.2)]"> <h2 className="text-2xl text-[#03e8f9] mb-6">{tag}</h2> {editingId === (project?.id || tag) ? ( <div className="flex flex-col gap-4"> <textarea value={editDescription} onChange={(e) => setEditDescription(e.target.value)} className="bg-[#050a13] text-white border border-[rgba(3,232,249,0.3)] rounded-lg p-4 text-base font-inherit resize-y min-h-[150px] focus:outline-none focus:border-[#03e8f9]" placeholder="프로젝트 소개글을 입력하세요 (마크다운 지원)" rows={8} /> <div className="flex gap-4"> <button onClick={() => handleSave(tag)}>저장</button> <button onClick={handleCancel}>취소</button> </div> </div> ) : ( <div className="flex flex-col gap-4"> {project && project.description ? ( <p className="text-white/80 leading-relaxed whitespace-pre-wrap"> {project.description} </p> ) : ( <p className="text-white/50 italic">아직 소개글이 없습니다.</p> )} <button onClick={() => handleEdit(project, tag)} className="px-4 py-2 bg-transparent text-[#03e8f9] border border-[#03e8f9] rounded-lg cursor-pointer transition-all duration-300 self-start hover:bg-[#03e8f9] hover:text-[#050a13]" > {project ? '수정' : '소개글 추가'} </button> </div> )} </div> ))} </div> )} </div> </AuthGuard> ) }
코드 설명:
-
태그와 프로젝트 매핑
const projectMap = new Map(projects.map(p => [p.tag, p])) const allProjectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, }))- 모든 태그를 표시하고, 프로젝트 정보가 있으면 함께 표시
-
인라인 편집
{editingId === (project?.id || tag) ? ( <textarea ... /> ) : ( <p>{project?.description || '아직 소개글이 없습니다.'}</p> )}- 편집 모드와 보기 모드 전환
-
프로젝트 생성/업데이트
await upsertProject(tag, editDescription)- 프로젝트가 있으면 업데이트, 없으면 생성
9. 마무리
관리자 기능 구현을 완료했습니다:
- 인증 시스템: Firebase Authentication으로 로그인/로그아웃
- AuthGuard: 관리자 페이지 접근 보호
- 글 작성: 마크다운 에디터, 이미지 업로드, 클립보드 붙여넣기
- 글 수정: 기존 데이터 로드 및 수정
- 글 삭제: Firestore와 Storage에서 삭제
- 프로젝트 관리: 태그별 소개글 작성/수정
전체적인 블로그 구현 기능은 끝이 났습니다. 이 다음 글 부터는 전체적인 구현 과정에서 생긴 트러블 슈팅들을 기록할 예정입니다.