Files
blog-frontend/src/components/admin/AdminPostEditor.tsx
박원엽 b371200515
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
Polish macOS layer styling
2026-05-29 14:18:02 +09:00

699 lines
26 KiB
TypeScript

'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 (
<div className={depth === 0 ? 'space-y-1' : 'mt-1 space-y-1'}>
{categories.map((category) => (
<div key={category.id}>
<label
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]"
style={{ marginLeft: `${depth * 10}px` }}
>
<input
type="radio"
name="category"
value={category.id}
checked={selectedId === category.id}
onChange={(event) => onSelect(Number(event.target.value))}
className="h-4 w-4 accent-[var(--color-accent)]"
/>
<span className={depth === 0 ? 'font-semibold' : ''}>{category.name}</span>
</label>
{category.children?.length > 0 && (
<CategoryOptions
categories={category.children}
selectedId={selectedId}
onSelect={onSelect}
depth={depth + 1}
/>
)}
</div>
))}
</div>
);
}
function DraftLoadDialog({
draft,
onCancel,
onConfirm,
}: {
draft: DraftPost;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/35 px-4 py-6 backdrop-blur-sm sm:items-center">
<WindowSurface
title="임시저장 불러오기"
className="w-full max-w-md"
bodyClassName="p-5"
>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
.
</p>
<Surface strong className="mt-4 p-4 shadow-none">
<p className="line-clamp-2 text-sm font-semibold text-[var(--color-text)]">{draft.title}</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">{draft.savedAt}</p>
</Surface>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onCancel}
className="inline-flex items-center justify-center rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
</button>
<button
type="button"
onClick={onConfirm}
className="inline-flex items-center justify-center rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
>
</button>
</div>
</WindowSurface>
</div>
);
}
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<number | ''>('');
const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [drafts, setDrafts] = useState<DraftPost[]>([]);
const [showDraftList, setShowDraftList] = useState(false);
const [draftToLoad, setDraftToLoad] = useState<DraftPost | null>(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<Post>) => {
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<boolean> => {
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<ApiResponse<AuthResponse>>(
`${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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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 (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}
return (
<main className="mx-auto w-full px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6" onPaste={handlePaste}>
<WindowSurface
title={isEditMode ? '글 수정' : '새 글 작성'}
subtitle="Markdown Studio"
controls={(
<button
type="button"
onClick={() => 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)]"
>
<ArrowLeft size={14} />
</button>
)}
bodyClassName="p-4 md:p-6"
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<section className="min-w-0 space-y-4">
<Surface strong className="p-4 shadow-none md:p-5">
<div className="mb-4 flex flex-col gap-3 border-b border-[var(--color-line)] pb-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">Document</p>
<h1 className="mt-1 text-xl font-bold text-[var(--color-text)]">
{isEditMode ? '게시글 수정' : '게시글 작성'}
</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleTempSave}
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<FileText size={16} />
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting || isUploading || !canSubmit}
className="inline-flex h-9 items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 text-sm font-bold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-55"
>
{isSubmitting ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{isEditMode ? '수정하기' : '발행하기'}
</button>
</div>
</div>
<label className="mb-2 block text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
</label>
<input
type="text"
value={title}
onChange={(event) => 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"
/>
<div className="wy-editor-shell" data-color-mode={resolvedTheme}>
<MDEditor
value={content}
onChange={(value) => 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"
/>
</div>
</Surface>
</section>
<aside className="space-y-4 xl:sticky xl:top-24 xl:self-start">
<Surface strong className="p-4 shadow-none">
<h2 className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<CheckCircle2 size={17} className="text-[var(--color-accent)]" />
</h2>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 shadow-[var(--shadow-card)]">
<p className="text-lg font-bold tabular-nums text-[var(--color-text)]">{contentLength.toLocaleString()}</p>
<p className="text-[10px] font-semibold text-[var(--color-text-subtle)]"></p>
</div>
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 shadow-[var(--shadow-card)]">
<p className="text-lg font-bold tabular-nums text-[var(--color-text)]">{wordCount.toLocaleString()}</p>
<p className="text-[10px] font-semibold text-[var(--color-text-subtle)]"></p>
</div>
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 shadow-[var(--shadow-card)]">
<p className="text-lg font-bold tabular-nums text-[var(--color-text)]">{readingMinutes}</p>
<p className="text-[10px] font-semibold text-[var(--color-text-subtle)]"></p>
</div>
</div>
<p className="mt-3 text-xs leading-5 text-[var(--color-text-muted)]">
: <span className="font-semibold text-[var(--color-text)]">{selectedCategoryName}</span>
</p>
</Surface>
<Surface strong className="p-4 shadow-none">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Folder size={17} />
</h2>
<div className="max-h-64 overflow-y-auto rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-2 shadow-[var(--shadow-card)]">
{categories.length > 0 ? (
<CategoryOptions categories={categories} selectedId={categoryId} onSelect={setCategoryId} />
) : (
<div className="flex min-h-24 items-center justify-center text-sm text-[var(--color-text-subtle)]">
...
</div>
)}
</div>
</Surface>
<Surface strong className="p-4 shadow-none">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Tags size={17} />
</h2>
<input
type="text"
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="react, nextjs, essay"
className="w-full rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
/>
<p className="mt-2 text-xs text-[var(--color-text-subtle)]"> .</p>
</Surface>
<Surface strong className="p-4 shadow-none">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<ImageIcon size={17} />
</h2>
<label
className={`flex min-h-28 w-full cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-[var(--card-border)] bg-[var(--card-bg)] p-4 text-center shadow-[var(--shadow-card)] transition hover:border-[var(--color-accent)] hover:bg-[var(--card-bg-strong)] ${isUploading ? 'cursor-wait opacity-60' : ''}`}
>
<UploadCloud className="mb-2 h-7 w-7 text-[var(--color-text-subtle)]" />
<span className="text-sm font-semibold text-[var(--color-text)]">
{isUploading ? '업로드 중...' : '이미지 선택'}
</span>
<span className="mt-1 text-xs leading-5 text-[var(--color-text-subtle)]">
</span>
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
/>
</label>
</Surface>
<Surface strong className="overflow-hidden shadow-none">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-line)] px-4 py-3">
<h2 className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Clock size={17} />
</h2>
<button
type="button"
onClick={() => setShowDraftList((previous) => !previous)}
className="rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-2.5 py-1 text-xs font-bold text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
{drafts.length}/10
</button>
</div>
{showDraftList ? (
<div className="max-h-72 overflow-y-auto p-2">
{drafts.length === 0 ? (
<div className="flex min-h-24 items-center justify-center text-sm text-[var(--color-text-subtle)]">
.
</div>
) : (
<ul className="space-y-1">
{drafts.map((draft) => (
<li key={draft.id} className="group rounded-lg px-3 py-2 transition hover:bg-[var(--card-bg)]">
<div className="flex items-start justify-between gap-2">
<button type="button" onClick={() => handleLoadDraft(draft)} className="min-w-0 flex-1 text-left">
<p className="line-clamp-1 text-sm font-semibold text-[var(--color-text)]">{draft.title}</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">{draft.savedAt}</p>
</button>
<button
type="button"
onClick={() => handleDeleteDraft(draft.id)}
className="rounded p-1.5 text-[var(--color-text-subtle)] transition hover:bg-[var(--color-danger-soft)] hover:text-red-500"
title="삭제"
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
) : (
<div className="p-4 text-xs leading-5 text-[var(--color-text-muted)]">
.
</div>
)}
</Surface>
</aside>
</div>
</WindowSurface>
{draftToLoad && (
<DraftLoadDialog
draft={draftToLoad}
onCancel={() => setDraftToLoad(null)}
onConfirm={confirmLoadDraft}
/>
)}
</main>
);
}