diff --git a/LOG.md b/LOG.md
index 46526d6..7231293 100644
--- a/LOG.md
+++ b/LOG.md
@@ -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.
- 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.
+- 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.
- 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.
diff --git a/src/app/globals.css b/src/app/globals.css
index 358c660..90b358f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -383,3 +383,90 @@ html[data-theme="dark"] .w-md-editor-toolbar {
background-color: var(--window-titlebar) !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;
+}
diff --git a/src/components/admin/AdminPostEditor.tsx b/src/components/admin/AdminPostEditor.tsx
index e6cb7a1..4af67f8 100644
--- a/src/components/admin/AdminPostEditor.tsx
+++ b/src/components/admin/AdminPostEditor.tsx
@@ -3,24 +3,27 @@
import axios from 'axios';
import dynamic from 'next/dynamic';
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 toast from 'react-hot-toast';
import {
ArrowLeft,
+ CheckCircle2,
Clock,
FileText,
Folder,
Image as ImageIcon,
Loader2,
Save,
+ Tags,
Trash2,
UploadCloud,
- X,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image';
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 { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types';
@@ -62,6 +65,17 @@ const findCategoryByName = (categories: Category[], name: string): Category | nu
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];
@@ -92,25 +106,22 @@ function CategoryOptions({
depth?: number;
}) {
return (
- <>
+
{categories.map((category) => (
-
);
}
@@ -136,39 +147,38 @@ function DraftLoadDialog({
onConfirm: () => void;
}) {
return (
-
-
-
-
임시저장 불러오기
-
- 현재 작성 중인 내용이 선택한 임시저장 글로 바뀝니다.
-
-
+
+
+
+ 현재 작성 중인 내용이 선택한 임시저장 글로 바뀝니다.
+
-
-
-
{draft.title}
-
{draft.savedAt}
-
+
+ {draft.title}
+ {draft.savedAt}
+
-
-
-
-
+
+
+
-
+
);
}
@@ -182,7 +192,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const isEditMode = Boolean(editSlug);
const [title, setTitle] = useState('');
- const [content, setContent] = useState('**Hello world!**');
+ const [content, setContent] = useState('');
const [categoryId, setCategoryId] = useState
('');
const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false);
@@ -246,6 +256,17 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
}
}, [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));
@@ -253,12 +274,12 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const handleTempSave = () => {
if (!title.trim() && !content.trim()) {
- toast.error('제목이나 내용을 입력해주세요.');
+ toast.error('제목이나 내용을 입력해 주세요.');
return;
}
if (drafts.length >= 10) {
- toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.');
+ toast.error('임시저장은 최대 10개까지 가능합니다. 기존 저장분을 삭제해 주세요.');
setShowDraftList(true);
return;
}
@@ -272,7 +293,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
},
...drafts,
]);
- toast.success('임시저장 되었습니다.');
+ toast.success('임시저장했습니다.');
};
const handleLoadDraft = (draft: DraftPost) => {
@@ -286,12 +307,12 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
setContent(draftToLoad.content);
setShowDraftList(false);
setDraftToLoad(null);
- toast.success('불러오기 완료');
+ toast.success('임시저장을 불러왔습니다.');
};
const handleDeleteDraft = (id: number) => {
saveDrafts(drafts.filter((draft) => draft.id !== id));
- toast.success('삭제되었습니다.');
+ toast.success('삭제했습니다.');
};
const mutation = useMutation({
@@ -316,7 +337,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
queryClient.removeQueries({ queryKey: ['post', newSlug] });
}
- toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다.');
+ toast.success(isEditMode ? '게시글을 수정했습니다.' : '게시글을 발행했습니다.');
router.push(newSlug ? `/posts/${newSlug}` : '/admin');
},
onError: (error) => {
@@ -350,13 +371,13 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
return false;
} catch {
- toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요.', { duration: 5000 });
+ toast.error('세션이 만료되었습니다. 작성 중인 글을 복사해 두고 다시 로그인해 주세요.', { duration: 5000 });
return false;
}
};
const appendImageMarkdown = (imageUrl: string) => {
- setContent((previous) => `${previous}\n`);
+ setContent((previous) => `${previous}${previous ? '\n\n' : ''}`);
};
const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => {
@@ -374,11 +395,11 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const handleSubmit = async () => {
if (!title.trim() || !content.trim()) {
- toast.error('제목과 내용을 입력해주세요.');
+ toast.error('제목과 내용을 입력해 주세요.');
return;
}
if (categoryId === '') {
- toast.error('카테고리를 선택해주세요.');
+ toast.error('카테고리를 선택해 주세요.');
return;
}
@@ -406,7 +427,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const uploadToast = toast.loading('이미지 업로드 중...');
try {
- await uploadEditorImage(file, '이미지가 업로드되었습니다.', uploadToast);
+ await uploadEditorImage(file, '이미지를 추가했습니다.', uploadToast);
} catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally {
@@ -430,7 +451,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const uploadToast = toast.loading('이미지 업로드 중...');
try {
- await uploadEditorImage(file, '이미지 붙여넣기 완료.', uploadToast);
+ await uploadEditorImage(file, '붙여넣은 이미지를 추가했습니다.', uploadToast);
} catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally {
@@ -442,175 +463,217 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) {
return (
-
+
);
}
return (
-
-
-
+
+ router.push('/admin/posts')}
- className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100"
- aria-label="게시글 관리로 돌아가기"
+ 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)]"
>
-
+
+ 목록
- {isEditMode ? '게시글 수정' : '새 글 작성'}
-
-
-
-
-
-
-
-
-
- {showDraftList && (
- <>
-
setShowDraftList(false)} />
-
-
-
임시저장 목록 ({drafts.length}/10)
-
-
-
-
- {drafts.length === 0 ? (
-
저장된 글이 없습니다.
- ) : (
-
- {drafts.map((draft) => (
- -
-
-
-
-
-
- ))}
-
- )}
-
+ )}
+ bodyClassName="p-4 md:p-6"
+ >
+
+
+
+
+
+
Document
+
+ {isEditMode ? '게시글 수정' : '게시글 작성'}
+
- >
- )}
-
-
-
-
-
-
-
-
setTitle(event.target.value)}
- placeholder="제목을 입력하세요"
- className="w-full border-none bg-transparent py-2 text-3xl font-bold outline-none placeholder:text-gray-300"
- />
-
- setContent(value || '')}
- height={600}
- preview="edit"
- className="rounded-lg border border-gray-200 shadow-sm !font-sans"
- />
-
-
-
-
-
-
-
- 카테고리
-
-
- {categories.length > 0 ? (
-
- ) : (
-
로딩 중...
- )}
-
-
-
-
-
-
- 이미지 업로드
-
-
- 에디터에 이미지를
- 복사 & 붙여넣기(Ctrl+V) 하거나
-
아래 버튼을 사용하세요.
-
-
-
-
-
-
- {isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
-
+
+
+
+
+
+
+ 제목
+
setTitle(event.target.value)}
+ placeholder="제목을 입력하세요"
+ 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"
/>
-
-
+
+
+ setContent(value || '')}
+ height={680}
+ preview="edit"
+ className="wy-markdown-editor"
+ />
+
+
+
+
+
-
+
{draftToLoad && (
)}
-
+
);
}