'use client'; import axios from 'axios'; import dynamic from 'next/dynamic'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { ArrowLeft, Clock, FileText, Folder, Image as ImageIcon, Loader2, Save, Trash2, UploadCloud, X, } from 'lucide-react'; import { getCategories } from '@/api/category'; import { uploadImage } from '@/api/image'; import { createPost, getPost, updatePost } from '@/api/posts'; import { useAuthStore } from '@/store/authStore'; import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types'; const MDEditor = dynamic( () => import('@uiw/react-md-editor').then((mod) => mod.default), { ssr: false }, ); interface DraftPost { id: number; title: string; content: string; savedAt: string; } interface AdminPostEditorProps { editSlug?: string; } const getErrorMessage = (error: unknown, fallback: string) => { if (typeof error === 'object' && error !== null && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response; if (response?.data?.message) return `${fallback}: ${response.data.message}`; } if (error instanceof Error) return `${fallback}: ${error.message}`; return fallback; }; const findCategoryByName = (categories: Category[], name: string): Category | null => { for (const category of categories) { if (category.name === name) return category; const found = findCategoryByName(category.children || [], name); if (found) return found; } return null; }; const isTokenExpired = (token: string) => { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent( atob(base64) .split('') .map((character) => `%${(`00${character.charCodeAt(0).toString(16)}`).slice(-2)}`) .join(''), ); const { exp } = JSON.parse(jsonPayload) as { exp?: number }; return !exp || Date.now() / 1000 >= exp - 30; } catch { return true; } }; function CategoryOptions({ categories, selectedId, onSelect, depth = 0, }: { categories: Category[]; selectedId: number | ''; onSelect: (id: number) => void; depth?: number; }) { return ( <> {categories.map((category) => (
{category.children?.length > 0 && ( )}
))} ); } function DraftLoadDialog({ draft, onCancel, onConfirm, }: { draft: DraftPost; onCancel: () => void; onConfirm: () => void; }) { return (

임시저장 불러오기

현재 작성 중인 내용이 선택한 임시저장 글로 바뀝니다.

{draft.title}

{draft.savedAt}

); } export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) { const router = useRouter(); const pathname = usePathname(); const queryClient = useQueryClient(); const { isLoggedIn, role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); const isAdmin = _hasHydrated && role?.includes('ADMIN'); const isEditMode = Boolean(editSlug); const [title, setTitle] = useState(''); const [content, setContent] = useState('**Hello world!**'); const [categoryId, setCategoryId] = useState(''); const [tags, setTags] = useState(''); const [isUploading, setIsUploading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [drafts, setDrafts] = useState([]); const [showDraftList, setShowDraftList] = useState(false); const [draftToLoad, setDraftToLoad] = useState(null); useEffect(() => { if (!_hasHydrated) return; if (!isLoggedIn) { toast.error('로그인이 필요합니다.'); const currentPath = typeof window === 'undefined' ? pathname : `${window.location.pathname}${window.location.search}`; router.replace(`/login?redirect=${encodeURIComponent(currentPath || '/admin/posts/new')}`); return; } if (!isAdmin) { toast.error('관리자 권한이 필요합니다.'); router.replace('/'); } }, [_hasHydrated, isAdmin, isLoggedIn, pathname, router]); useEffect(() => { const savedDrafts = localStorage.getItem('temp_drafts'); if (!savedDrafts) return; try { setDrafts(JSON.parse(savedDrafts) as DraftPost[]); } catch { localStorage.removeItem('temp_drafts'); } }, []); const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: getCategories, enabled: isAdmin, }); const { data: existingPost, isLoading: isLoadingPost } = useQuery({ queryKey: ['post', editSlug], queryFn: () => getPost(editSlug!), enabled: isAdmin && isEditMode, staleTime: 0, gcTime: 0, refetchOnMount: 'always', }); useEffect(() => { if (!existingPost) return; setTitle(existingPost.title || ''); setContent(existingPost.content || ''); setTags(existingPost.tags ? existingPost.tags.join(', ') : ''); if (categories.length > 0 && existingPost.categoryName) { const found = findCategoryByName(categories, existingPost.categoryName); if (found) setCategoryId(found.id); } }, [existingPost, categories]); const saveDrafts = (nextDrafts: DraftPost[]) => { setDrafts(nextDrafts); localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts)); }; const handleTempSave = () => { if (!title.trim() && !content.trim()) { toast.error('제목이나 내용을 입력해주세요.'); return; } if (drafts.length >= 10) { toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.'); setShowDraftList(true); return; } saveDrafts([ { id: Date.now(), title: title || '(제목 없음)', content, savedAt: new Date().toLocaleString(), }, ...drafts, ]); toast.success('임시저장 되었습니다.'); }; const handleLoadDraft = (draft: DraftPost) => { setDraftToLoad(draft); }; const confirmLoadDraft = () => { if (!draftToLoad) return; setTitle(draftToLoad.title === '(제목 없음)' ? '' : draftToLoad.title); setContent(draftToLoad.content); setShowDraftList(false); setDraftToLoad(null); toast.success('불러오기 완료'); }; const handleDeleteDraft = (id: number) => { saveDrafts(drafts.filter((draft) => draft.id !== id)); toast.success('삭제되었습니다.'); }; const mutation = useMutation({ mutationFn: (data: PostSaveRequest) => { if (isEditMode) { return updatePost(existingPost!.id, data); } return createPost(data); }, onSuccess: async (response: ApiResponse) => { const savedPost = response.data; const newSlug = savedPost?.slug || editSlug; await queryClient.resetQueries({ queryKey: ['posts'] }); await queryClient.invalidateQueries({ queryKey: ['categories'] }); if (editSlug) { queryClient.removeQueries({ queryKey: ['post', editSlug] }); } if (newSlug && newSlug !== editSlug) { queryClient.removeQueries({ queryKey: ['post', newSlug] }); } toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다.'); router.push(newSlug ? `/posts/${newSlug}` : '/admin'); }, onError: (error) => { toast.error(getErrorMessage(error, '저장 실패')); }, onSettled: () => { setIsSubmitting(false); }, }); const ensureAuthToken = async (): Promise => { if (!accessToken || !refreshToken) { toast.error('로그인이 필요합니다.'); return false; } if (!isTokenExpired(accessToken)) return true; try { const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; const { data } = await axios.post>( `${baseUrl}/api/auth/reissue`, { accessToken, refreshToken }, { headers: { 'Content-Type': 'application/json' }, withCredentials: true }, ); if (data.code === 'SUCCESS' && data.data) { login(data.data.accessToken, data.data.refreshToken); return true; } return false; } catch { toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요.', { duration: 5000 }); return false; } }; const appendImageMarkdown = (imageUrl: string) => { setContent((previous) => `${previous}\n![image](${imageUrl})`); }; const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => { const isTokenValid = await ensureAuthToken(); if (!isTokenValid) return; const response = await uploadImage(file); if (response.code === 'SUCCESS' && response.data) { appendImageMarkdown(response.data); toast.success(successMessage, { id: toastId }); } else { toast.error(response.message || '이미지 업로드 실패', { id: toastId }); } }; const handleSubmit = async () => { if (!title.trim() || !content.trim()) { toast.error('제목과 내용을 입력해주세요.'); return; } if (categoryId === '') { toast.error('카테고리를 선택해주세요.'); return; } setIsSubmitting(true); const isTokenValid = await ensureAuthToken(); if (!isTokenValid) { setIsSubmitting(false); return; } mutation.mutate({ title, content, categoryId: Number(categoryId), tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean), }); }; const handleFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; setIsUploading(true); const uploadToast = toast.loading('이미지 업로드 중...'); try { await uploadEditorImage(file, '이미지가 업로드되었습니다.', uploadToast); } catch (error) { toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); } finally { setIsUploading(false); event.target.value = ''; } }; const handlePaste = async (event: React.ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) return; for (const item of Array.from(items)) { if (!item.type.startsWith('image')) continue; event.preventDefault(); const file = item.getAsFile(); if (!file) return; setIsUploading(true); const uploadToast = toast.loading('이미지 업로드 중...'); try { await uploadEditorImage(file, '이미지 붙여넣기 완료.', uploadToast); } catch (error) { toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); } finally { setIsUploading(false); } } }; if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) { return (
); } return (

{isEditMode ? '게시글 수정' : '새 글 작성'}

{showDraftList && ( <>
setShowDraftList(false)} />

임시저장 목록 ({drafts.length}/10)

{drafts.length === 0 ? (
저장된 글이 없습니다.
) : (
    {drafts.map((draft) => (
  • ))}
)}
)}
setTitle(event.target.value)} placeholder="제목을 입력하세요" className="w-full border-none bg-transparent py-2 text-3xl font-bold outline-none placeholder:text-gray-300" />
setContent(value || '')} height={600} preview="edit" className="rounded-lg border border-gray-200 shadow-sm !font-sans" />

카테고리

{categories.length > 0 ? ( ) : (

로딩 중...

)}

이미지 업로드

에디터에 이미지를
복사 & 붙여넣기(Ctrl+V) 하거나
아래 버튼을 사용하세요.

{draftToLoad && ( setDraftToLoad(null)} onConfirm={confirmLoadDraft} /> )}
); }