All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
699 lines
26 KiB
TypeScript
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' : ''}`);
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|