'use client'; import axios from 'axios'; import dynamic from 'next/dynamic'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { ArrowLeft, CheckCircle2, Clock, FileText, Folder, Image as ImageIcon, Loader2, Save, Tags, Trash2, UploadCloud, } from 'lucide-react'; import { getCategories } from '@/api/category'; import { uploadImage } from '@/api/image'; import { createPost, getPost, updatePost } from '@/api/posts'; import { useTheme } from '@/components/theme/ThemeProvider'; import Surface from '@/components/ui/Surface'; import WindowSurface from '@/components/ui/WindowSurface'; 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 findCategoryById = (categories: Category[], id: number): Category | null => { for (const category of categories) { if (category.id === id) return category; const found = findCategoryById(category.children || [], id); 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 { resolvedTheme } = useTheme(); 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(''); 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 selectedCategoryName = useMemo(() => { if (categoryId === '') return '선택 안 됨'; return findCategoryById(categories, categoryId)?.name || '선택 안 됨'; }, [categories, categoryId]); const contentLength = content.trim().length; const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; const readingMinutes = Math.max(1, Math.ceil(wordCount / 350)); const canSubmit = Boolean(title.trim() && content.trim() && categoryId !== ''); 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개까지 가능합니다. 기존 저장분을 삭제해 주세요.'); 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('세션이 만료되었습니다. 작성 중인 글을 복사해 두고 다시 로그인해 주세요.', { duration: 5000 }); return false; } }; const appendImageMarkdown = (imageUrl: string) => { setContent((previous) => `${previous}${previous ? '\n\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 (
router.push('/admin/posts')} className="inline-flex h-8 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-xs font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]" > 목록 )} bodyClassName="p-4 md:p-6" >

Document

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

setTitle(event.target.value)} placeholder="제목을 입력하세요" className="mb-5 w-full rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 py-3 text-2xl font-bold tracking-normal text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] md:text-3xl" />
setContent(value || '')} height={680} preview="edit" data-color-mode={resolvedTheme} textareaProps={{ style: { color: 'var(--color-text)', WebkitTextFillColor: 'var(--color-text)', caretColor: 'var(--color-accent)', background: 'transparent', }, }} className="wy-markdown-editor" />
{draftToLoad && ( setDraftToLoad(null)} onConfirm={confirmLoadDraft} /> )}
); }