All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
625 lines
21 KiB
TypeScript
625 lines
21 KiB
TypeScript
'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) => (
|
|
<div key={category.id}>
|
|
<label className="flex cursor-pointer items-center gap-2 rounded p-2 transition-colors hover:bg-gray-50">
|
|
<input
|
|
type="radio"
|
|
name="category"
|
|
value={category.id}
|
|
checked={selectedId === category.id}
|
|
onChange={(event) => onSelect(Number(event.target.value))}
|
|
className="text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span
|
|
style={{ marginLeft: `${depth * 10}px` }}
|
|
className={depth === 0 ? 'font-medium' : 'text-gray-600'}
|
|
>
|
|
{depth > 0 && '- '}
|
|
{category.name}
|
|
</span>
|
|
</label>
|
|
{category.children?.length > 0 && (
|
|
<CategoryOptions
|
|
categories={category.children}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
depth={depth + 1}
|
|
/>
|
|
)}
|
|
</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-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
|
|
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl">
|
|
<div className="border-b border-gray-100 p-5">
|
|
<h3 className="text-lg font-bold text-gray-950">임시저장 불러오기</h3>
|
|
<p className="mt-1 text-sm leading-6 text-gray-500">
|
|
현재 작성 중인 내용이 선택한 임시저장 글로 바뀝니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-5">
|
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
|
<p className="line-clamp-2 text-sm font-semibold text-gray-950">{draft.title}</p>
|
|
<p className="mt-1 text-xs text-gray-500">{draft.savedAt}</p>
|
|
</div>
|
|
|
|
<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-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
className="inline-flex items-center justify-center rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
|
>
|
|
불러오기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<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 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<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('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요.', { duration: 5000 });
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const appendImageMarkdown = (imageUrl: string) => {
|
|
setContent((previous) => `${previous}\n`);
|
|
};
|
|
|
|
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-blue-500" size={40} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl px-4 py-8" onPaste={handlePaste}>
|
|
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/admin/posts')}
|
|
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100"
|
|
aria-label="게시글 관리로 돌아가기"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative">
|
|
<div className="flex items-center rounded-lg border border-gray-300 bg-white shadow-sm">
|
|
<button
|
|
type="button"
|
|
onClick={handleTempSave}
|
|
className="flex items-center gap-2 rounded-l-lg border-r border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
title="현재 내용 임시저장"
|
|
>
|
|
<FileText size={16} className="text-gray-500" />
|
|
<span className="hidden sm:inline">임시저장</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDraftList(!showDraftList)}
|
|
className="flex items-center gap-1 rounded-r-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
title="임시저장 목록 보기"
|
|
>
|
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs font-bold text-gray-600">
|
|
{drafts.length}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{showDraftList && (
|
|
<>
|
|
<div className="fixed inset-0 z-10" onClick={() => setShowDraftList(false)} />
|
|
<div className="absolute right-0 top-full z-20 mt-2 w-80 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl">
|
|
<div className="flex items-center justify-between border-b border-gray-100 bg-gray-50 px-4 py-3">
|
|
<h3 className="text-sm font-bold text-gray-700">임시저장 목록 ({drafts.length}/10)</h3>
|
|
<button type="button" onClick={() => setShowDraftList(false)} aria-label="임시저장 목록 닫기">
|
|
<X size={16} className="text-gray-400 hover:text-gray-600" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{drafts.length === 0 ? (
|
|
<div className="p-8 text-center text-sm text-gray-400">저장된 글이 없습니다.</div>
|
|
) : (
|
|
<ul className="divide-y divide-gray-100">
|
|
{drafts.map((draft) => (
|
|
<li key={draft.id} className="group p-3 transition-colors hover:bg-blue-50">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<button type="button" onClick={() => handleLoadDraft(draft)} className="flex-1 text-left">
|
|
<p className="mb-1 line-clamp-1 text-sm font-medium text-gray-800 group-hover:text-blue-600">
|
|
{draft.title}
|
|
</p>
|
|
<div className="flex items-center gap-1 text-xs text-gray-400">
|
|
<Clock size={12} />
|
|
{draft.savedAt}
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteDraft(draft.id)}
|
|
className="rounded p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500"
|
|
title="삭제"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting || isUploading}
|
|
className="flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 font-bold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 disabled:bg-gray-400"
|
|
>
|
|
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
|
{isEditMode ? '수정하기' : '작성하기'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
|
<div className="space-y-4 lg:col-span-3">
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(event) => setTitle(event.target.value)}
|
|
placeholder="제목을 입력하세요"
|
|
className="w-full border-none bg-transparent py-2 text-3xl font-bold outline-none placeholder:text-gray-300"
|
|
/>
|
|
<div data-color-mode="light">
|
|
<MDEditor
|
|
value={content}
|
|
onChange={(value) => setContent(value || '')}
|
|
height={600}
|
|
preview="edit"
|
|
className="rounded-lg border border-gray-200 shadow-sm !font-sans"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="sticky top-6 rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 flex items-center gap-2 font-bold text-gray-700">
|
|
<Folder size={18} />
|
|
카테고리
|
|
</h3>
|
|
<div className="max-h-60 space-y-1 overflow-y-auto border-t border-gray-100 pt-2 text-sm">
|
|
{categories.length > 0 ? (
|
|
<CategoryOptions categories={categories} selectedId={categoryId} onSelect={setCategoryId} />
|
|
) : (
|
|
<p className="text-sm text-gray-400">로딩 중...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sticky top-[280px] rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 flex items-center gap-2 font-bold text-gray-700">
|
|
<ImageIcon size={18} />
|
|
이미지 업로드
|
|
</h3>
|
|
<p className="mb-3 text-xs leading-relaxed text-gray-500">
|
|
에디터에 이미지를 <br />
|
|
<strong>복사 & 붙여넣기(Ctrl+V)</strong> 하거나
|
|
<br /> 아래 버튼을 사용하세요.
|
|
</p>
|
|
|
|
<label
|
|
className={`flex h-24 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 transition-all duration-200 hover:border-blue-400 hover:bg-gray-50 ${isUploading ? 'cursor-wait opacity-50' : ''}`}
|
|
>
|
|
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
|
<UploadCloud className="mb-2 h-8 w-8 text-gray-400" />
|
|
<p className="text-xs font-medium text-gray-500">
|
|
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
className="hidden"
|
|
accept="image/*"
|
|
onChange={handleFileChange}
|
|
disabled={isUploading}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{draftToLoad && (
|
|
<DraftLoadDialog
|
|
draft={draftToLoad}
|
|
onCancel={() => setDraftToLoad(null)}
|
|
onConfirm={confirmLoadDraft}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|