.
Some checks failed
Deploy blog-frontend / build-and-deploy (push) Failing after 8s

This commit is contained in:
박원엽
2026-05-29 11:55:49 +09:00
parent 5e37995fbb
commit 3ecccbe28f
3 changed files with 361 additions and 210 deletions

1
LOG.md
View File

@@ -9,6 +9,7 @@
- Moved auth/admin/write/logout actions into the Dock and changed category windows/menu labels to show the actual category name instead of Finder/posts copy. - Moved auth/admin/write/logout actions into the Dock and changed category windows/menu labels to show the actual category name instead of Finder/posts copy.
- Tuned the follow-up layout: main content now centers inside the area remaining after the desktop sidebar, sidebar GitHub/email shortcuts were removed, top-bar GitHub/email links now sit as plain left-side menu items, and the home labels were simplified to 홈/WYPark. - Tuned the follow-up layout: main content now centers inside the area remaining after the desktop sidebar, sidebar GitHub/email shortcuts were removed, top-bar GitHub/email links now sit as plain left-side menu items, and the home labels were simplified to 홈/WYPark.
- Softened the home dashboard inner popular/latest sections so they no longer render as nested macOS windows with traffic-light controls; they now read as embedded dashboard panels inside the WYPark window. - Softened the home dashboard inner popular/latest sections so they no longer render as nested macOS windows with traffic-light controls; they now read as embedded dashboard panels inside the WYPark window.
- Rebuilt the admin post writing screen as a glassy Markdown Studio with a larger writing area, publish controls, category/tag/image/draft panels, and fixed MDEditor foreground colors so typed text remains readable in both light and dark themes.
- Validation: `npm run lint` passed, `npm run build` passed. The build logged a sitemap fetch warning because the backend API at the local default was not reachable, but the command completed successfully. - Validation: `npm run lint` passed, `npm run build` passed. The build logged a sitemap fetch warning because the backend API at the local default was not reachable, but the command completed successfully.
- Browser verification: previously started this app on `http://localhost:3100` because ports 3000 and 3001 were already occupied; confirmed desktop background gradients, menu bar, Dock, and window surfaces on `/`, `/archive`, and `/login`. For this follow-up, the dev/standalone server started successfully in foreground but exited when launched as a background non-interactive process, so browser verification was skipped after lint/build passed. - Browser verification: previously started this app on `http://localhost:3100` because ports 3000 and 3001 were already occupied; confirmed desktop background gradients, menu bar, Dock, and window surfaces on `/`, `/archive`, and `/login`. For this follow-up, the dev/standalone server started successfully in foreground but exited when launched as a background non-interactive process, so browser verification was skipped after lint/build passed.
- Recommended next task: review the remaining older Korean strings in admin/editor flows and normalize any mojibake that predates this redesign. - Recommended next task: review the remaining older Korean strings in admin/editor flows and normalize any mojibake that predates this redesign.

View File

@@ -383,3 +383,90 @@ html[data-theme="dark"] .w-md-editor-toolbar {
background-color: var(--window-titlebar) !important; background-color: var(--window-titlebar) !important;
border-color: var(--color-line) !important; border-color: var(--color-line) !important;
} }
.wy-editor-shell {
--color-canvas-default: transparent;
--color-canvas-subtle: transparent;
--color-border-default: var(--color-line);
--color-border-muted: var(--color-line);
--color-fg-default: var(--color-text);
--color-fg-muted: var(--color-text-muted);
--color-accent-fg: var(--color-accent);
color: var(--color-text);
}
.wy-editor-shell .w-md-editor {
min-height: 520px;
overflow: hidden;
border-radius: 0.5rem !important;
border: 1px solid var(--color-line) !important;
background-color: var(--window-bg-strong) !important;
color: var(--color-text) !important;
box-shadow: var(--shadow-card) !important;
}
.wy-editor-shell .w-md-editor-toolbar {
border-color: var(--color-line) !important;
background-color: var(--window-titlebar) !important;
}
.wy-editor-shell .w-md-editor-toolbar button,
.wy-editor-shell .w-md-editor-toolbar li,
.wy-editor-shell .w-md-editor-toolbar svg {
color: var(--color-text-muted) !important;
}
.wy-editor-shell .w-md-editor-toolbar button:hover {
background-color: var(--color-control) !important;
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-content,
.wy-editor-shell .w-md-editor-area,
.wy-editor-shell .w-md-editor-text,
.wy-editor-shell .w-md-editor-text-pre,
.wy-editor-shell .w-md-editor-text-input,
.wy-editor-shell .w-md-editor-preview,
.wy-editor-shell .wmde-markdown {
background: transparent !important;
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-text-input {
-webkit-text-fill-color: var(--color-text) !important;
caret-color: var(--color-accent);
}
.wy-editor-shell .w-md-editor-text-input::placeholder {
color: var(--color-text-subtle) !important;
-webkit-text-fill-color: var(--color-text-subtle) !important;
}
.wy-editor-shell .w-md-editor-text-pre > code,
.wy-editor-shell .w-md-editor-text-pre code,
.wy-editor-shell .wmde-markdown,
.wy-editor-shell .wmde-markdown p,
.wy-editor-shell .wmde-markdown li,
.wy-editor-shell .wmde-markdown h1,
.wy-editor-shell .wmde-markdown h2,
.wy-editor-shell .wmde-markdown h3,
.wy-editor-shell .wmde-markdown h4,
.wy-editor-shell .wmde-markdown h5,
.wy-editor-shell .wmde-markdown h6 {
color: var(--color-text) !important;
}
.wy-editor-shell .wmde-markdown a {
color: var(--color-accent) !important;
}
.wy-editor-shell .wmde-markdown blockquote {
color: var(--color-text-muted) !important;
border-left-color: var(--color-line) !important;
}
.wy-editor-shell .wmde-markdown pre,
.wy-editor-shell .wmde-markdown code {
background-color: rgba(30, 30, 30, 0.9) !important;
color: #f4f4f5 !important;
}

View File

@@ -3,24 +3,27 @@
import axios from 'axios'; import axios from 'axios';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { import {
ArrowLeft, ArrowLeft,
CheckCircle2,
Clock, Clock,
FileText, FileText,
Folder, Folder,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
Save, Save,
Tags,
Trash2, Trash2,
UploadCloud, UploadCloud,
X,
} from 'lucide-react'; } from 'lucide-react';
import { getCategories } from '@/api/category'; import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image'; import { uploadImage } from '@/api/image';
import { createPost, getPost, updatePost } from '@/api/posts'; import { createPost, getPost, updatePost } from '@/api/posts';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types'; import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types';
@@ -62,6 +65,17 @@ const findCategoryByName = (categories: Category[], name: string): Category | nu
return null; 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) => { const isTokenExpired = (token: string) => {
try { try {
const base64Url = token.split('.')[1]; const base64Url = token.split('.')[1];
@@ -92,25 +106,22 @@ function CategoryOptions({
depth?: number; depth?: number;
}) { }) {
return ( return (
<> <div className={depth === 0 ? 'space-y-1' : 'mt-1 space-y-1'}>
{categories.map((category) => ( {categories.map((category) => (
<div key={category.id}> <div key={category.id}>
<label className="flex cursor-pointer items-center gap-2 rounded p-2 transition-colors hover:bg-gray-50"> <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-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10"
style={{ marginLeft: `${depth * 10}px` }}
>
<input <input
type="radio" type="radio"
name="category" name="category"
value={category.id} value={category.id}
checked={selectedId === category.id} checked={selectedId === category.id}
onChange={(event) => onSelect(Number(event.target.value))} onChange={(event) => onSelect(Number(event.target.value))}
className="text-blue-600 focus:ring-blue-500" className="h-4 w-4 accent-[var(--color-accent)]"
/> />
<span <span className={depth === 0 ? 'font-semibold' : ''}>{category.name}</span>
style={{ marginLeft: `${depth * 10}px` }}
className={depth === 0 ? 'font-medium' : 'text-gray-600'}
>
{depth > 0 && '- '}
{category.name}
</span>
</label> </label>
{category.children?.length > 0 && ( {category.children?.length > 0 && (
<CategoryOptions <CategoryOptions
@@ -122,7 +133,7 @@ function CategoryOptions({
)} )}
</div> </div>
))} ))}
</> </div>
); );
} }
@@ -136,39 +147,38 @@ function DraftLoadDialog({
onConfirm: () => void; onConfirm: () => void;
}) { }) {
return ( 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="fixed inset-0 z-50 flex items-end justify-center bg-black/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"> <WindowSurface
<div className="border-b border-gray-100 p-5"> title="임시저장 불러오기"
<h3 className="text-lg font-bold text-gray-950"> </h3> className="w-full max-w-md"
<p className="mt-1 text-sm leading-6 text-gray-500"> bodyClassName="p-5"
. >
</p> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
</div> .
</p>
<div className="p-5"> <Surface strong className="mt-4 p-4 shadow-none">
<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-[var(--color-text)]">{draft.title}</p>
<p className="line-clamp-2 text-sm font-semibold text-gray-950">{draft.title}</p> <p className="mt-1 text-xs text-[var(--color-text-subtle)]">{draft.savedAt}</p>
<p className="mt-1 text-xs text-gray-500">{draft.savedAt}</p> </Surface>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button <button
type="button" type="button"
onClick={onCancel} 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" className="inline-flex items-center justify-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]"
> >
</button> </button>
<button <button
type="button" type="button"
onClick={onConfirm} 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" 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> </button>
</div>
</div> </div>
</div> </WindowSurface>
</div> </div>
); );
} }
@@ -182,7 +192,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const isEditMode = Boolean(editSlug); const isEditMode = Boolean(editSlug);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [content, setContent] = useState('**Hello world!**'); const [content, setContent] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>(''); const [categoryId, setCategoryId] = useState<number | ''>('');
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@@ -246,6 +256,17 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
} }
}, [existingPost, categories]); }, [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[]) => { const saveDrafts = (nextDrafts: DraftPost[]) => {
setDrafts(nextDrafts); setDrafts(nextDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts)); localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts));
@@ -253,12 +274,12 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const handleTempSave = () => { const handleTempSave = () => {
if (!title.trim() && !content.trim()) { if (!title.trim() && !content.trim()) {
toast.error('제목이나 내용을 입력해주세요.'); toast.error('제목이나 내용을 입력해 주세요.');
return; return;
} }
if (drafts.length >= 10) { if (drafts.length >= 10) {
toast.error('임시저장은 최대 10개까지 가능합니다.\n기존 저장분을 삭제해주세요.'); toast.error('임시저장은 최대 10개까지 가능합니다. 기존 저장분을 삭제해 주세요.');
setShowDraftList(true); setShowDraftList(true);
return; return;
} }
@@ -272,7 +293,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
}, },
...drafts, ...drafts,
]); ]);
toast.success('임시저장 되었습니다.'); toast.success('임시저장습니다.');
}; };
const handleLoadDraft = (draft: DraftPost) => { const handleLoadDraft = (draft: DraftPost) => {
@@ -286,12 +307,12 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
setContent(draftToLoad.content); setContent(draftToLoad.content);
setShowDraftList(false); setShowDraftList(false);
setDraftToLoad(null); setDraftToLoad(null);
toast.success('불러오기 완료'); toast.success('임시저장을 불러왔습니다.');
}; };
const handleDeleteDraft = (id: number) => { const handleDeleteDraft = (id: number) => {
saveDrafts(drafts.filter((draft) => draft.id !== id)); saveDrafts(drafts.filter((draft) => draft.id !== id));
toast.success('삭제되었습니다.'); toast.success('삭제습니다.');
}; };
const mutation = useMutation({ const mutation = useMutation({
@@ -316,7 +337,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
queryClient.removeQueries({ queryKey: ['post', newSlug] }); queryClient.removeQueries({ queryKey: ['post', newSlug] });
} }
toast.success(isEditMode ? '게시글 수정되었습니다.' : '게시글 발행되었습니다.'); toast.success(isEditMode ? '게시글 수정습니다.' : '게시글 발행습니다.');
router.push(newSlug ? `/posts/${newSlug}` : '/admin'); router.push(newSlug ? `/posts/${newSlug}` : '/admin');
}, },
onError: (error) => { onError: (error) => {
@@ -350,13 +371,13 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
return false; return false;
} catch { } catch {
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요.', { duration: 5000 }); toast.error('세션이 만료되었습니다. 작성 중인 글을 복사해 두고 다시 로그인해 주세요.', { duration: 5000 });
return false; return false;
} }
}; };
const appendImageMarkdown = (imageUrl: string) => { const appendImageMarkdown = (imageUrl: string) => {
setContent((previous) => `${previous}\n![image](${imageUrl})`); setContent((previous) => `${previous}${previous ? '\n\n' : ''}![image](${imageUrl})`);
}; };
const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => { const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => {
@@ -374,11 +395,11 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
toast.error('제목과 내용을 입력해주세요.'); toast.error('제목과 내용을 입력해 주세요.');
return; return;
} }
if (categoryId === '') { if (categoryId === '') {
toast.error('카테고리를 선택해주세요.'); toast.error('카테고리를 선택해 주세요.');
return; return;
} }
@@ -406,7 +427,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const uploadToast = toast.loading('이미지 업로드 중...'); const uploadToast = toast.loading('이미지 업로드 중...');
try { try {
await uploadEditorImage(file, '이미지가 업로드되었습니다.', uploadToast); await uploadEditorImage(file, '이미지를 추가했습니다.', uploadToast);
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally { } finally {
@@ -430,7 +451,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const uploadToast = toast.loading('이미지 업로드 중...'); const uploadToast = toast.loading('이미지 업로드 중...');
try { try {
await uploadEditorImage(file, '이미지 붙여넣기 완료.', uploadToast); await uploadEditorImage(file, '붙여넣은 이미지를 추가했습니다.', uploadToast);
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally { } finally {
@@ -442,175 +463,217 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) { if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={40} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8" onPaste={handlePaste}> <main className="mx-auto w-full px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6" onPaste={handlePaste}>
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center"> <WindowSurface
<div className="flex items-center gap-3"> title={isEditMode ? '글 수정' : '새 글 작성'}
subtitle="Markdown Studio"
controls={(
<button <button
type="button" type="button"
onClick={() => router.push('/admin/posts')} onClick={() => router.push('/admin/posts')}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100" className="inline-flex h-8 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-xs font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]"
aria-label="게시글 관리로 돌아가기"
> >
<ArrowLeft size={20} /> <ArrowLeft size={14} />
</button> </button>
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1> )}
</div> bodyClassName="p-4 md:p-6"
>
<div className="flex flex-wrap items-center gap-3"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="relative"> <section className="min-w-0 space-y-4">
<div className="flex items-center rounded-lg border border-gray-300 bg-white shadow-sm"> <Surface strong className="p-4 shadow-none md:p-5">
<button <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">
type="button" <div>
onClick={handleTempSave} <p className="text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">Document</p>
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" <h1 className="mt-1 text-xl font-bold text-[var(--color-text)]">
title="현재 내용 임시저장" {isEditMode ? '게시글 수정' : '게시글 작성'}
> </h1>
<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>
</>
)}
</div>
<button <div className="flex flex-wrap items-center gap-2">
type="button" <button
onClick={handleSubmit} type="button"
disabled={isSubmitting || isUploading} onClick={handleTempSave}
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" className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]"
> >
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />} <FileText size={16} />
{isEditMode ? '수정하기' : '작성하기'}
</button> </button>
</div> <button
</div> type="button"
onClick={handleSubmit}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4"> disabled={isSubmitting || isUploading || !canSubmit}
<div className="space-y-4 lg:col-span-3"> 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"
<input >
type="text" {isSubmitting ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
value={title} {isEditMode ? '수정하기' : '발행하기'}
onChange={(event) => setTitle(event.target.value)} </button>
placeholder="제목을 입력하세요" </div>
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> </div>
<label className="mb-2 block text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
</label>
<input <input
type="file" type="text"
className="hidden" value={title}
accept="image/*" onChange={(event) => setTitle(event.target.value)}
onChange={handleFileChange} placeholder="제목을 입력하세요"
disabled={isUploading} className="mb-5 w-full rounded-lg border border-[var(--color-line)] 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"
/> />
</label>
</div> <div className="wy-editor-shell" data-color-mode="auto">
<MDEditor
value={content}
onChange={(value) => setContent(value || '')}
height={680}
preview="edit"
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(--color-line)] bg-[var(--color-control)] p-3">
<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(--color-line)] bg-[var(--color-control)] p-3">
<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(--color-line)] bg-[var(--color-control)] p-3">
<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(--color-line)] bg-[var(--color-control)] p-2">
{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(--color-line)] 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(--color-line)] bg-[var(--color-control)] p-4 text-center transition hover:border-[var(--color-accent)] hover:bg-[var(--window-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(--color-line)] bg-[var(--color-control)] px-2.5 py-1 text-xs font-bold text-[var(--color-text-muted)] transition hover:bg-[var(--window-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-black/[0.04] dark:hover:bg-white/10">
<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> </div>
</div> </WindowSurface>
{draftToLoad && ( {draftToLoad && (
<DraftLoadDialog <DraftLoadDialog
@@ -619,6 +682,6 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
onConfirm={confirmLoadDraft} onConfirm={confirmLoadDraft}
/> />
)} )}
</div> </main>
); );
} }