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![image](${imageUrl})`); + setContent((previous) => `${previous}${previous ? '\n\n' : ''}![image](${imageUrl})`); }; 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) 하거나 -
아래 버튼을 사용하세요. -

- - -
+ +
+ setContent(value || '')} + height={680} + preview="edit" + className="wy-markdown-editor" + /> +
+ + + +
-
+ {draftToLoad && ( )} -
+ ); }