Compare commits
2 Commits
116b9a4fd2
...
58a012621a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a012621a | ||
|
|
f4481b88cb |
116
AGENTS.md
Normal file
116
AGENTS.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Repository-specific instructions for coding agents working on `blog-frontend`.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- This is the frontend for WYPark Blog, built with Next.js 16 App Router, React 19, TypeScript, Tailwind CSS 4, TanStack React Query, Zustand, and Axios.
|
||||||
|
- The app talks to a backend API through `NEXT_PUBLIC_API_URL`.
|
||||||
|
- Local default: `http://localhost:8080`
|
||||||
|
- Production API used by deploy: `https://blogserver.wypark.me`
|
||||||
|
- Public site metadata uses: `https://blog.wypark.me`
|
||||||
|
- Node 20 is the production baseline. The Docker build uses `node:20-alpine`.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
- `src/app/`: Next.js App Router routes, root layout, providers, metadata, sitemap, and robots.
|
||||||
|
- `src/components/layout/`: persistent layout components such as `Sidebar` and `TopHeader`.
|
||||||
|
- `src/components/post/`: post listing, detail, Markdown rendering, search, and TOC components.
|
||||||
|
- `src/components/comment/`: comment form, list, and item components.
|
||||||
|
- `src/api/`: API client wrappers. Use these instead of calling backend endpoints directly from components.
|
||||||
|
- `src/api/http.ts`: shared Axios instance, auth header injection, and token refresh handling.
|
||||||
|
- `src/store/authStore.ts`: persisted auth state with Zustand.
|
||||||
|
- `src/types/index.ts`: shared API and domain types.
|
||||||
|
- `public/`: static assets.
|
||||||
|
- `.gitea/workflows/deploy.yml`: Gitea deployment pipeline.
|
||||||
|
- `DockerFile`: production Docker image definition. Keep the current filename casing unless changing the deploy workflow too.
|
||||||
|
|
||||||
|
## Package Manager And Commands
|
||||||
|
|
||||||
|
- Prefer `npm` for scripts and dependency work because `package-lock.json`, Docker, and CI use npm.
|
||||||
|
- Do not update both `package-lock.json` and `yarn.lock` for routine changes. If dependencies must change, update the npm lockfile and leave a note about the lockfile decision.
|
||||||
|
- Common commands:
|
||||||
|
- `npm ci` to install dependencies from the lockfile.
|
||||||
|
- `npm run dev` to start local development on port 3000.
|
||||||
|
- `npm run lint` for ESLint.
|
||||||
|
- `npm run build` for production build validation.
|
||||||
|
- `npm run start` after a successful build.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Use `NEXT_PUBLIC_API_URL` when testing against a non-local backend.
|
||||||
|
- Features that fetch categories, posts, profile, comments, auth, or uploads require the backend API to be reachable.
|
||||||
|
- If the backend is not running, prefer validating static rendering, linting, and isolated UI logic instead of inventing mock API behavior.
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
|
||||||
|
- Keep TypeScript strict. Add or refine shared types in `src/types/index.ts` instead of spreading untyped `any` through components.
|
||||||
|
- Use the `@/*` alias for imports from `src`.
|
||||||
|
- Prefer existing local patterns over new abstractions.
|
||||||
|
- Keep components focused and colocate small UI-only helpers near the component that uses them.
|
||||||
|
- Preserve the existing Tailwind-first styling approach. Use global CSS only for app-wide behavior or third-party component overrides.
|
||||||
|
- Prefer icons from the existing icon libraries (`lucide-react`, `@heroicons/react`) instead of custom inline SVGs.
|
||||||
|
- User-facing copy is Korean-first. When editing Korean text, save files as UTF-8 and verify the rendered text or diff so strings do not become mojibake.
|
||||||
|
- Some existing files contain Korean comments and UI strings. Do not mass-rewrite text while making unrelated changes; fix only the text needed for the task.
|
||||||
|
|
||||||
|
## Next.js App Router Rules
|
||||||
|
|
||||||
|
- Use Server Components by default.
|
||||||
|
- Add `'use client'` only when a component uses hooks, browser APIs, localStorage, React Query hooks, Zustand hooks, events, or client-only libraries.
|
||||||
|
- Components that use `useSearchParams` should remain under a `Suspense` boundary.
|
||||||
|
- Dynamic route params follow the current Next.js 16 style in this repo, where route components may receive `params` as a `Promise`.
|
||||||
|
- Keep metadata, sitemap, and robots changes aligned with the production domain `blog.wypark.me`.
|
||||||
|
|
||||||
|
## Data Fetching And API Rules
|
||||||
|
|
||||||
|
- Use `src/api/*` functions from UI code. Add new backend calls there before consuming them in components.
|
||||||
|
- Use `http` from `src/api/http.ts` for authenticated JSON API calls so auth headers, credentials, and refresh behavior stay consistent.
|
||||||
|
- Direct `axios` calls should be limited to token refresh paths or cases where the shared interceptor would recurse.
|
||||||
|
- API responses generally follow `ApiResponse<T>` with useful data under `response.data.data`.
|
||||||
|
- Keep React Query keys stable and descriptive, following existing patterns such as `['posts', ...]`, `['post', slug]`, `['categories']`, `['profile']`, and `['comments', postSlug]`.
|
||||||
|
- After mutations, invalidate or reset the related queries rather than relying on stale cache state.
|
||||||
|
|
||||||
|
## Auth And Permissions
|
||||||
|
|
||||||
|
- Auth state lives in `useAuthStore` and is persisted under `auth-storage`.
|
||||||
|
- Admin-only UI checks generally use `_hasHydrated` plus `role?.includes('ADMIN')`.
|
||||||
|
- Avoid reading localStorage during server rendering. Gate browser-only auth logic behind client components and effects.
|
||||||
|
- Be careful around token refresh. There is queueing and Web Locks logic in `src/api/http.ts`; do not simplify it without testing concurrent 401 behavior.
|
||||||
|
- Do not expose refresh tokens or auth tokens in logs, UI, or error messages.
|
||||||
|
|
||||||
|
## Markdown And Content Rendering
|
||||||
|
|
||||||
|
- Render post content through `src/components/post/MarkdownRenderer.tsx`.
|
||||||
|
- Keep `rehype-sanitize` in the Markdown pipeline unless there is a deliberate, reviewed security change.
|
||||||
|
- When adding Markdown features, consider XSS, external links, image behavior, syntax highlighting, TOC generation, and SSR/client compatibility.
|
||||||
|
- Uploaded images are inserted as Markdown image syntax by the write page. Preserve that convention unless changing backend upload semantics too.
|
||||||
|
|
||||||
|
## UI And UX Guidelines
|
||||||
|
|
||||||
|
- Preserve the main layout: fixed sidebar on larger screens, mobile sidebar toggle, top-right auth/write controls, content area with responsive widths.
|
||||||
|
- Keep blog reading surfaces calm and content-focused. Avoid large decorative sections unless the user explicitly asks for a redesign.
|
||||||
|
- Ensure text fits on mobile and desktop, especially Korean labels in buttons, cards, modals, and sidebar items.
|
||||||
|
- For admin flows, keep destructive actions confirmed and visually distinct.
|
||||||
|
- For forms, preserve loading, disabled, error, and success states with `react-hot-toast` or the existing local patterns.
|
||||||
|
|
||||||
|
## Validation Expectations
|
||||||
|
|
||||||
|
- For most code changes, run `npm run lint`.
|
||||||
|
- Run `npm run build` when changing routing, layout, metadata, data fetching, auth, Markdown rendering, or build/deploy config.
|
||||||
|
- For visible UI changes, start `npm run dev` and verify the affected route in a browser when feasible.
|
||||||
|
- If validation cannot run because dependencies or the backend are unavailable, state that clearly and explain what was checked instead.
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
- The production Docker image uses standalone Next output. Keep `next.config.ts` `output: 'standalone'` unless the deployment strategy changes.
|
||||||
|
- The Gitea workflow builds on pushes to `main`, passes `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me`, runs the container as `blog-frontend`, and maps host port `3005` to container port `3000`.
|
||||||
|
- Keep Docker, Gitea workflow, and Next config changes synchronized.
|
||||||
|
|
||||||
|
## Change Discipline
|
||||||
|
|
||||||
|
- Keep edits scoped to the requested task.
|
||||||
|
- Record completed work in `LOG.md` as part of each task. Include the date, a brief summary of changes, and any validation performed or skipped.
|
||||||
|
- When finishing a task and updating `LOG.md`, also leave a concise note about the recommended next task so future agents can continue smoothly.
|
||||||
|
- Do not rewrite generated files, `.next`, `node_modules`, build output, or unrelated lockfiles.
|
||||||
|
- Do not silently change public URLs, analytics IDs, SEO behavior, auth storage keys, or backend endpoint paths.
|
||||||
|
- When touching fragile areas such as auth refresh, Markdown sanitization, deployment, or route params, include a short explanation and stronger validation.
|
||||||
35
LOG.md
Normal file
35
LOG.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# LOG.md
|
||||||
|
|
||||||
|
## 2026-05-28
|
||||||
|
|
||||||
|
- Created `AGENTS.md` with repository-specific coding-agent guidance for the Next.js blog frontend.
|
||||||
|
- Updated `AGENTS.md` to require recording completed work in `LOG.md` for each task.
|
||||||
|
- Validation: confirmed `AGENTS.md` content and checked repository status with a one-off safe-directory git option.
|
||||||
|
- Started the renewal plan by adding an `/admin` dashboard entry point with admin-only route guarding, post/category summary widgets, and quick access to writing.
|
||||||
|
- Updated `TopHeader` to show `관리자 설정` and `새 글` only after auth hydration confirms an admin role.
|
||||||
|
- Renamed the post detail loading file from `loding.tsx` to `loading.tsx`, gated React Query Devtools to development mode, and added `PostSaveRequest` for post create/update APIs.
|
||||||
|
- Validation: ran `npm ci` successfully after rerunning outside the sandbox due npm cache permissions; targeted ESLint passed for the new admin/header/API/type files; `npm run build` passed; verified `/admin` redirects unauthenticated users to `/login` in the in-app browser on local port 3100. `npm run lint` still fails on existing baseline issues across the repo, mostly `any` usage, React Compiler `set-state-in-effect` findings, and a `require()` in `tailwind.config.ts`.
|
||||||
|
- Updated `AGENTS.md` so future task wrap-ups must include the recommended next task in `LOG.md`.
|
||||||
|
- Next task: proceed with renewal Phase 2 by removing profile/category admin editing controls from `Sidebar` and moving those flows into `/admin` tabs or dedicated admin components.
|
||||||
|
- Continued renewal Phase 2 by reducing `Sidebar` to public navigation only: profile display, search, archive, category links, and social links remain; profile edit modal and category create/delete/move controls were removed.
|
||||||
|
- Added `/admin` profile and category management panels with profile image upload, profile save, category creation, rename, parent move, and guarded delete confirmation flows using the existing API wrappers.
|
||||||
|
- Validation: targeted ESLint passed for the changed admin/sidebar files; `npm run build` passed, with the existing sitemap backend connection warning logged as `ECONNREFUSED`; `npm run lint` still fails on the known baseline issues outside this task; local dev server on port 3100 returned HTTP 200 for `/admin`.
|
||||||
|
- Next task: proceed with renewal Phase 3 by adding an admin posts management view with searchable post rows, edit links, and a non-browser-confirm delete flow.
|
||||||
|
- Continued renewal Phase 3 by adding an `/admin` 게시글 관리 panel with keyword search, sort controls, pagination, view/edit actions, and an in-app delete confirmation dialog that invalidates post queries after deletion.
|
||||||
|
- Added `PageMeta` and expanded `PostListResponse` so admin and dashboard counts can handle either top-level pagination fields or a nested `page` object from the backend.
|
||||||
|
- Validation: installed dependencies with `npm ci`; targeted ESLint passed for `AdminPostsPanel`, `/admin`, and shared types; `tsc --noEmit` passed; `next build --webpack` passed with the existing sitemap fetch `EPERM` warning. The default `npm run build` Turbopack run hung during optimization and was terminated; full `npm run lint` still fails on known baseline issues outside this task. Local dev server on port 3100 returned HTTP 200 for `/admin` and redirected unauthenticated browser access to `/login`.
|
||||||
|
- Next task: continue Phase 3 by tightening the write/edit flow under the admin experience, including replacing remaining browser `alert`/`confirm` usage in post detail/write flows with in-app dialogs or toast patterns.
|
||||||
|
- Added a Next.js `src/proxy.ts` HTTPS enforcement layer that redirects non-local HTTP requests to HTTPS, respects `X-Forwarded-Proto`/`X-Forwarded-Host` to avoid reverse-proxy redirect loops, and adds HSTS on non-local HTTPS responses.
|
||||||
|
- Validation: targeted ESLint passed for `src/proxy.ts` and the existing changed admin/type files; `tsc --noEmit` passed; `next build --webpack` passed with the existing sitemap fetch `EPERM` warning. Curl checks confirmed `Host: blog.wypark.me` over HTTP returns `301 Location: https://blog.wypark.me/archive`, `X-Forwarded-Proto: https` returns `200` with HSTS, and `127.0.0.1` local HTTP remains `200`. Full `npm run lint` still fails on the known baseline issues outside this task.
|
||||||
|
- Next task: mirror the HTTPS redirect at the outer reverse proxy/load balancer as infrastructure defense-in-depth, then continue Phase 3 write/edit flow cleanup.
|
||||||
|
- Completed the admin function move by extracting the post editor into `AdminPostEditor`, adding `/admin/posts/new` and `/admin/posts/[slug]/edit`, turning legacy `/write` into a compatibility redirect, and updating header/admin/post links to the new admin routes.
|
||||||
|
- Removed direct post edit/delete controls from the public post detail page and moved admin comment deletion into a new `/admin` comments panel backed by typed `AdminComment` responses.
|
||||||
|
- Updated `npm run build` to use `next build --webpack` so Docker/CI uses the build mode that passes locally instead of the default Turbopack build that previously hung.
|
||||||
|
- Validation: targeted ESLint passed for changed admin/write/comment/post/header/API/type/proxy files with one existing `<img>` warning in `PostDetailClient`; `tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; local dev server on port 3100 returned HTTP 200 for `/admin/posts/new`, `/admin/posts/sample/edit`, and `/write`. Full `npm run lint` still fails on known baseline issues outside this task.
|
||||||
|
- Next task: continue with non-design quality work by cleaning the remaining lint baseline (`src/api/http.ts`, category/archive/home/login/signup/comment form/list, Markdown renderer, TOC, auth store, and Tailwind config).
|
||||||
|
- Split admin management into dedicated routes under a shared guarded admin shell: `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/admin/profile`; `/admin` is now a dashboard only.
|
||||||
|
- Added admin dashboard recent comments and total comments widgets, plus defensive `getAdminComments` normalization for possible `author`, `postSlug`, and `postTitle` response aliases.
|
||||||
|
- Enhanced admin post management with category filtering, direct page-number navigation, current-page row selection, and bulk delete via the existing single-delete API.
|
||||||
|
- Added login return handling with `/login?redirect=...`, kept `/write` as the chosen legacy compatibility redirect, and documented these decisions in `docs/renewal-plan.md`.
|
||||||
|
- Validation: unauthenticated live-server request to `https://blogserver.wypark.me/api/admin/comments?page=0&size=1` returned HTTP 403 with an empty body, so exact admin comment fields still require an authenticated admin token/session; targeted ESLint passed for the changed admin/login/comment API files; `npx tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; full `npm run lint` still fails on known baseline issues outside this task; local dev route checks returned HTTP 200 for `/admin`, `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/login?redirect=%2Fadmin%2Fcomments`; in-app browser verification confirmed `/admin/comments` redirects to `/login?redirect=%2Fadmin%2Fcomments` when logged out.
|
||||||
|
- Next task: authenticate against the real admin API to capture the exact `getAdminComments` payload, then clean the existing lint baseline so full `npm run lint` can become a reliable gate.
|
||||||
621
docs/renewal-plan.md
Normal file
621
docs/renewal-plan.md
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
# WYPark Blog Frontend Renewal Plan
|
||||||
|
|
||||||
|
작성일: 2026-05-28
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
이 문서는 현재 `blog-frontend` 코드를 기준으로 개인 블로그 프론트엔드를 대폭 리뉴얼하기 위한 계획이다. 백엔드 API와 데이터 구조는 유지하고, 프론트엔드 라우팅, 컴포넌트 구조, 관리자 UX, 마크다운 렌더링, 시각 디자인을 중심으로 개선한다.
|
||||||
|
|
||||||
|
이번 리뉴얼은 기존 기능을 보존하는 마이너 개선이 아니라, 필요하다면 프론트엔드 구조를 넓게 재배치하는 것을 전제로 한다. 개인 블로그이므로 기존 UI 코드는 과감히 갈아엎어도 된다. 단, 인증 토큰 처리, API 경로, 공개 URL, SEO 도메인, 배포 구조처럼 외부 계약에 가까운 부분은 검증 없이 임의 변경하지 않는다.
|
||||||
|
|
||||||
|
## 2. 현재 코드 요약
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
|
||||||
|
- Next.js 16 App Router, React 19, TypeScript
|
||||||
|
- Tailwind CSS 4, `@tailwindcss/typography`
|
||||||
|
- TanStack React Query, Zustand, Axios
|
||||||
|
- `react-markdown`, `rehype-sanitize`, `react-syntax-highlighter`
|
||||||
|
- 글 작성 에디터: `@uiw/react-md-editor`
|
||||||
|
|
||||||
|
### 주요 구조
|
||||||
|
|
||||||
|
- 전역 레이아웃: `src/app/layout.tsx`
|
||||||
|
- 홈: `src/app/page.tsx`
|
||||||
|
- 글 상세: `src/app/posts/[slug]/page.tsx`, `src/components/post/PostDetailClient.tsx`
|
||||||
|
- 글 작성/수정: `src/app/write/page.tsx`
|
||||||
|
- 카테고리 페이지: `src/app/category/[id]/page.tsx`
|
||||||
|
- 아카이브: `src/app/archive/page.tsx`
|
||||||
|
- 사이드바/상단 헤더: `src/components/layout/Sidebar.tsx`, `src/components/layout/TopHeader.tsx`
|
||||||
|
- API 래퍼: `src/api/*`
|
||||||
|
- 인증 상태: `src/store/authStore.ts`
|
||||||
|
|
||||||
|
### API 재사용 가능성
|
||||||
|
|
||||||
|
백엔드 수정 없이 다음 관리자 기능을 만들 수 있다.
|
||||||
|
|
||||||
|
- 게시글 작성/수정/삭제: `createPost`, `updatePost`, `deletePost`
|
||||||
|
- 게시글 목록 조회/검색/정렬: `getPosts`
|
||||||
|
- 카테고리 조회/생성/수정/삭제: `getCategories`, `createCategory`, `updateCategory`, `deleteCategory`
|
||||||
|
- 프로필 조회/수정: `getProfile`, `updateProfile`
|
||||||
|
- 이미지 업로드: `uploadImage`
|
||||||
|
- 댓글 조회/삭제: `getComments`, `getAdminComments`, `deleteAdminComment`
|
||||||
|
|
||||||
|
## 3. 현재 문제와 개선 방향
|
||||||
|
|
||||||
|
### 홈 대시보드가 비어 보임
|
||||||
|
|
||||||
|
현재 홈은 공지 3개, 최신 글 3개, 인기 글 3개 정도의 섹션으로 구성되어 있다. `src/app/page.tsx`에서 이미 `getPosts`를 여러 번 호출해 최신순, 조회수순, 공지 목록을 가져오고 있으므로 데이터 기반은 있다. 다만 한 화면에서 읽을 수 있는 정보 밀도가 낮고, 블로그의 현재 상태를 보여주는 대시보드 느낌이 약하다.
|
||||||
|
|
||||||
|
개선 방향:
|
||||||
|
|
||||||
|
- 최신 글을 6~8개까지 보여주는 리스트 중심 섹션 추가
|
||||||
|
- 인기 글, 공지, 최근 댓글 또는 카테고리 현황을 보조 패널로 배치
|
||||||
|
- 전체 글 수, 카테고리 수, 최근 업데이트일 같은 작은 통계 영역 추가
|
||||||
|
- 검색 결과 화면과 기본 대시보드 화면을 시각적으로 분리
|
||||||
|
- 공지 글은 별도 스트립 또는 상단 compact list로 처리
|
||||||
|
|
||||||
|
### 관리자 기능이 일반 페이지에 섞여 있음
|
||||||
|
|
||||||
|
현재 사이드바에서 관리자 권한이면 프로필 수정, 카테고리 생성/삭제/이동 기능이 바로 노출된다. 글 작성 버튼은 상단 헤더에 있고, 글 상세에서는 수정/삭제 버튼이 직접 보인다. 기능은 동작하지만 일반 방문자용 읽기 화면과 관리자 도구가 섞여 있어 UI가 복잡해지고, “블로그를 보는 경험”과 “블로그를 관리하는 경험”이 분리되지 않는다.
|
||||||
|
|
||||||
|
개선 방향:
|
||||||
|
|
||||||
|
- `/admin` 관리자 설정 페이지 신설
|
||||||
|
- 관리자 계정 로그인 후에만 `TopHeader` 또는 사이드바 하단에 “관리자 설정” 버튼 노출
|
||||||
|
- 일반 회원과 비로그인 사용자는 관리자 버튼을 볼 수 없게 처리
|
||||||
|
- 기존 사이드바의 카테고리 편집 버튼, 프로필 수정 모달 제거
|
||||||
|
- 카테고리/프로필/댓글/게시글 관리는 `/admin` 내부에서만 수행
|
||||||
|
- `/write`는 외부 링크와 기존 북마크 호환을 위해 legacy redirect로 유지하고, 실제 작성/수정 화면은 `/admin/posts/new`, `/admin/posts/[slug]/edit`로 편입
|
||||||
|
|
||||||
|
### 마크다운 렌더링 기능이 제한적임
|
||||||
|
|
||||||
|
현재 `MarkdownRenderer`는 `remark-gfm`, `rehype-sanitize`, `rehype-slug`를 사용하고, 코드 블록/표/이미지/헤딩을 직접 커스텀한다. 그러나 `package.json`에는 이미 `remark-math`, `rehype-katex`, `remark-breaks`, `rehype-autolink-headings`, `katex`가 설치되어 있는데 실제 렌더러에서는 사용하지 않는다.
|
||||||
|
|
||||||
|
개선 방향:
|
||||||
|
|
||||||
|
- GFM, 줄바꿈, 수식, 코드, 표, 체크리스트, 자동 heading anchor를 일관되게 지원
|
||||||
|
- `rehype-sanitize`는 유지하되, 코드 언어 class, heading id, 링크 속성, KaTeX class를 허용하는 명시적 sanitize schema 구성
|
||||||
|
- 본문 타이포그래피를 전역 CSS와 Tailwind Typography 기반으로 정리
|
||||||
|
- 이미지 캡션, 이미지 확대 보기, 외부 링크 아이콘, anchor link 복사를 개선
|
||||||
|
- TOC는 렌더러의 heading slug 생성 방식과 완전히 같은 유틸리티를 공유
|
||||||
|
|
||||||
|
### 디자인이 컴포넌트마다 다소 분산되어 있음
|
||||||
|
|
||||||
|
대부분 Tailwind 클래스로 잘 구성되어 있지만, 파란색 중심의 색상, `rounded-2xl`, `shadow-xl`, 카드형 UI가 여러 곳에 반복된다. 블로그 읽기 화면은 미니멀해야 하는데, 카드와 그림자 중심의 요소가 많아 화면별 톤이 약간 다르게 느껴진다.
|
||||||
|
|
||||||
|
개선 방향:
|
||||||
|
|
||||||
|
- 색상, radius, border, shadow, spacing의 기준을 정하고 반복 컴포넌트화
|
||||||
|
- Apple 제품 UI에서 느껴지는 정제된 여백, 얇은 경계선, 부드러운 깊이감, 고급스러운 모션을 디자인 기준으로 채택
|
||||||
|
- 카드 남발을 줄이고, 글 목록/본문/관리자 화면별 밀도를 다르게 설계
|
||||||
|
- 본문 읽기 영역은 여백과 행간 중심으로 차분하게 구성
|
||||||
|
- 관리자 화면은 작업 효율 중심의 조밀한 테이블/패널 UI로 구성
|
||||||
|
- 모바일에서는 사이드바보다 상단/하단 내비게이션 접근성을 우선
|
||||||
|
|
||||||
|
### 타입 안정성과 운영 품질 개선 여지
|
||||||
|
|
||||||
|
`any`가 API, 페이지, 컴포넌트에 여러 곳 존재한다. 또한 `src/app/posts/[slug]/loding.tsx`는 Next.js가 인식하는 `loading.tsx`가 아니므로 로딩 UI 파일명이 잘못되어 있다. `ReactQueryDevtools`도 현재 `Providers`에서 항상 렌더링된다.
|
||||||
|
|
||||||
|
개선 방향:
|
||||||
|
|
||||||
|
- `PostSaveRequest`, `PagedResponse<T>`, `AdminComment` 등 누락 타입 추가
|
||||||
|
- 페이지 메타 구조가 `data.page` 또는 기존 `data` 양쪽에 대응하는 부분을 타입으로 흡수
|
||||||
|
- `loding.tsx`를 `loading.tsx`로 수정
|
||||||
|
- React Query Devtools는 개발 환경에서만 렌더링
|
||||||
|
- 브라우저 기본 `alert`, `confirm`, `prompt`를 주요 관리자 플로우에서 커스텀 모달/토스트로 교체
|
||||||
|
|
||||||
|
## 4. 제안하는 정보 구조
|
||||||
|
|
||||||
|
### 공개 페이지
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
/archive
|
||||||
|
/category/[id]
|
||||||
|
/posts/[slug]
|
||||||
|
/login
|
||||||
|
/signup
|
||||||
|
```
|
||||||
|
|
||||||
|
공개 페이지는 읽기 경험을 중심으로 유지한다. 관리자 전용 조작 버튼은 최소화하거나 관리자 페이지로 연결하는 정도로만 둔다.
|
||||||
|
|
||||||
|
### 관리자 페이지
|
||||||
|
|
||||||
|
```text
|
||||||
|
/admin
|
||||||
|
/admin/posts
|
||||||
|
/admin/posts/new
|
||||||
|
/admin/posts/[slug]/edit
|
||||||
|
/admin/categories
|
||||||
|
/admin/profile
|
||||||
|
/admin/comments
|
||||||
|
/admin/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 구현에서는 `/admin` 대시보드와 세부 관리 라우트를 분리한다. 기존 `/write`는 호환 redirect로만 유지한다.
|
||||||
|
|
||||||
|
권장 MVP:
|
||||||
|
|
||||||
|
- `/admin` 대시보드
|
||||||
|
- `/admin/posts` 게시글 관리 및 작성 버튼
|
||||||
|
- `/admin/categories` 카테고리 관리
|
||||||
|
- `/admin/profile` 프로필 관리
|
||||||
|
|
||||||
|
댓글 관리는 이후 단계로 분리해도 된다.
|
||||||
|
|
||||||
|
## 5. 홈 대시보드 리뉴얼 상세
|
||||||
|
|
||||||
|
### 현재 데이터 활용
|
||||||
|
|
||||||
|
기존 `getPosts`만으로 다음 데이터를 구성할 수 있다.
|
||||||
|
|
||||||
|
- 최신 글: `getPosts({ size: 8, sort: 'createdAt,desc' })`
|
||||||
|
- 인기 글: `getPosts({ size: 5, sort: 'viewCount,desc' })`
|
||||||
|
- 공지: `getPosts({ category: '공지', size: 3 })`
|
||||||
|
- 전체 글 수: 응답의 `totalElements`
|
||||||
|
- 카테고리: `getCategories()`
|
||||||
|
|
||||||
|
### 레이아웃 제안
|
||||||
|
|
||||||
|
```text
|
||||||
|
홈
|
||||||
|
├─ 상단: 블로그 이름, 짧은 소개, 검색
|
||||||
|
├─ 공지 스트립: 최신 공지 1~3개
|
||||||
|
├─ 메인 컬럼: 최신 글 목록 6~8개
|
||||||
|
├─ 보조 컬럼: 인기 글, 카테고리 요약, 최근 업데이트
|
||||||
|
└─ 하단: 아카이브 진입, 태그/카테고리 탐색
|
||||||
|
```
|
||||||
|
|
||||||
|
디자인 방향:
|
||||||
|
|
||||||
|
- 최신 글은 카드보다 리스트형을 기본으로 하여 정보 밀도 확보
|
||||||
|
- 대표 글 1개만 살짝 강조하고 나머지는 compact list
|
||||||
|
- 인기 글은 조회수와 카테고리를 함께 표시
|
||||||
|
- 공지는 빨간색 강조보다 얇은 라인과 작은 배지로 처리
|
||||||
|
- 검색 결과 화면에서는 “검색어, 결과 수, 정렬, 목록”만 집중해서 표시
|
||||||
|
|
||||||
|
## 6. 관리자 페이지 설계
|
||||||
|
|
||||||
|
### 접근 제어
|
||||||
|
|
||||||
|
프론트에서는 `useAuthStore`의 `_hasHydrated`와 `role?.includes('ADMIN')`를 기준으로 관리자 버튼과 페이지 접근을 제어한다. 백엔드 admin API가 최종 권한 검사를 담당한다는 전제는 유지한다.
|
||||||
|
|
||||||
|
권장 동작:
|
||||||
|
|
||||||
|
- 비로그인: `/login?redirect=/admin...`로 이동하고 로그인 성공 후 원래 가려던 관리자 경로로 복귀
|
||||||
|
- 일반 회원: “관리자 권한이 필요합니다” 토스트 후 홈으로 이동
|
||||||
|
- 관리자: 관리자 메뉴 노출 및 `/admin` 접근 허용
|
||||||
|
|
||||||
|
### 상단 헤더 변경
|
||||||
|
|
||||||
|
현재 `TopHeader`는 관리자에게 글쓰기 버튼만 보여준다. 리뉴얼 후에는 다음처럼 바꾼다.
|
||||||
|
|
||||||
|
- 비로그인: 로그인, 회원가입
|
||||||
|
- 일반 회원: 로그아웃
|
||||||
|
- 관리자: 관리자 설정, 새 글, 로그아웃
|
||||||
|
|
||||||
|
“관리자 설정”은 명확히 `/admin`으로 이동한다. 일반 회원에게는 렌더링하지 않는다.
|
||||||
|
|
||||||
|
### 사이드바 변경
|
||||||
|
|
||||||
|
사이드바는 공개 탐색용으로만 둔다.
|
||||||
|
|
||||||
|
제거할 기능:
|
||||||
|
|
||||||
|
- 프로필 수정 모달
|
||||||
|
- 카테고리 편집 모드
|
||||||
|
- 카테고리 추가/삭제/이동 버튼
|
||||||
|
- 관리자 전용 hover 편집 버튼
|
||||||
|
|
||||||
|
유지할 기능:
|
||||||
|
|
||||||
|
- 프로필 표시
|
||||||
|
- 검색
|
||||||
|
- 아카이브 링크
|
||||||
|
- 카테고리 트리 탐색
|
||||||
|
- Github/Email 링크
|
||||||
|
|
||||||
|
### 관리자 대시보드
|
||||||
|
|
||||||
|
`/admin` 첫 화면은 운영 현황을 빠르게 보여준다.
|
||||||
|
|
||||||
|
추천 위젯:
|
||||||
|
|
||||||
|
- 총 게시글 수
|
||||||
|
- 카테고리 수
|
||||||
|
- 최근 작성 글 5개
|
||||||
|
- 인기 글 5개
|
||||||
|
- 최근 댓글 5개
|
||||||
|
- 빠른 작업: 새 글 작성, 카테고리 관리, 프로필 수정
|
||||||
|
|
||||||
|
### 게시글 관리
|
||||||
|
|
||||||
|
기존 `/write` 기능을 재사용하되 관리자 흐름으로 재배치한다.
|
||||||
|
|
||||||
|
필요 UI:
|
||||||
|
|
||||||
|
- 검색/필터/정렬 가능한 게시글 테이블
|
||||||
|
- 카테고리 필터
|
||||||
|
- 제목, 카테고리, 날짜, 조회수, 작업 버튼
|
||||||
|
- 페이지 번호 직접 이동
|
||||||
|
- 현재 페이지 선택 및 대량 삭제
|
||||||
|
- 새 글 작성 버튼
|
||||||
|
- 수정 버튼
|
||||||
|
- 삭제 전 확인 모달
|
||||||
|
|
||||||
|
백엔드 유지 조건에서는 공개 `getPosts`를 관리 목록에도 사용한다. 비공개 글 같은 개념이 없다면 별도 admin list API 없이 충분하다.
|
||||||
|
대량 삭제는 별도 bulk API가 없으므로 기존 단건 삭제 API를 선택 항목별로 호출한다.
|
||||||
|
|
||||||
|
### 카테고리 관리
|
||||||
|
|
||||||
|
사이드바에서 하던 기능을 `/admin/categories`로 이동한다.
|
||||||
|
|
||||||
|
필요 UI:
|
||||||
|
|
||||||
|
- 트리 형태 카테고리 목록
|
||||||
|
- 카테고리 생성
|
||||||
|
- 이름 변경
|
||||||
|
- 부모 변경 또는 드래그 이동
|
||||||
|
- 삭제 확인
|
||||||
|
- 변경 후 `['categories']`, `['posts']` 관련 쿼리 무효화
|
||||||
|
|
||||||
|
현재 API는 `updateCategory(id, { name, parentId })` 형식이므로 이름 변경과 이동 모두 가능하다.
|
||||||
|
|
||||||
|
### 프로필 관리
|
||||||
|
|
||||||
|
현재 사이드바 모달 기능을 `/admin/profile`로 이동한다.
|
||||||
|
|
||||||
|
필요 UI:
|
||||||
|
|
||||||
|
- 이름, 소개, 이미지, Github URL, Email 편집
|
||||||
|
- 이미지 업로드
|
||||||
|
- 미리보기
|
||||||
|
- 저장 후 프로필 쿼리 무효화
|
||||||
|
|
||||||
|
### 댓글 관리
|
||||||
|
|
||||||
|
이미 `getAdminComments`와 `deleteAdminComment`가 존재하므로 관리자 화면에 편입할 수 있다.
|
||||||
|
|
||||||
|
필요 UI:
|
||||||
|
|
||||||
|
- 최근 댓글 목록
|
||||||
|
- 작성자, 게시글, 내용, 작성일
|
||||||
|
- 댓글 위치로 이동
|
||||||
|
- 관리자 삭제
|
||||||
|
|
||||||
|
공개 댓글 목록의 일반 사용자/비회원 댓글 삭제 UX는 공개 기능으로 유지한다. 관리자 전용 강제 삭제만 `/admin/comments`로 분리한다.
|
||||||
|
|
||||||
|
단, `getAdminComments` 실제 필드 검증에는 관리자 인증이 필요하다. 2026-05-28 비인증 실서버 요청은 HTTP 403과 빈 본문을 반환했으므로, 프론트에서는 `postSlug`, `postTitle`, `author` 외에 중첩 `post`, `postResponse`, `member`, `guest` 등 가능한 alias를 정규화해 방어적으로 대응한다.
|
||||||
|
|
||||||
|
## 7. 마크다운 렌더러 개선 상세
|
||||||
|
|
||||||
|
### 플러그인 구성
|
||||||
|
|
||||||
|
권장 파이프라인:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
remarkPlugins={[
|
||||||
|
remarkGfm,
|
||||||
|
remarkBreaks,
|
||||||
|
remarkMath,
|
||||||
|
]}
|
||||||
|
rehypePlugins={[
|
||||||
|
rehypeSlug,
|
||||||
|
[rehypeAutolinkHeadings, { behavior: 'append' }],
|
||||||
|
rehypeKatex,
|
||||||
|
[rehypeSanitize, customSchema],
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
보안상 `rehype-sanitize`는 반드시 유지한다. 다만 기본 schema가 너무 좁거나 플러그인 class를 제거할 수 있으므로 `customSchema`를 명시한다.
|
||||||
|
|
||||||
|
### 지원할 문법
|
||||||
|
|
||||||
|
- 제목 anchor 및 TOC 연동
|
||||||
|
- GFM 표
|
||||||
|
- 체크리스트
|
||||||
|
- 취소선
|
||||||
|
- inline code/code block
|
||||||
|
- 수식: inline/block KaTeX
|
||||||
|
- 일반 줄바꿈
|
||||||
|
- 이미지 캡션
|
||||||
|
- 외부 링크 새 탭
|
||||||
|
- 코드 복사
|
||||||
|
|
||||||
|
### 본문 스타일
|
||||||
|
|
||||||
|
본문은 `MarkdownRenderer` 내부에 모든 스타일을 몰아넣기보다, `globals.css` 또는 전용 `markdown.css`에서 `.markdown-content`를 정리하는 편이 낫다.
|
||||||
|
|
||||||
|
권장 스타일:
|
||||||
|
|
||||||
|
- 본문 최대 너비: 720~820px
|
||||||
|
- 한글 행간: `leading-8` 수준
|
||||||
|
- 문단 간격: 과하지 않게 일정하게
|
||||||
|
- heading은 큰 장식보다 명확한 계층 중심
|
||||||
|
- blockquote는 callout처럼 보이되 색상 과다 사용 금지
|
||||||
|
- code block은 가로 스크롤과 긴 줄 처리를 안정화
|
||||||
|
- table은 모바일 overflow 대응
|
||||||
|
|
||||||
|
### TOC 개선
|
||||||
|
|
||||||
|
현재 TOC는 마크다운 원문을 정규식으로 다시 파싱한다. 렌더러와 TOC가 서로 다른 방식으로 heading을 만들 수 있으므로 slug 생성 유틸을 공유한다.
|
||||||
|
|
||||||
|
개선안:
|
||||||
|
|
||||||
|
- `src/utils/markdown.ts` 추가
|
||||||
|
- `extractHeadings(content)` 제공
|
||||||
|
- `MarkdownRenderer`와 `TOC`가 같은 slug 생성 규칙 사용
|
||||||
|
- h1~h3 또는 h2~h3만 표시할지 정책 결정
|
||||||
|
|
||||||
|
## 8. 디자인 시스템 방향
|
||||||
|
|
||||||
|
### 디자인 레퍼런스
|
||||||
|
|
||||||
|
전체 디자인 방향은 Apple의 제품 UI와 웹사이트에서 느껴지는 미니멀하고 정제된 스타일을 참고한다. 단, Apple의 로고, 아이콘, 제품 이미지, 고유한 브랜드 표현을 직접 복제하지 않고, 다음 원칙을 블로그 맥락에 맞게 재해석한다.
|
||||||
|
|
||||||
|
- 넓고 명확한 여백
|
||||||
|
- 얇은 선과 낮은 대비의 경계
|
||||||
|
- 과장되지 않은 입체감
|
||||||
|
- 선명한 타이포그래피 위계
|
||||||
|
- 부드러운 전환과 즉각적인 피드백
|
||||||
|
- 불필요한 장식보다 콘텐츠 자체를 돋보이게 하는 구성
|
||||||
|
- 모바일과 데스크톱 모두에서 손에 익은 네이티브 앱 같은 조작감
|
||||||
|
|
||||||
|
### 전체 톤
|
||||||
|
|
||||||
|
목표는 “Apple-inspired 미니멀 블로그”다. 화면은 깨끗하고 조용하지만 비어 보이지 않아야 하며, 정보는 정돈된 레이어와 타이포그래피로 충분히 채운다.
|
||||||
|
|
||||||
|
키워드:
|
||||||
|
|
||||||
|
- Apple-inspired
|
||||||
|
- 조용한 대비
|
||||||
|
- 선명하고 큰 타이포그래피
|
||||||
|
- 적당한 정보 밀도
|
||||||
|
- 적은 장식
|
||||||
|
- 빠른 탐색
|
||||||
|
- 부드러운 물성
|
||||||
|
- 정제된 인터랙션
|
||||||
|
|
||||||
|
### 색상
|
||||||
|
|
||||||
|
현재 파란색이 대부분의 강조 색으로 쓰인다. Apple 스타일을 참고해 흰색, off-white, neutral gray를 넓게 사용하고, 파란색은 주요 액션과 링크에만 선명하게 사용한다. 전체 화면이 파란색 테마처럼 보이지 않도록 중립색을 기본값으로 둔다.
|
||||||
|
|
||||||
|
권장:
|
||||||
|
|
||||||
|
- 배경: white, near-white, neutral-50 계열
|
||||||
|
- 본문 텍스트: neutral/slate 계열의 높은 가독성 색상
|
||||||
|
- 보조 텍스트: neutral-400~500 계열
|
||||||
|
- 링크/주요 액션: Apple blue에 가까운 명확한 blue 계열
|
||||||
|
- 위험 액션: red 계열을 작고 단호하게 사용
|
||||||
|
- 성공/저장 완료: green 계열을 토스트/상태 표시에 제한적으로 사용
|
||||||
|
- 공지: red 대신 amber 또는 muted red를 작은 배지/라인으로 제한 사용
|
||||||
|
|
||||||
|
### Radius와 Shadow
|
||||||
|
|
||||||
|
읽기 화면은 큰 radius와 강한 shadow를 줄인다. Apple UI처럼 표면이 떠 있는 느낌은 주되, 그림자가 먼저 보이지 않게 한다. 깊이감은 `border`, 배경 레이어, 아주 약한 shadow, blur가 있는 overlay로 만든다.
|
||||||
|
|
||||||
|
권장:
|
||||||
|
|
||||||
|
- 일반 버튼/입력: `rounded-lg` 또는 pill 형태
|
||||||
|
- 목록 아이템: `rounded-md` 또는 border-only
|
||||||
|
- 모달/드롭다운: `rounded-xl`
|
||||||
|
- 반복 카드: shadow보다 border와 hover background 중심
|
||||||
|
- floating panel: 얇은 border + 약한 shadow + 흰색/반투명 배경
|
||||||
|
- destructive dialog: 시각적으로 단호하되 과한 빨간 배경은 피함
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
Apple 스타일의 핵심은 장식보다 타이포그래피의 밀도와 리듬이다. 블로그에서는 글 제목, 섹션 제목, 본문, 보조 정보의 위계를 선명히 나누고, 한글 가독성을 우선한다.
|
||||||
|
|
||||||
|
권장:
|
||||||
|
|
||||||
|
- 큰 제목은 과감하게 쓰되 한 화면에 너무 많은 hero 텍스트를 두지 않음
|
||||||
|
- 본문은 16~18px, 넉넉한 행간, 적절한 문단 간격 유지
|
||||||
|
- 날짜/조회수/카테고리 같은 메타 정보는 작고 차분하게 처리
|
||||||
|
- 버튼 라벨은 짧게 유지하고 아이콘과 함께 사용
|
||||||
|
- letter spacing은 기본값에 가깝게 두고, 한글에 과한 자간을 적용하지 않음
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
인터랙션은 빠르고 조용해야 한다. Apple UI처럼 화면 전환, hover, active, modal open/close에 미세한 움직임을 주되, 블로그 읽기를 방해하는 큰 애니메이션은 피한다.
|
||||||
|
|
||||||
|
권장:
|
||||||
|
|
||||||
|
- hover: 배경색 변화, 살짝 올라감, 아주 약한 shadow
|
||||||
|
- active: 짧은 scale down 또는 색상 변화
|
||||||
|
- modal/sheet: fade + subtle scale/slide
|
||||||
|
- sidebar/mobile menu: 부드러운 slide
|
||||||
|
- skeleton/loading: 과하게 번쩍이지 않는 pulse
|
||||||
|
- motion duration: 150~250ms 중심
|
||||||
|
|
||||||
|
### 레이아웃
|
||||||
|
|
||||||
|
데스크톱:
|
||||||
|
|
||||||
|
- 좌측 사이드바 유지
|
||||||
|
- 본문은 route별 최대 너비 분리
|
||||||
|
- 홈은 2-column 대시보드
|
||||||
|
- 글 상세는 본문 + 우측 TOC
|
||||||
|
- 관리자 화면은 macOS 설정 앱처럼 좌측 섹션 내비게이션 + 우측 상세 패널 구조 우선 검토
|
||||||
|
|
||||||
|
모바일:
|
||||||
|
|
||||||
|
- 사이드바 토글을 유지하되 탐색 경험 단순화
|
||||||
|
- 관리자 페이지는 탭/섹션을 세로 스택으로
|
||||||
|
- 글 목록은 리스트형 우선
|
||||||
|
- 버튼 라벨이 줄바꿈되어도 깨지지 않게 고정 폭/반응형 처리
|
||||||
|
- 주요 액션은 터치 타겟을 충분히 확보하고, 하단 sheet/compact menu 패턴을 적극 사용
|
||||||
|
|
||||||
|
## 9. 코드 구조 리팩터링 제안
|
||||||
|
|
||||||
|
### 새 디렉터리
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/components/admin/
|
||||||
|
src/components/common/
|
||||||
|
src/components/markdown/
|
||||||
|
src/components/post/list/
|
||||||
|
src/lib/
|
||||||
|
src/utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 분리 대상
|
||||||
|
|
||||||
|
- `Sidebar.tsx`: 공개 사이드바와 관리자 기능 분리
|
||||||
|
- `TopHeader.tsx`: auth action과 admin entry 분리
|
||||||
|
- `write/page.tsx`: 에디터 로직, 임시저장, 이미지 업로드, 카테고리 선택을 컴포넌트로 분리
|
||||||
|
- `MarkdownRenderer.tsx`: 렌더러, 코드 블록, 이미지, 링크, sanitize schema 분리
|
||||||
|
- `PostCard`/`PostListItem`: 목록 UI 변형을 통합 또는 명확히 분리
|
||||||
|
|
||||||
|
### 타입 보강
|
||||||
|
|
||||||
|
추가 권장 타입:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PageMeta {
|
||||||
|
totalPages: number;
|
||||||
|
totalElements: number;
|
||||||
|
number?: number;
|
||||||
|
last?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostSaveRequest {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
categoryId: number;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminComment {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
postSlug?: string;
|
||||||
|
postTitle?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 단계별 실행 계획
|
||||||
|
|
||||||
|
### Phase 0. 기준선 정리
|
||||||
|
|
||||||
|
- `npm ci`로 의존성 설치
|
||||||
|
- `npm run lint`, `npm run build` 현재 상태 확인
|
||||||
|
- `src/app/posts/[slug]/loding.tsx` 파일명 오류 확인 후 수정 계획 반영
|
||||||
|
- `ReactQueryDevtools` 개발 환경 조건부 렌더링 계획 반영
|
||||||
|
- 현재 화면 스크린샷 기록
|
||||||
|
|
||||||
|
### Phase 1. 관리자 진입점과 라우트 신설
|
||||||
|
|
||||||
|
- `/admin` 라우트 추가
|
||||||
|
- 관리자 가드 컴포넌트 추가
|
||||||
|
- `TopHeader`에 관리자 설정 버튼 추가
|
||||||
|
- 일반 회원/비로그인에서는 관리자 버튼 비노출
|
||||||
|
- `robots.ts`의 `/admin` 차단 유지
|
||||||
|
|
||||||
|
### Phase 2. 공개 사이드바 정리
|
||||||
|
|
||||||
|
- `Sidebar`에서 관리자 편집 상태 제거
|
||||||
|
- 카테고리 편집/프로필 수정 로직을 관리자 컴포넌트로 이동
|
||||||
|
- 사이드바는 탐색, 검색, 프로필 표시만 담당
|
||||||
|
- 모바일 사이드바 열림 기본값과 overlay UX 점검
|
||||||
|
|
||||||
|
### Phase 3. 관리자 기능 구현
|
||||||
|
|
||||||
|
- 관리자 대시보드 카드/리스트 구현
|
||||||
|
- 게시글 관리 목록 구현
|
||||||
|
- 기존 글쓰기 화면을 관리자 작성/수정 흐름으로 연결
|
||||||
|
- 카테고리 관리 화면 구현
|
||||||
|
- 프로필 관리 화면 구현
|
||||||
|
- 댓글 관리 화면은 응답 타입 확인 후 구현
|
||||||
|
|
||||||
|
### Phase 4. 홈 대시보드 리뉴얼
|
||||||
|
|
||||||
|
- 최신 글 중심 레이아웃으로 개편
|
||||||
|
- 공지/인기글/카테고리 요약 패널 추가
|
||||||
|
- 검색 결과 모드 재정리
|
||||||
|
- 빈 상태, 로딩 상태, 오류 상태 디자인 통일
|
||||||
|
|
||||||
|
### Phase 5. 마크다운 렌더러 개선
|
||||||
|
|
||||||
|
- markdown 관련 컴포넌트와 유틸 분리
|
||||||
|
- `remark-breaks`, `remark-math`, `rehype-katex`, `rehype-autolink-headings` 적용
|
||||||
|
- sanitize schema 확장
|
||||||
|
- 본문 CSS 정리
|
||||||
|
- TOC slug 공유
|
||||||
|
- 코드/표/이미지/수식/체크리스트 테스트 글로 검증
|
||||||
|
|
||||||
|
### Phase 6. 디자인 폴리싱
|
||||||
|
|
||||||
|
- 버튼, 입력, 모달, 리스트, 배지 공통 스타일 정리
|
||||||
|
- 카드 그림자와 radius 정리
|
||||||
|
- 모바일 레이아웃 검증
|
||||||
|
- 한국어 문구 톤 통일
|
||||||
|
- 접근성: label, aria-label, focus ring, keyboard navigation 점검
|
||||||
|
|
||||||
|
### Phase 7. 검증과 배포 전 점검
|
||||||
|
|
||||||
|
- `npm run lint`
|
||||||
|
- `npm run build`
|
||||||
|
- 로컬 dev 서버에서 핵심 플로우 확인
|
||||||
|
- 관리자 계정/일반 계정/비로그인 상태별 UI 확인
|
||||||
|
- 백엔드 미연결 상태에서 graceful loading/error 확인
|
||||||
|
- 배포 환경 `NEXT_PUBLIC_API_URL` 확인
|
||||||
|
|
||||||
|
## 11. 검증 체크리스트
|
||||||
|
|
||||||
|
### 인증/권한
|
||||||
|
|
||||||
|
- 비로그인 사용자는 관리자 버튼을 볼 수 없다.
|
||||||
|
- 일반 회원은 관리자 버튼을 볼 수 없다.
|
||||||
|
- 관리자만 `/admin`에 접근할 수 있다.
|
||||||
|
- 관리자 API 실패 시 토큰이나 민감 정보가 노출되지 않는다.
|
||||||
|
|
||||||
|
### 공개 화면
|
||||||
|
|
||||||
|
- 홈이 비어 보이지 않고 최신 글 목록이 충분히 보인다.
|
||||||
|
- 검색 결과가 명확하게 분리되어 보인다.
|
||||||
|
- 카테고리 페이지의 grid/list 전환이 유지된다.
|
||||||
|
- 글 상세의 본문 너비와 TOC 위치가 안정적이다.
|
||||||
|
- 모바일에서 사이드바와 상단 버튼이 겹치지 않는다.
|
||||||
|
|
||||||
|
### 관리자 화면
|
||||||
|
|
||||||
|
- 게시글 작성/수정/삭제 후 관련 쿼리가 갱신된다.
|
||||||
|
- 카테고리 생성/수정/삭제/이동 후 사이드바가 갱신된다.
|
||||||
|
- 프로필 저장 후 공개 사이드바가 갱신된다.
|
||||||
|
- 삭제 액션은 확인 모달을 거친다.
|
||||||
|
- 로딩/비활성/오류/성공 상태가 모두 보인다.
|
||||||
|
|
||||||
|
### 마크다운
|
||||||
|
|
||||||
|
- heading anchor와 TOC가 같은 위치로 이동한다.
|
||||||
|
- 코드 블록 언어 표시와 복사가 동작한다.
|
||||||
|
- 표가 모바일에서 깨지지 않는다.
|
||||||
|
- 이미지가 본문 폭을 넘지 않는다.
|
||||||
|
- 수식이 렌더링된다.
|
||||||
|
- 악성 HTML/script는 sanitize된다.
|
||||||
|
|
||||||
|
## 12. 우선순위
|
||||||
|
|
||||||
|
가장 먼저 할 일:
|
||||||
|
|
||||||
|
1. `/admin` 라우트와 관리자 버튼 추가
|
||||||
|
2. 사이드바 관리자 기능 제거 및 관리자 페이지로 이동
|
||||||
|
3. 홈 대시보드 정보 밀도 개선
|
||||||
|
4. 마크다운 렌더러 개선
|
||||||
|
5. 디자인 토큰/공통 컴포넌트 정리
|
||||||
|
|
||||||
|
리뉴얼 체감이 가장 큰 조합은 “관리자 기능 분리 + 홈 대시보드 개편 + 본문 타이포그래피 개선”이다. 이 세 가지를 먼저 끝내면 블로그가 보는 화면과 관리하는 화면 모두에서 확실히 달라진다.
|
||||||
|
|
||||||
|
## 13. 확인된 현재 검증 상태
|
||||||
|
|
||||||
|
현재 로컬에 `node_modules`가 없어 `npm run lint`는 실행되지 않았다. 오류는 다음과 같다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
'eslint' is not recognized as an internal or external command
|
||||||
|
```
|
||||||
|
|
||||||
|
리뉴얼 작업을 시작하기 전 `npm ci`로 의존성을 설치한 뒤 lint/build 기준선을 먼저 잡는 것이 좋다.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse, Comment, CommentSaveRequest, CommentDeleteRequest } from '@/types';
|
import {
|
||||||
|
AdminComment,
|
||||||
|
AdminCommentListResponse,
|
||||||
|
ApiResponse,
|
||||||
|
Comment,
|
||||||
|
CommentDeleteRequest,
|
||||||
|
CommentSaveRequest,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
type RawCommentAuthor = {
|
||||||
|
nickname?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawCommentPost = {
|
||||||
|
slug?: string;
|
||||||
|
title?: string;
|
||||||
|
postSlug?: string;
|
||||||
|
postTitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawAdminComment = AdminComment & {
|
||||||
|
nickname?: string;
|
||||||
|
authorName?: string;
|
||||||
|
writer?: string;
|
||||||
|
member?: RawCommentAuthor | null;
|
||||||
|
guest?: RawCommentAuthor | null;
|
||||||
|
post?: RawCommentPost | null;
|
||||||
|
postResponse?: RawCommentPost | null;
|
||||||
|
postName?: string;
|
||||||
|
articleSlug?: string;
|
||||||
|
articleTitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawAdminCommentListResponse = Omit<AdminCommentListResponse, 'content'> & {
|
||||||
|
content: RawAdminComment[];
|
||||||
|
};
|
||||||
|
|
||||||
// 1. 댓글 목록 조회
|
// 1. 댓글 목록 조회
|
||||||
export const getComments = async (postSlug: string) => {
|
export const getComments = async (postSlug: string) => {
|
||||||
@@ -24,12 +60,62 @@ export const deleteComment = async (id: number, password?: string) => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeAdminComment = (comment: RawAdminComment): AdminComment => ({
|
||||||
|
...comment,
|
||||||
|
author: comment.author
|
||||||
|
?? comment.memberNickname
|
||||||
|
?? comment.guestNickname
|
||||||
|
?? comment.nickname
|
||||||
|
?? comment.authorName
|
||||||
|
?? comment.writer
|
||||||
|
?? comment.member?.nickname
|
||||||
|
?? comment.member?.name
|
||||||
|
?? comment.guest?.nickname
|
||||||
|
?? comment.guest?.name,
|
||||||
|
postSlug: comment.postSlug
|
||||||
|
?? comment.post?.slug
|
||||||
|
?? comment.post?.postSlug
|
||||||
|
?? comment.postResponse?.slug
|
||||||
|
?? comment.postResponse?.postSlug
|
||||||
|
?? comment.articleSlug,
|
||||||
|
postTitle: comment.postTitle
|
||||||
|
?? comment.post?.title
|
||||||
|
?? comment.post?.postTitle
|
||||||
|
?? comment.postResponse?.title
|
||||||
|
?? comment.postResponse?.postTitle
|
||||||
|
?? comment.postName
|
||||||
|
?? comment.articleTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeAdminComments = (
|
||||||
|
data: RawAdminCommentListResponse | RawAdminComment[],
|
||||||
|
): AdminCommentListResponse => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return {
|
||||||
|
content: data.map(normalizeAdminComment),
|
||||||
|
totalElements: data.length,
|
||||||
|
totalPages: 1,
|
||||||
|
number: 0,
|
||||||
|
last: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
content: data.content.map(normalizeAdminComment),
|
||||||
|
totalElements: data.page?.totalElements ?? data.totalElements,
|
||||||
|
totalPages: data.page?.totalPages ?? data.totalPages,
|
||||||
|
number: data.page?.number ?? data.number,
|
||||||
|
last: data.page?.last ?? data.last,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 4. 관리자 댓글 목록 조회 (대시보드용)
|
// 4. 관리자 댓글 목록 조회 (대시보드용)
|
||||||
export const getAdminComments = async (page = 0, size = 20) => {
|
export const getAdminComments = async (page = 0, size = 20) => {
|
||||||
const response = await http.get<ApiResponse<any>>('/api/admin/comments', {
|
const response = await http.get<ApiResponse<RawAdminCommentListResponse | RawAdminComment[]>>('/api/admin/comments', {
|
||||||
params: { page, size },
|
params: { page, size },
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return normalizeAdminComments(response.data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. 관리자 댓글 강제 삭제
|
// 5. 관리자 댓글 강제 삭제
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse, PostListResponse, Post } from '@/types';
|
import { ApiResponse, PostListResponse, Post, PostSaveRequest } from '@/types';
|
||||||
|
|
||||||
// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원)
|
// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원)
|
||||||
export const getPosts = async (params?: {
|
export const getPosts = async (params?: {
|
||||||
@@ -37,13 +37,13 @@ export const getPost = async (slug: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 4. 게시글 작성
|
// 4. 게시글 작성
|
||||||
export const createPost = async (data: any) => {
|
export const createPost = async (data: PostSaveRequest) => {
|
||||||
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. 게시글 수정
|
// 5. 게시글 수정
|
||||||
export const updatePost = async (id: number, data: any) => {
|
export const updatePost = async (id: number, data: PostSaveRequest) => {
|
||||||
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
5
src/app/admin/categories/page.tsx
Normal file
5
src/app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
|
||||||
|
|
||||||
|
export default function AdminCategoriesPage() {
|
||||||
|
return <AdminCategoryPanel />;
|
||||||
|
}
|
||||||
5
src/app/admin/comments/page.tsx
Normal file
5
src/app/admin/comments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminCommentsPanel from '@/components/admin/AdminCommentsPanel';
|
||||||
|
|
||||||
|
export default function AdminCommentsPage() {
|
||||||
|
return <AdminCommentsPanel />;
|
||||||
|
}
|
||||||
5
src/app/admin/layout.tsx
Normal file
5
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminRouteShell from '@/components/admin/AdminRouteShell';
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AdminRouteShell>{children}</AdminRouteShell>;
|
||||||
|
}
|
||||||
300
src/app/admin/page.tsx
Normal file
300
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
FolderTree,
|
||||||
|
Loader2,
|
||||||
|
MessageSquareText,
|
||||||
|
PenLine,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getCategories } from '@/api/category';
|
||||||
|
import { getAdminComments } from '@/api/comments';
|
||||||
|
import { getPosts } from '@/api/posts';
|
||||||
|
import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types';
|
||||||
|
|
||||||
|
const countCategories = (categories: Category[] = []): number => {
|
||||||
|
return categories.reduce((count, category) => {
|
||||||
|
return count + 1 + countCategories(category.children || []);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyPageMeta: PageMeta = {
|
||||||
|
totalPages: 0,
|
||||||
|
totalElements: 0,
|
||||||
|
number: 0,
|
||||||
|
last: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value?: string, withTime = false) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
|
||||||
|
if (!data) return emptyPageMeta;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
|
||||||
|
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
|
||||||
|
number: data.page?.number ?? data.number ?? 0,
|
||||||
|
last: data.page?.last ?? data.last,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthorName = (comment: AdminComment) => {
|
||||||
|
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
|
||||||
|
};
|
||||||
|
|
||||||
|
function PostRow({ post }: { post: Post }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/posts/${post.slug}`}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-900">{post.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1 text-xs text-gray-400">
|
||||||
|
<Eye size={14} />
|
||||||
|
<span>{post.viewCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentRow({ comment }: { comment: AdminComment }) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-gray-400">
|
||||||
|
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="shrink-0 text-gray-300" size={15} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (comment.postSlug) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/posts/${comment.postSlug}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||||
|
queryKey: ['posts', 'admin', 'latest'],
|
||||||
|
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||||
|
queryKey: ['posts', 'admin', 'popular'],
|
||||||
|
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: categories, isLoading: isCategoriesLoading } = useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: getCategories,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: commentsData, isLoading: isCommentsLoading } = useQuery({
|
||||||
|
queryKey: ['comments', 'admin', 'recent'],
|
||||||
|
queryFn: () => getAdminComments(0, 5),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
|
||||||
|
const latestPosts = latestData?.content ?? [];
|
||||||
|
const popularPosts = popularData?.content ?? [];
|
||||||
|
const recentComments = commentsData?.content ?? [];
|
||||||
|
const totalPosts = latestData?.page?.totalElements ?? latestData?.totalElements ?? 0;
|
||||||
|
const totalCategories = countCategories(categories);
|
||||||
|
const totalComments = getCommentListMeta(commentsData).totalElements;
|
||||||
|
const lastUpdatedAt = latestPosts[0]?.createdAt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex flex-col justify-between gap-5 border-b border-gray-200 pb-8 md:flex-row md:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||||
|
<Settings size={14} />
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
|
||||||
|
관리자 대시보드
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500">
|
||||||
|
공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
href="/admin/posts"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<FileText size={17} />
|
||||||
|
게시글 관리
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/posts/new"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<PenLine size={17} />
|
||||||
|
새 글 작성
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="flex items-center justify-between text-gray-500">
|
||||||
|
<span className="text-sm font-medium">총 게시글</span>
|
||||||
|
<FileText size={18} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-3xl font-bold text-gray-950">{totalPosts.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="flex items-center justify-between text-gray-500">
|
||||||
|
<span className="text-sm font-medium">카테고리</span>
|
||||||
|
<FolderTree size={18} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="flex items-center justify-between text-gray-500">
|
||||||
|
<span className="text-sm font-medium">총 댓글</span>
|
||||||
|
<MessageSquareText size={18} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-3xl font-bold text-gray-950">{totalComments.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="flex items-center justify-between text-gray-500">
|
||||||
|
<span className="text-sm font-medium">최근 업데이트</span>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-2xl font-bold text-gray-950">{formatDate(lastUpdatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-950">최근 글</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">발행 흐름을 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/posts"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
관리
|
||||||
|
<ArrowRight size={15} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : latestPosts.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{latestPosts.map((post) => (
|
||||||
|
<PostRow key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||||
|
아직 작성된 글이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<h2 className="text-lg font-bold text-gray-950">인기 글</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">조회수가 높은 글을 확인합니다.</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : popularPosts.length > 0 ? (
|
||||||
|
<div className="mt-4 divide-y divide-gray-100">
|
||||||
|
{popularPosts.map((post) => (
|
||||||
|
<PostRow key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||||
|
아직 집계된 인기 글이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-950">최근 댓글 5개</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">새 반응을 대시보드에서 바로 봅니다.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/comments"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
관리
|
||||||
|
<ArrowRight size={15} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCommentsLoading ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : recentComments.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{recentComments.map((comment) => (
|
||||||
|
<CommentRow key={comment.id} comment={comment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||||
|
아직 작성된 댓글이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||||
|
|
||||||
|
interface EditAdminPostPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditAdminPostPage({ params }: EditAdminPostPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
return <AdminPostEditor editSlug={slug} />;
|
||||||
|
}
|
||||||
5
src/app/admin/posts/new/page.tsx
Normal file
5
src/app/admin/posts/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||||
|
|
||||||
|
export default function NewAdminPostPage() {
|
||||||
|
return <AdminPostEditor />;
|
||||||
|
}
|
||||||
5
src/app/admin/posts/page.tsx
Normal file
5
src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminPostsPanel from '@/components/admin/AdminPostsPanel';
|
||||||
|
|
||||||
|
export default function AdminPostsPage() {
|
||||||
|
return <AdminPostsPanel />;
|
||||||
|
}
|
||||||
5
src/app/admin/profile/page.tsx
Normal file
5
src/app/admin/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
|
||||||
|
|
||||||
|
export default function AdminProfilePage() {
|
||||||
|
return <AdminProfilePanel />;
|
||||||
|
}
|
||||||
@@ -1,83 +1,101 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { login } from '@/api/auth';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { login } from '@/api/auth';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
export default function LoginPage() {
|
const getSafeRedirectPath = (value: string | null) => {
|
||||||
|
if (!value) return '/';
|
||||||
|
if (!value.startsWith('/') || value.startsWith('//')) return '/';
|
||||||
|
if (value.startsWith('/login')) return '/';
|
||||||
|
if (value.includes('://')) return '/';
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown) => {
|
||||||
|
if (typeof error === 'object' && error !== null && 'response' in error) {
|
||||||
|
const response = (error as { response?: { data?: { message?: string } } }).response;
|
||||||
|
if (response?.data?.message) return response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
|
||||||
|
return '로그인 중 오류가 발생했습니다.';
|
||||||
|
};
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { login: setLoginState } = useAuthStore();
|
const { login: setLoginState } = useAuthStore();
|
||||||
|
const redirectPath = getSafeRedirectPath(searchParams.get('redirect'));
|
||||||
|
|
||||||
const [formData, setFormData] = useState({ email: '', password: '' });
|
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await login(formData);
|
const response = await login(formData);
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
if (response.code === 'SUCCESS' && response.data) {
|
||||||
// ✨ 수정됨: AccessToken과 RefreshToken 모두 저장
|
setLoginState(response.data.accessToken, response.data.refreshToken);
|
||||||
setLoginState(res.data.accessToken, res.data.refreshToken);
|
router.push(redirectPath);
|
||||||
|
|
||||||
// 로그인 성공 후 메인으로 이동
|
|
||||||
router.push('/');
|
|
||||||
} else {
|
} else {
|
||||||
setError(res.message || '로그인에 실패했습니다.');
|
setError(response.message || '로그인에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (loginError) {
|
||||||
setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.');
|
setError(getErrorMessage(loginError));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 🎨 배경색 수정: bg-gray-50 -> bg-white
|
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4">
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-white">
|
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-gray-100 bg-white p-8 shadow-xl">
|
||||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8 border border-gray-100">
|
<div className="mb-8 text-center">
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
||||||
<p className="text-gray-500 mt-2">블로그에 오신 것을 환영합니다.</p>
|
<p className="mt-2 text-gray-500">블로그에 오신 것을 환영합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
<label className="mb-1 block text-sm font-medium text-gray-700">이메일</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(event) => setFormData({ ...formData, email: event.target.value })}
|
||||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
className="w-full rounded-lg border border-gray-200 px-4 py-2 outline-none transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="example@email.com"
|
placeholder="example@email.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
<label className="mb-1 block text-sm font-medium text-gray-700">비밀번호</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(event) => setFormData({ ...formData, password: event.target.value })}
|
||||||
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
className="w-full rounded-lg border border-gray-200 px-4 py-2 outline-none transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
{error && <p className="text-center text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-blue-400 flex justify-center items-center gap-2"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-3 font-bold text-white transition-colors hover:bg-blue-700 disabled:bg-blue-400"
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="animate-spin" size={20} />}
|
{loading && <Loader2 className="animate-spin" size={20} />}
|
||||||
로그인
|
로그인
|
||||||
@@ -86,7 +104,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-gray-500">
|
<div className="mt-6 text-center text-sm text-gray-500">
|
||||||
계정이 없으신가요?{' '}
|
계정이 없으신가요?{' '}
|
||||||
<Link href="/signup" className="text-blue-600 font-medium hover:underline">
|
<Link href="/signup" className="font-medium text-blue-600 hover:underline">
|
||||||
회원가입
|
회원가입
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,3 +112,17 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
)}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,524 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
import { Suspense, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { createPost, updatePost, getPost } from '@/api/posts';
|
|
||||||
import { getCategories } from '@/api/category';
|
|
||||||
import { uploadImage } from '@/api/image';
|
|
||||||
import { useAuthStore } from '@/store/authStore';
|
|
||||||
import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud, FileText, Trash2, X, Clock } from 'lucide-react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const MDEditor = dynamic(
|
function LegacyWriteRedirect() {
|
||||||
() => import('@uiw/react-md-editor').then((mod) => mod.default),
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 🛠️ 임시저장 데이터 타입 정의
|
|
||||||
interface DraftPost {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
savedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🛠️ 1. 토큰 만료 체크 유틸리티
|
|
||||||
function isTokenExpired(token: string) {
|
|
||||||
try {
|
|
||||||
const base64Url = token.split('.')[1];
|
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const jsonPayload = decodeURIComponent(
|
|
||||||
atob(base64)
|
|
||||||
.split('')
|
|
||||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
|
||||||
.join('')
|
|
||||||
);
|
|
||||||
const { exp } = JSON.parse(jsonPayload);
|
|
||||||
return Date.now() / 1000 >= exp - 30;
|
|
||||||
} catch (e) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function WritePageContent() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore();
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const editSlug = searchParams.get('slug');
|
const editSlug = searchParams.get('slug');
|
||||||
const isEditMode = !!editSlug;
|
router.replace(editSlug ? `/admin/posts/${editSlug}/edit` : '/admin/posts/new');
|
||||||
|
}, [router, searchParams]);
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
|
|
||||||
toast.error('관리자 권한이 필요합니다.');
|
|
||||||
router.push('/');
|
|
||||||
}
|
|
||||||
}, [role, _hasHydrated, router]);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 로컬스토리지에서 임시저장 목록 불러오기
|
|
||||||
useEffect(() => {
|
|
||||||
const savedDrafts = localStorage.getItem('temp_drafts');
|
|
||||||
if (savedDrafts) {
|
|
||||||
try {
|
|
||||||
setDrafts(JSON.parse(savedDrafts));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse drafts', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: categories } = useQuery({
|
|
||||||
queryKey: ['categories'],
|
|
||||||
queryFn: getCategories,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: existingPost, isLoading: isLoadingPost } = useQuery({
|
|
||||||
queryKey: ['post', editSlug],
|
|
||||||
queryFn: () => getPost(editSlug!),
|
|
||||||
enabled: isEditMode,
|
|
||||||
staleTime: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
refetchOnMount: 'always'
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (existingPost) {
|
|
||||||
setTitle(existingPost.title || '');
|
|
||||||
setContent(existingPost.content || '');
|
|
||||||
setTags(existingPost.tags ? existingPost.tags.join(', ') : '');
|
|
||||||
|
|
||||||
if (categories && existingPost.categoryName) {
|
|
||||||
const found = findCategoryByName(categories, existingPost.categoryName);
|
|
||||||
if (found) setCategoryId(found.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [existingPost, categories]);
|
|
||||||
|
|
||||||
const findCategoryByName = (cats: any[], name: string): any => {
|
|
||||||
for (const cat of cats) {
|
|
||||||
if (cat.name === name) return cat;
|
|
||||||
if (cat.children) {
|
|
||||||
const found = findCategoryByName(cat.children, name);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 💾 임시저장 기능
|
|
||||||
const handleTempSave = () => {
|
|
||||||
if (!title.trim() && !content.trim()) {
|
|
||||||
toast.error('제목이나 내용을 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drafts.length >= 10) {
|
|
||||||
toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.');
|
|
||||||
setShowDraftList(true); // 목록 열어주기
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newDraft: DraftPost = {
|
|
||||||
id: Date.now(),
|
|
||||||
title: title || '(제목 없음)',
|
|
||||||
content: content,
|
|
||||||
savedAt: new Date().toLocaleString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const newDrafts = [newDraft, ...drafts];
|
|
||||||
setDrafts(newDrafts);
|
|
||||||
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
|
|
||||||
toast.success('임시저장 되었습니다!');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 💾 임시저장 불러오기
|
|
||||||
const handleLoadDraft = (draft: DraftPost) => {
|
|
||||||
if (confirm('현재 작성 중인 내용이 사라집니다.\n선택한 임시저장 글을 불러오시겠습니까?')) {
|
|
||||||
setTitle(draft.title === '(제목 없음)' ? '' : draft.title);
|
|
||||||
setContent(draft.content);
|
|
||||||
setShowDraftList(false);
|
|
||||||
toast.success('불러오기 완료');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 💾 임시저장 삭제
|
|
||||||
const handleDeleteDraft = (id: number) => {
|
|
||||||
const newDrafts = drafts.filter(d => d.id !== id);
|
|
||||||
setDrafts(newDrafts);
|
|
||||||
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
|
|
||||||
toast.success('삭제되었습니다.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
|
|
||||||
onSuccess: async (response: any) => {
|
|
||||||
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(isEditMode ? `/posts/${newSlug}` : '/');
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ensureAuthToken = async (): Promise<boolean> => {
|
|
||||||
if (!accessToken || !refreshToken) {
|
|
||||||
toast.error('로그인이 필요합니다.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isTokenExpired(accessToken)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔄 Access token expired during write. Refreshing...');
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
|
||||||
|
|
||||||
const { data } = await axios.post(
|
|
||||||
`${BASE_URL}/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);
|
|
||||||
console.log('✅ Token refreshed successfully before save.');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to refresh token before save:', error);
|
|
||||||
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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(t => t.trim()).filter(t => t),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureAuthToken();
|
|
||||||
|
|
||||||
const res = await uploadImage(file);
|
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
|
||||||
const imageUrl = res.data;
|
|
||||||
const markdownImage = ``;
|
|
||||||
setContent((prev) => prev + '\n' + markdownImage);
|
|
||||||
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('이미지 업로드 실패', { id: uploadToast });
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPaste = async (event: any) => {
|
|
||||||
const items = event.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.type.indexOf('image') !== -1) {
|
|
||||||
event.preventDefault();
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureAuthToken();
|
|
||||||
|
|
||||||
const res = await uploadImage(file);
|
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
|
||||||
const imageUrl = res.data;
|
|
||||||
const markdownImage = ``;
|
|
||||||
setContent((prev) => prev + '\n' + markdownImage);
|
|
||||||
toast.success('이미지 붙여넣기 완료!', { id: uploadToast });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('이미지 업로드 실패', { id: uploadToast });
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditMode && isLoadingPost) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-screen">
|
|
||||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCategoryOptions = (cats: any[], depth = 0) => {
|
|
||||||
return cats.map((cat) => (
|
|
||||||
<div key={cat.id}>
|
|
||||||
<label className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer rounded transition-colors">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="category"
|
|
||||||
value={cat.id}
|
|
||||||
checked={categoryId === cat.id}
|
|
||||||
onChange={(e) => setCategoryId(Number(e.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 && '- '} {cat.name}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{cat.children && renderCategoryOptions(cat.children, depth + 1)}
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}>
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
{/* 상단 헤더 영역 */}
|
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors">
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 💾 임시저장 버튼 그룹 */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center bg-white border border-gray-300 rounded-lg shadow-sm">
|
|
||||||
<button
|
|
||||||
onClick={handleTempSave}
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2 border-r border-gray-300 rounded-l-lg transition-colors"
|
|
||||||
title="현재 내용 임시저장"
|
|
||||||
>
|
|
||||||
<FileText size={16} className="text-gray-500" />
|
|
||||||
<span className="hidden sm:inline">임시저장</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDraftList(!showDraftList)}
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-r-lg transition-colors flex items-center gap-1"
|
|
||||||
title="임시저장 목록 보기"
|
|
||||||
>
|
|
||||||
<span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded text-xs font-bold">
|
|
||||||
{drafts.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 💾 임시저장 목록 팝업 */}
|
|
||||||
{showDraftList && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-10"
|
|
||||||
onClick={() => setShowDraftList(false)}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-20 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
|
||||||
<h3 className="font-bold text-gray-700 text-sm">임시저장 목록 ({drafts.length}/10)</h3>
|
|
||||||
<button onClick={() => setShowDraftList(false)}><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-gray-400 text-sm">
|
|
||||||
저장된 글이 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-gray-100">
|
|
||||||
{drafts.map((draft) => (
|
|
||||||
<li key={draft.id} className="p-3 hover:bg-blue-50 transition-colors group">
|
|
||||||
<div className="flex justify-between items-start gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleLoadDraft(draft)}
|
|
||||||
className="flex-1 text-left"
|
|
||||||
>
|
|
||||||
<p className="font-medium text-gray-800 text-sm line-clamp-1 mb-1 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
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteDraft(draft.id); }}
|
|
||||||
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || isUploading}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-400 shadow-md hover:shadow-lg transform active:scale-95 duration-200"
|
|
||||||
>
|
|
||||||
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
|
||||||
{isEditMode ? '수정하기' : '작성하기'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
||||||
<div className="lg:col-span-3 space-y-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="제목을 입력하세요"
|
|
||||||
className="w-full text-3xl font-bold placeholder:text-gray-300 border-none outline-none py-2 bg-transparent"
|
|
||||||
/>
|
|
||||||
<div data-color-mode="light">
|
|
||||||
<MDEditor
|
|
||||||
value={content}
|
|
||||||
onChange={(val) => setContent(val || '')}
|
|
||||||
height={600}
|
|
||||||
preview="edit"
|
|
||||||
className="border border-gray-200 rounded-lg shadow-sm !font-sans"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-6">
|
|
||||||
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
|
||||||
<Folder size={18} /> 카테고리
|
|
||||||
</h3>
|
|
||||||
<div className="max-h-60 overflow-y-auto space-y-1 text-sm border-t border-gray-100 pt-2 scrollbar-thin scrollbar-thumb-gray-200">
|
|
||||||
{categories ? renderCategoryOptions(categories) : <p className="text-gray-400 text-sm">로딩 중...</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🛠️ [Legacy] 태그 입력 UI 비활성화
|
|
||||||
사용자 요청으로 UI에서 숨김 처리 (데이터 구조는 유지)
|
|
||||||
*/}
|
|
||||||
{/*
|
|
||||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
|
|
||||||
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
|
||||||
🏷️ 태그
|
|
||||||
</h3>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tags}
|
|
||||||
onChange={(e) => setTags(e.target.value)}
|
|
||||||
placeholder="태그 입력 (콤마 , 로 구분)"
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 mt-2">예: React, Next.js, 튜토리얼</p>
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
|
|
||||||
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
|
||||||
<ImageIcon size={18} /> 이미지 업로드
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
|
|
||||||
에디터에 이미지를 <br/><strong>복사 & 붙여넣기(Ctrl+V)</strong> 하거나<br/> 아래 버튼을 사용하세요.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className={`flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50 hover:border-blue-400 transition-all duration-200 ${isUploading ? 'opacity-50 cursor-wait' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
|
||||||
<UploadCloud className="w-8 h-8 text-gray-400 mb-2 group-hover:text-blue-500" />
|
|
||||||
<p className="text-xs text-gray-500 font-medium">
|
|
||||||
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
disabled={isUploading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WritePage() {
|
export default function WritePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
||||||
<WritePageContent />
|
<LegacyWriteRedirect />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
431
src/components/admin/AdminCategoryPanel.tsx
Normal file
431
src/components/admin/AdminCategoryPanel.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { Folder, FolderOpen, FolderPlus, FolderTree, Loader2, Save, Trash2 } from 'lucide-react';
|
||||||
|
import { createCategory, deleteCategory, getCategories, updateCategory } from '@/api/category';
|
||||||
|
import { Category, CategoryUpdateRequest } from '@/types';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
const ROOT_PARENT = 'root';
|
||||||
|
|
||||||
|
type FlatCategory = Category & {
|
||||||
|
depth: number;
|
||||||
|
parentId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 sortCategories = (categories: Category[] = []) => {
|
||||||
|
return [...categories].sort((a, b) => a.id - b.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenCategories = (
|
||||||
|
categories: Category[] = [],
|
||||||
|
depth = 0,
|
||||||
|
parentId: number | null = null,
|
||||||
|
): FlatCategory[] => {
|
||||||
|
return sortCategories(categories).flatMap((category) => {
|
||||||
|
const resolvedParentId = category.parentId ?? parentId;
|
||||||
|
const current: FlatCategory = {
|
||||||
|
...category,
|
||||||
|
parentId: resolvedParentId,
|
||||||
|
depth,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
current,
|
||||||
|
...flattenCategories(category.children || [], depth + 1, category.id),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectDescendantIds = (category: Category): Set<number> => {
|
||||||
|
const ids = new Set<number>([category.id]);
|
||||||
|
|
||||||
|
for (const child of category.children || []) {
|
||||||
|
for (const id of collectDescendantIds(child)) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentValueToId = (value: string): number | null => {
|
||||||
|
return value === ROOT_PARENT ? null : Number(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentIdToValue = (parentId?: number | null) => {
|
||||||
|
return parentId == null ? ROOT_PARENT : String(parentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
function ParentSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
categories,
|
||||||
|
disabledIds = new Set<number>(),
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
categories: FlatCategory[];
|
||||||
|
disabledIds?: Set<number>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
>
|
||||||
|
<option value={ROOT_PARENT}>최상위 카테고리</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option
|
||||||
|
key={category.id}
|
||||||
|
value={category.id}
|
||||||
|
disabled={disabledIds.has(category.id)}
|
||||||
|
>
|
||||||
|
{`${' '.repeat(category.depth)}${category.depth > 0 ? '- ' : ''}${category.name}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryTree({
|
||||||
|
categories,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
categories: Category[];
|
||||||
|
selectedId: number | null;
|
||||||
|
onSelect: (category: Category) => void;
|
||||||
|
depth?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sortCategories(categories).map((category) => {
|
||||||
|
const isSelected = selectedId === category.id;
|
||||||
|
const hasChildren = category.children?.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(category)}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition',
|
||||||
|
isSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
{hasChildren ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||||
|
<span className="truncate font-medium">{category.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-gray-400">#{category.id}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<CategoryTree
|
||||||
|
categories={category.children}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedCategoryEditor({
|
||||||
|
category,
|
||||||
|
flatCategories,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
isSaving,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
category: FlatCategory;
|
||||||
|
flatCategories: FlatCategory[];
|
||||||
|
onUpdate: (id: number, data: CategoryUpdateRequest) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(category.name);
|
||||||
|
const [parentValue, setParentValue] = useState(parentIdToValue(category.parentId));
|
||||||
|
const [deleteArmed, setDeleteArmed] = useState(false);
|
||||||
|
const disabledParentIds = useMemo(() => collectDescendantIds(category), [category]);
|
||||||
|
const isBusy = isSaving || isDeleting;
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error('카테고리 이름을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(category.id, {
|
||||||
|
name: trimmedName,
|
||||||
|
parentId: parentValueToId(parentValue),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">선택한 카테고리</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">이름 변경과 부모 카테고리 이동을 여기서 처리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-500">
|
||||||
|
#{category.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-gray-500">카테고리 이름</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-gray-500">상위 카테고리</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
<ParentSelect
|
||||||
|
value={parentValue}
|
||||||
|
onChange={setParentValue}
|
||||||
|
categories={flatCategories}
|
||||||
|
disabledIds={disabledParentIds}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!deleteArmed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteArmed(true)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-100 bg-white px-4 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(category.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||||
|
삭제 확인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteArmed(false)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="rounded-full px-3 py-2 text-sm font-semibold text-gray-500 transition hover:bg-white"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isBusy}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-2 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="animate-spin" size={15} /> : <Save size={15} />}
|
||||||
|
변경 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCategoryPanel() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createName, setCreateName] = useState('');
|
||||||
|
const [createParentValue, setCreateParentValue] = useState(ROOT_PARENT);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: categories = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: getCategories,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatCategories = useMemo(() => flattenCategories(categories), [categories]);
|
||||||
|
const selectedCategory = flatCategories.find((category) => category.id === selectedId);
|
||||||
|
|
||||||
|
const invalidateCategoryData = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createCategory,
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateCategoryData();
|
||||||
|
setCreateName('');
|
||||||
|
setCreateParentValue(ROOT_PARENT);
|
||||||
|
toast.success('카테고리가 생성되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '카테고리 생성 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: CategoryUpdateRequest }) => updateCategory(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateCategoryData();
|
||||||
|
toast.success('카테고리가 수정되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '카테고리 수정 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteCategory,
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateCategoryData();
|
||||||
|
setSelectedId(null);
|
||||||
|
toast.success('카테고리가 삭제되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '카테고리 삭제 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const trimmedName = createName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
toast.error('카테고리 이름을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate({
|
||||||
|
name: trimmedName,
|
||||||
|
parentId: parentValueToId(createParentValue),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||||
|
<FolderTree size={19} />
|
||||||
|
카테고리 관리
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">공개 사이드바의 카테고리 구조를 관리자 화면에서 정리합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.2fr)]">
|
||||||
|
<div className="rounded-lg border border-gray-200 p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between px-1">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">카테고리 트리</h3>
|
||||||
|
<span className="text-xs text-gray-400">{flatCategories.length.toLocaleString()}개</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-64 items-center justify-center rounded-lg bg-gray-50">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : categories.length > 0 ? (
|
||||||
|
<CategoryTree categories={categories} selectedId={selectedId} onSelect={(category) => setSelectedId(category.id)} />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||||
|
아직 카테고리가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={handleCreate} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<FolderPlus size={18} className="text-blue-600" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">새 카테고리 추가</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<input
|
||||||
|
value={createName}
|
||||||
|
onChange={(event) => setCreateName(event.target.value)}
|
||||||
|
placeholder="카테고리 이름"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
/>
|
||||||
|
<ParentSelect
|
||||||
|
value={createParentValue}
|
||||||
|
onChange={setCreateParentValue}
|
||||||
|
categories={flatCategories}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? <Loader2 className="animate-spin" size={15} /> : <FolderPlus size={15} />}
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{selectedCategory ? (
|
||||||
|
<SelectedCategoryEditor
|
||||||
|
key={`${selectedCategory.id}-${selectedCategory.name}-${selectedCategory.parentId}`}
|
||||||
|
category={selectedCategory}
|
||||||
|
flatCategories={flatCategories}
|
||||||
|
onUpdate={(id, data) => updateMutation.mutate({ id, data })}
|
||||||
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-12 text-center">
|
||||||
|
<p className="text-sm font-semibold text-gray-700">수정할 카테고리를 선택하세요.</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-400">왼쪽 트리에서 항목을 선택하면 이름과 위치를 변경할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/components/admin/AdminCommentsPanel.tsx
Normal file
288
src/components/admin/AdminCommentsPanel.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
MessageSquareText,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { deleteAdminComment, getAdminComments } from '@/api/comments';
|
||||||
|
import { AdminComment, AdminCommentListResponse, PageMeta } from '@/types';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const emptyPageMeta: PageMeta = {
|
||||||
|
totalElements: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
number: 0,
|
||||||
|
last: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
|
||||||
|
if (!data) return emptyPageMeta;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
|
||||||
|
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
|
||||||
|
number: data.page?.number ?? data.number ?? 0,
|
||||||
|
last: data.page?.last ?? data.last,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthorName = (comment: AdminComment) => {
|
||||||
|
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeleteCommentDialog({
|
||||||
|
comment,
|
||||||
|
isDeleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
comment: AdminComment;
|
||||||
|
isDeleting: boolean;
|
||||||
|
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="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-950">댓글 삭제</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||||
|
관리자 권한으로 댓글을 삭제합니다. 계속 진행할까요?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
|
||||||
|
aria-label="댓글 삭제 닫기"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
|
||||||
|
<p className="line-clamp-3 text-sm font-semibold text-red-950">{comment.content}</p>
|
||||||
|
<p className="mt-2 text-xs text-red-700">
|
||||||
|
{getAuthorName(comment)} · {formatDate(comment.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCommentsPanel() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminComment | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
|
queryKey: ['comments', 'admin', { page }],
|
||||||
|
queryFn: () => getAdminComments(page, PAGE_SIZE),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const comments = data?.content ?? [];
|
||||||
|
const meta = getCommentListMeta(data);
|
||||||
|
const displayTotalPages = Math.max(meta.totalPages, 1);
|
||||||
|
const isLastPage = meta.last ?? (page + 1 >= displayTotalPages);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteAdminComment,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||||
|
|
||||||
|
if (comments.length === 1 && page > 0) {
|
||||||
|
setPage((currentPage) => Math.max(0, currentPage - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast.success('댓글이 삭제되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '댓글 삭제 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteMutation.mutate(deleteTarget.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||||
|
<MessageSquareText size={19} />
|
||||||
|
댓글 관리
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">최근 댓글을 확인하고 관리자 권한으로 삭제합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-gray-50 px-3 py-1 text-sm font-semibold text-gray-500">
|
||||||
|
{meta.totalElements.toLocaleString()}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
|
<div className="hidden grid-cols-[120px_minmax(0,1fr)_180px_96px] gap-3 border-b border-gray-100 bg-gray-50 px-4 py-3 text-xs font-bold text-gray-500 md:grid">
|
||||||
|
<span>작성자</span>
|
||||||
|
<span>내용</span>
|
||||||
|
<span>작성일</span>
|
||||||
|
<span className="text-right">작업</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : comments.length > 0 ? (
|
||||||
|
<div className={isFetching ? 'divide-y divide-gray-100 opacity-70' : 'divide-y divide-gray-100'}>
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="grid gap-3 px-4 py-4 transition hover:bg-gray-50 md:grid-cols-[120px_minmax(0,1fr)_180px_96px] md:items-center"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-800">{getAuthorName(comment)}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 md:hidden">{formatDate(comment.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
|
||||||
|
{comment.postTitle && (
|
||||||
|
<p className="mt-1 truncate text-xs text-gray-400">
|
||||||
|
글: {comment.postTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="hidden text-sm text-gray-500 md:block">{formatDate(comment.createdAt)}</span>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{comment.postSlug && (
|
||||||
|
<Link
|
||||||
|
href={`/posts/${comment.postSlug}`}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
aria-label="댓글이 달린 글 보기"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTarget(comment)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-red-50 hover:text-red-600"
|
||||||
|
aria-label="댓글 삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-16 text-center">
|
||||||
|
<MessageSquareText className="mx-auto mb-3 text-gray-300" size={42} />
|
||||||
|
<p className="text-sm font-semibold text-gray-700">아직 관리할 댓글이 없습니다.</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-400">댓글이 작성되면 이곳에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{isFetching && !isLoading ? '댓글을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((currentPage) => Math.max(0, currentPage - 1))}
|
||||||
|
disabled={page === 0 || isFetching}
|
||||||
|
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((currentPage) => currentPage + 1)}
|
||||||
|
disabled={isLastPage || isFetching}
|
||||||
|
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteCommentDialog
|
||||||
|
comment={deleteTarget}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
624
src/components/admin/AdminPostEditor.tsx
Normal file
624
src/components/admin/AdminPostEditor.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
670
src/components/admin/AdminPostsPanel.tsx
Normal file
670
src/components/admin/AdminPostsPanel.tsx
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit2,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getCategories } from '@/api/category';
|
||||||
|
import { deletePost, getPosts } from '@/api/posts';
|
||||||
|
import { Category, PageMeta, Post, PostListResponse } from '@/types';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'createdAt,desc', label: '최신순' },
|
||||||
|
{ value: 'createdAt,asc', label: '오래된순' },
|
||||||
|
{ value: 'viewCount,desc', label: '조회수순' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyPageMeta: PageMeta = {
|
||||||
|
totalPages: 0,
|
||||||
|
totalElements: 0,
|
||||||
|
number: 0,
|
||||||
|
last: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenCategoryOptions = (categories: Category[] = [], depth = 0): CategoryOption[] => {
|
||||||
|
return categories.flatMap((category) => [
|
||||||
|
{
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
label: `${' '.repeat(depth)}${depth > 0 ? '- ' : ''}${category.name}`,
|
||||||
|
},
|
||||||
|
...flattenCategoryOptions(category.children || [], depth + 1),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPostListMeta = (data?: PostListResponse): PageMeta => {
|
||||||
|
if (!data) return emptyPageMeta;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
|
||||||
|
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
|
||||||
|
number: data.page?.number ?? data.number ?? 0,
|
||||||
|
last: data.page?.last ?? data.last,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeletePostDialog({
|
||||||
|
post,
|
||||||
|
isDeleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
post: Post;
|
||||||
|
isDeleting: boolean;
|
||||||
|
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="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-950">게시글 삭제</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||||
|
삭제한 글은 복구할 수 없습니다. 계속 진행할까요?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
|
||||||
|
aria-label="삭제 확인 닫기"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
|
||||||
|
<p className="line-clamp-2 text-sm font-semibold text-red-950">{post.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700">
|
||||||
|
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulkDeletePostsDialog({
|
||||||
|
posts,
|
||||||
|
isDeleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
posts: Post[];
|
||||||
|
isDeleting: boolean;
|
||||||
|
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-lg rounded-xl border border-gray-200 bg-white shadow-xl">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-950">게시글 대량 삭제</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||||
|
선택한 게시글 {posts.length.toLocaleString()}개를 삭제합니다. 삭제한 글은 복구할 수 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
|
||||||
|
aria-label="대량 삭제 확인 닫기"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="max-h-52 overflow-y-auto rounded-lg border border-red-100 bg-red-50">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<div key={post.id} className="border-b border-red-100 px-4 py-3 last:border-b-0">
|
||||||
|
<p className="line-clamp-1 text-sm font-semibold text-red-950">{post.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700">
|
||||||
|
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||||
|
선택 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPostsPanel() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageInput, setPageInput] = useState('1');
|
||||||
|
const [keywordInput, setKeywordInput] = useState('');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [sort, setSort] = useState(sortOptions[0].value);
|
||||||
|
const [categoryName, setCategoryName] = useState('');
|
||||||
|
const [selectedPostIds, setSelectedPostIds] = useState<Set<number>>(() => new Set());
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Post | null>(null);
|
||||||
|
const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
|
queryKey: ['posts', 'admin', 'management', { page, keyword, sort, categoryName }],
|
||||||
|
queryFn: () => getPosts({
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
category: categoryName || undefined,
|
||||||
|
sort,
|
||||||
|
}),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: categories = [] } = useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: getCategories,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts = useMemo(() => data?.content ?? [], [data?.content]);
|
||||||
|
const meta = getPostListMeta(data);
|
||||||
|
const displayTotalPages = Math.max(meta.totalPages, 1);
|
||||||
|
const isLastPage = meta.last ?? (page + 1 >= displayTotalPages);
|
||||||
|
const categoryOptions = useMemo(() => flattenCategoryOptions(categories), [categories]);
|
||||||
|
const selectedPosts = useMemo(
|
||||||
|
() => posts.filter((post) => selectedPostIds.has(post.id)),
|
||||||
|
[posts, selectedPostIds],
|
||||||
|
);
|
||||||
|
const allPageSelected = posts.length > 0 && posts.every((post) => selectedPostIds.has(post.id));
|
||||||
|
|
||||||
|
const resetSelection = () => {
|
||||||
|
setSelectedPostIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (nextPage: number) => {
|
||||||
|
const boundedPage = Math.min(Math.max(nextPage, 0), displayTotalPages - 1);
|
||||||
|
setPage(boundedPage);
|
||||||
|
setPageInput(String(boundedPage + 1));
|
||||||
|
resetSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deletePost,
|
||||||
|
onSuccess: async () => {
|
||||||
|
const deletedPost = deleteTarget;
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
if (deletedPost?.slug) {
|
||||||
|
queryClient.removeQueries({ queryKey: ['post', deletedPost.slug] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posts.length === 1 && page > 0) {
|
||||||
|
goToPage(page - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSelection();
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast.success('게시글이 삭제되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '게시글 삭제 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeleteMutation = useMutation({
|
||||||
|
mutationFn: async (targets: Post[]) => {
|
||||||
|
await Promise.all(targets.map((post) => deletePost(post.id)));
|
||||||
|
return targets;
|
||||||
|
},
|
||||||
|
onSuccess: async (deletedPosts) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
deletedPosts.forEach((post) => {
|
||||||
|
if (post.slug) queryClient.removeQueries({ queryKey: ['post', post.slug] });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deletedPosts.length >= posts.length && page > 0) {
|
||||||
|
goToPage(page - 1);
|
||||||
|
} else {
|
||||||
|
resetSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBulkDeleteOpen(false);
|
||||||
|
toast.success(`게시글 ${deletedPosts.length.toLocaleString()}개가 삭제되었습니다.`);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '게시글 대량 삭제 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPage(0);
|
||||||
|
setPageInput('1');
|
||||||
|
resetSelection();
|
||||||
|
setKeyword(keywordInput.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setKeywordInput('');
|
||||||
|
setKeyword('');
|
||||||
|
setPage(0);
|
||||||
|
setPageInput('1');
|
||||||
|
resetSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSort(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
setPageInput('1');
|
||||||
|
resetSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setCategoryName(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
setPageInput('1');
|
||||||
|
resetSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageJump = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const parsedPage = Number(pageInput);
|
||||||
|
if (!Number.isFinite(parsedPage) || parsedPage < 1) {
|
||||||
|
toast.error('이동할 페이지 번호를 입력해주세요.');
|
||||||
|
setPageInput(String(page + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(parsedPage - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteMutation.mutate(deleteTarget.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmBulkDelete = () => {
|
||||||
|
if (selectedPosts.length === 0) return;
|
||||||
|
bulkDeleteMutation.mutate(selectedPosts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePostSelection = (id: number) => {
|
||||||
|
setSelectedPostIds((currentIds) => {
|
||||||
|
const nextIds = new Set(currentIds);
|
||||||
|
|
||||||
|
if (nextIds.has(id)) {
|
||||||
|
nextIds.delete(id);
|
||||||
|
} else {
|
||||||
|
nextIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextIds;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePageSelection = () => {
|
||||||
|
setSelectedPostIds((currentIds) => {
|
||||||
|
const nextIds = new Set(currentIds);
|
||||||
|
|
||||||
|
if (allPageSelected) {
|
||||||
|
posts.forEach((post) => nextIds.delete(post.id));
|
||||||
|
} else {
|
||||||
|
posts.forEach((post) => nextIds.add(post.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextIds;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="mb-5 flex flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||||
|
<FileText size={19} />
|
||||||
|
게시글 관리
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">검색, 수정, 삭제를 한 화면에서 처리합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/admin/posts/new"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<Edit2 size={15} />
|
||||||
|
새 글 작성
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<form onSubmit={handleSearch} className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<label className="relative block min-w-0 flex-1">
|
||||||
|
<span className="sr-only">게시글 검색</span>
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||||
|
<input
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
placeholder="제목 또는 본문 검색"
|
||||||
|
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2 pl-9 pr-3 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex shrink-0 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>
|
||||||
|
|
||||||
|
{keyword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className="inline-flex shrink-0 items-center justify-center rounded-full px-3 py-2 text-sm font-semibold text-gray-500 transition hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{keyword ? (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-gray-900">{keyword}</span> 결과{' '}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<span className="font-semibold text-gray-900">{meta.totalElements.toLocaleString()}</span>개
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{selectedPostIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsBulkDeleteOpen(true)}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-100 bg-red-50 px-3 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
선택 삭제 {selectedPostIds.size.toLocaleString()}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="sr-only">카테고리 필터</span>
|
||||||
|
<select
|
||||||
|
value={categoryName}
|
||||||
|
onChange={handleCategoryChange}
|
||||||
|
className="rounded-full border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
>
|
||||||
|
<option value="">전체 카테고리</option>
|
||||||
|
{categoryOptions.map((category) => (
|
||||||
|
<option key={category.id} value={category.name}>
|
||||||
|
{category.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="sr-only">게시글 정렬</span>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
className="rounded-full border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
|
<div className="hidden grid-cols-[44px_minmax(0,1fr)_130px_110px_84px_132px] gap-3 border-b border-gray-100 bg-gray-50 px-4 py-3 text-xs font-bold text-gray-500 md:grid">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allPageSelected}
|
||||||
|
onChange={togglePageSelection}
|
||||||
|
disabled={posts.length === 0 || isFetching}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="현재 페이지 게시글 전체 선택"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>제목</span>
|
||||||
|
<span>카테고리</span>
|
||||||
|
<span>작성일</span>
|
||||||
|
<span className="text-right">조회수</span>
|
||||||
|
<span className="text-right">작업</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-64 items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : posts.length > 0 ? (
|
||||||
|
<div className={clsx('divide-y divide-gray-100', isFetching && 'opacity-70')}>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<div
|
||||||
|
key={post.id}
|
||||||
|
className="grid gap-3 px-4 py-4 transition hover:bg-gray-50 md:grid-cols-[44px_minmax(0,1fr)_130px_110px_84px_132px] md:items-center"
|
||||||
|
>
|
||||||
|
<label className="flex items-start md:items-center md:justify-center">
|
||||||
|
<span className="sr-only">{post.title} 선택</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPostIds.has(post.id)}
|
||||||
|
onChange={() => togglePostSelection(post.id)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 md:mt-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/posts/${post.slug}`}
|
||||||
|
className="line-clamp-2 text-sm font-semibold text-gray-950 transition hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 md:hidden">
|
||||||
|
{post.categoryName || '미분류'} · {formatDate(post.createdAt)} · 조회 {post.viewCount.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="hidden truncate text-sm text-gray-500 md:block">{post.categoryName || '미분류'}</span>
|
||||||
|
<span className="hidden text-sm text-gray-500 md:block">{formatDate(post.createdAt)}</span>
|
||||||
|
<span className="hidden text-right text-sm tabular-nums text-gray-500 md:block">
|
||||||
|
{post.viewCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/posts/${post.slug}`}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
aria-label={`${post.title} 보기`}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/admin/posts/${post.slug}/edit`}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-blue-50 hover:text-blue-600"
|
||||||
|
aria-label={`${post.title} 수정`}
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTarget(post)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-red-50 hover:text-red-600"
|
||||||
|
aria-label={`${post.title} 삭제`}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-16 text-center">
|
||||||
|
<FileText className="mx-auto mb-3 text-gray-300" size={42} />
|
||||||
|
<p className="text-sm font-semibold text-gray-700">
|
||||||
|
{keyword ? '검색 조건에 맞는 게시글이 없습니다.' : '아직 작성된 글이 없습니다.'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-400">새 글을 작성하면 이곳에서 바로 관리할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{isFetching && !isLoading ? '목록을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(page - 1)}
|
||||||
|
disabled={page === 0 || isFetching}
|
||||||
|
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<form onSubmit={handlePageJump} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={displayTotalPages}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={(event) => setPageInput(event.target.value)}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="h-9 w-20 rounded-full border border-gray-200 px-3 text-center text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
|
aria-label="이동할 페이지 번호"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isFetching}
|
||||||
|
className="inline-flex h-9 items-center justify-center rounded-full border border-gray-200 px-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
이동
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(page + 1)}
|
||||||
|
disabled={isLastPage || isFetching}
|
||||||
|
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeletePostDialog
|
||||||
|
post={deleteTarget}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBulkDeleteOpen && selectedPosts.length > 0 && (
|
||||||
|
<BulkDeletePostsDialog
|
||||||
|
posts={selectedPosts}
|
||||||
|
isDeleting={bulkDeleteMutation.isPending}
|
||||||
|
onCancel={() => setIsBulkDeleteOpen(false)}
|
||||||
|
onConfirm={handleConfirmBulkDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/components/admin/AdminProfilePanel.tsx
Normal file
229
src/components/admin/AdminProfilePanel.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { Camera, Loader2, Save, UserRound } from 'lucide-react';
|
||||||
|
import { getProfile, updateProfile } from '@/api/profile';
|
||||||
|
import { uploadImage } from '@/api/image';
|
||||||
|
import { Profile, ProfileUpdateRequest } from '@/types';
|
||||||
|
|
||||||
|
const defaultProfile: Profile = {
|
||||||
|
name: 'Dev Park',
|
||||||
|
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
||||||
|
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
||||||
|
githubUrl: 'https://github.com',
|
||||||
|
email: 'user@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toProfileForm(profile: Profile): ProfileUpdateRequest {
|
||||||
|
return {
|
||||||
|
name: profile.name,
|
||||||
|
bio: profile.bio,
|
||||||
|
imageUrl: profile.imageUrl || '',
|
||||||
|
githubUrl: profile.githubUrl || '',
|
||||||
|
email: profile.email || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileForm({ profile }: { profile: Profile }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [form, setForm] = useState<ProfileUpdateRequest>(() => toProfileForm(profile));
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: updateProfile,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||||
|
toast.success('프로필이 저장되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error, '프로필 저장 실패')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uploadImage(file);
|
||||||
|
if (response.code === 'SUCCESS' && response.data) {
|
||||||
|
setForm((previous) => ({ ...previous, imageUrl: response.data }));
|
||||||
|
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || '이미지 업로드 실패', { id: uploadToast });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateMutation.mutate({
|
||||||
|
...form,
|
||||||
|
name: form.name.trim(),
|
||||||
|
bio: form.bio.trim(),
|
||||||
|
githubUrl: form.githubUrl?.trim(),
|
||||||
|
email: form.email?.trim(),
|
||||||
|
imageUrl: form.imageUrl?.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = updateMutation.isPending || isUploading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-gray-200 bg-gray-50 p-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="group relative h-28 w-28 overflow-hidden rounded-full border border-gray-200 bg-white shadow-sm"
|
||||||
|
aria-label="프로필 이미지 변경"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={form.imageUrl || defaultProfile.imageUrl!}
|
||||||
|
alt="프로필 미리보기"
|
||||||
|
fill
|
||||||
|
sizes="112px"
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{isUploading ? <Loader2 className="animate-spin" size={24} /> : <Camera size={24} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="mt-4 rounded-full border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-blue-200 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
이미지 변경
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-gray-500">이름</span>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, name: event.target.value }))}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-gray-500">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email || ''}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, email: event.target.value }))}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-gray-500">Github URL</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.githubUrl || ''}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, githubUrl: event.target.value }))}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-gray-500">소개</span>
|
||||||
|
<textarea
|
||||||
|
value={form.bio}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, bio: event.target.value }))}
|
||||||
|
className="mt-1 h-28 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm leading-6 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
|
||||||
|
저장하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminProfilePanel() {
|
||||||
|
const { data: profile, isLoading } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: getProfile,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||||
|
const formKey = [
|
||||||
|
displayProfile.name,
|
||||||
|
displayProfile.bio,
|
||||||
|
displayProfile.imageUrl,
|
||||||
|
displayProfile.githubUrl,
|
||||||
|
displayProfile.email,
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||||
|
<UserRound size={19} />
|
||||||
|
프로필 관리
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">공개 사이드바에 표시되는 블로그 소개를 수정합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-56 items-center justify-center rounded-lg bg-gray-50">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ProfileForm key={formKey} profile={displayProfile} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/admin/AdminRouteShell.tsx
Normal file
86
src/components/admin/AdminRouteShell.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { BarChart3, FileText, FolderTree, Loader2, MessageSquareText, UserRound } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/admin', label: '대시보드', icon: BarChart3, exact: true },
|
||||||
|
{ href: '/admin/posts', label: '게시글', icon: FileText },
|
||||||
|
{ href: '/admin/comments', label: '댓글', icon: MessageSquareText },
|
||||||
|
{ href: '/admin/categories', label: '카테고리', icon: FolderTree },
|
||||||
|
{ href: '/admin/profile', label: '프로필', icon: UserRound },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getCurrentPath = (pathname: string) => {
|
||||||
|
if (typeof window === 'undefined') return pathname || '/admin';
|
||||||
|
|
||||||
|
return `${window.location.pathname}${window.location.search}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoginRedirectPath = (pathname: string) => {
|
||||||
|
const currentPath = getCurrentPath(pathname);
|
||||||
|
return `/login?redirect=${encodeURIComponent(currentPath || '/admin')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminRouteShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
|
||||||
|
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated) return;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
router.replace(getLoginRedirectPath(pathname));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
toast.error('관리자 권한이 필요합니다.');
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
}, [_hasHydrated, isAdmin, isLoggedIn, pathname, router]);
|
||||||
|
|
||||||
|
if (!_hasHydrated || !isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6 px-1 py-4 md:px-4">
|
||||||
|
<nav className="flex gap-2 overflow-x-auto border-b border-gray-200 pb-3" aria-label="관리자 메뉴">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-950 text-white'
|
||||||
|
: 'border border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-950',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import { useState } from 'react';
|
|||||||
import { Comment } from '@/types';
|
import { Comment } from '@/types';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { deleteComment, deleteAdminComment } from '@/api/comments';
|
import { deleteComment } from '@/api/comments';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, ShieldAlert, X } from 'lucide-react'; // X 아이콘 추가
|
import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, X } from 'lucide-react'; // X 아이콘 추가
|
||||||
import CommentForm from './CommentForm';
|
import CommentForm from './CommentForm';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||||
@@ -17,8 +17,19 @@ interface CommentItemProps {
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) {
|
export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) {
|
||||||
const { isLoggedIn, role, user } = useAuthStore();
|
const { isLoggedIn, user } = useAuthStore();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isReplying, setIsReplying] = useState(false);
|
const [isReplying, setIsReplying] = useState(false);
|
||||||
@@ -26,7 +37,6 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [guestPassword, setGuestPassword] = useState('');
|
const [guestPassword, setGuestPassword] = useState('');
|
||||||
|
|
||||||
const isAdmin = isLoggedIn && role?.includes('ADMIN');
|
|
||||||
const isGuestComment = !comment.memberId;
|
const isGuestComment = !comment.memberId;
|
||||||
|
|
||||||
const isMyComment = isLoggedIn && !isGuestComment && (
|
const isMyComment = isLoggedIn && !isGuestComment && (
|
||||||
@@ -34,7 +44,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
|||||||
(user?.nickname === comment.author)
|
(user?.nickname === comment.author)
|
||||||
);
|
);
|
||||||
|
|
||||||
const showDeleteButton = isAdmin || isGuestComment || isMyComment;
|
const showDeleteButton = isGuestComment || isMyComment;
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password),
|
mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password),
|
||||||
@@ -42,32 +52,12 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
|||||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||||
toast.success('댓글이 삭제되었습니다.');
|
toast.success('댓글이 삭제되었습니다.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (error) => {
|
||||||
toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.'));
|
toast.error(`삭제 실패: ${getErrorMessage(error, '비밀번호가 틀렸습니다.')}`);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminDeleteMutation = useMutation({
|
|
||||||
mutationFn: deleteAdminComment,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
|
||||||
toast.success('관리자 권한으로 삭제했습니다.');
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
const handleDeleteClick = () => {
|
||||||
// A. 관리자 -> 즉시 삭제 (컨펌만)
|
|
||||||
if (isAdmin) {
|
|
||||||
if (confirm('관리자 권한으로 삭제하시겠습니까?')) {
|
|
||||||
adminDeleteMutation.mutate(comment.id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// B. 내 댓글 -> 즉시 삭제 (컨펌만)
|
|
||||||
if (isMyComment) {
|
if (isMyComment) {
|
||||||
if (confirm('이 댓글을 삭제하시겠습니까?')) {
|
if (confirm('이 댓글을 삭제하시겠습니까?')) {
|
||||||
deleteMutation.mutate({ id: comment.id });
|
deleteMutation.mutate({ id: comment.id });
|
||||||
@@ -138,11 +128,11 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded transition-colors",
|
"p-1.5 rounded transition-colors",
|
||||||
isDeleting ? "bg-red-50 text-red-600" :
|
isDeleting ? "bg-red-50 text-red-600" :
|
||||||
isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50"
|
"text-gray-400 hover:text-red-600 hover:bg-red-50"
|
||||||
)}
|
)}
|
||||||
title={isAdmin ? "관리자 삭제" : "삭제"}
|
title="삭제"
|
||||||
>
|
>
|
||||||
{isAdmin ? <ShieldAlert size={14} /> : <Trash2 size={14} />}
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,189 +1,106 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect, useMemo, Suspense } from 'react';
|
import { Suspense, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
// 🎨 이미지 최적화를 위해 next/image 사용
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/category';
|
|
||||||
import { getProfile, updateProfile } from '@/api/profile';
|
|
||||||
import { uploadImage } from '@/api/image';
|
|
||||||
import {
|
|
||||||
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
|
|
||||||
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion,
|
|
||||||
Archive
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
import {
|
||||||
import { useAuthStore } from '@/store/authStore';
|
Archive,
|
||||||
import toast from 'react-hot-toast';
|
ChevronRight,
|
||||||
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가
|
FileQuestion,
|
||||||
|
Folder,
|
||||||
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
FolderOpen,
|
||||||
for (const cat of categories) {
|
Github,
|
||||||
if (cat.id === id) return cat.name;
|
Mail,
|
||||||
if (cat.children && cat.children.length > 0) {
|
Menu,
|
||||||
const found = findCategoryNameById(cat.children, id);
|
X,
|
||||||
if (found) return found;
|
} from 'lucide-react';
|
||||||
}
|
import { getCategories } from '@/api/category';
|
||||||
}
|
import { getProfile } from '@/api/profile';
|
||||||
return undefined;
|
import PostSearch from '@/components/post/PostSearch';
|
||||||
};
|
import { Category, Profile } from '@/types';
|
||||||
|
|
||||||
interface CategoryItemProps {
|
interface CategoryItemProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
depth: number;
|
depth: number;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
isEditMode: boolean;
|
|
||||||
onDrop: (draggedId: number, targetId: number | null) => void;
|
|
||||||
onAdd: (parentId: number) => void;
|
|
||||||
onDelete: (id: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
const defaultProfile: Profile = {
|
||||||
|
name: 'Dev Park',
|
||||||
|
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
||||||
|
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
||||||
|
githubUrl: 'https://github.com',
|
||||||
|
email: 'user@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortCategories = (categories: Category[] = []) => {
|
||||||
|
return [...categories].sort((a, b) => a.id - b.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||||
|
const sortedChildren = useMemo(() => sortCategories(category.children), [category.children]);
|
||||||
|
const hasChildren = sortedChildren.length > 0;
|
||||||
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||||
|
|
||||||
const hasActiveChild = useMemo(() => {
|
const hasActiveChild = useMemo(() => {
|
||||||
const check = (cats: Category[] | undefined): boolean => {
|
const check = (categories: Category[] | undefined): boolean => {
|
||||||
if (!cats) return false;
|
if (!categories) return false;
|
||||||
return cats.some(c =>
|
|
||||||
decodeURIComponent(pathname) === `/category/${c.name}` || check(c.children)
|
return categories.some((child) => {
|
||||||
);
|
return decodeURIComponent(pathname) === `/category/${child.name}` || check(child.children);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return check(category.children);
|
return check(category.children);
|
||||||
}, [category.children, pathname]);
|
}, [category.children, pathname]);
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
||||||
|
const isChildrenVisible = isExpanded || isActive || hasActiveChild;
|
||||||
useEffect(() => {
|
|
||||||
if (isActive || hasActiveChild) {
|
|
||||||
setIsExpanded(true);
|
|
||||||
}
|
|
||||||
}, [isActive, hasActiveChild]);
|
|
||||||
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.dataTransfer.setData('categoryId', category.id.toString());
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isEditMode) {
|
|
||||||
setIsDragOver(true);
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
if (!isEditMode) return;
|
|
||||||
|
|
||||||
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
|
||||||
if (!draggedId || draggedId === category.id) return;
|
|
||||||
|
|
||||||
if (confirm(`'${category.name}' 하위로 이동하시겠습니까?`)) {
|
|
||||||
onDrop(draggedId, category.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedChildren = useMemo(() => {
|
|
||||||
if (!category.children) return [];
|
|
||||||
return [...category.children].sort((a, b) => a.id - b.id);
|
|
||||||
}, [category.children]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center justify-between px-4 py-2 text-sm rounded-lg transition-all group relative',
|
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all',
|
||||||
isActive && !isEditMode ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50',
|
isActive ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50',
|
||||||
isEditMode && 'border border-dashed border-gray-300 hover:border-blue-400 cursor-move bg-white',
|
|
||||||
isDragOver && 'bg-blue-100 border-blue-500'
|
|
||||||
)}
|
)}
|
||||||
style={{ marginLeft: `${depth * 12}px` }}
|
style={{ marginLeft: `${depth * 10}px` }}
|
||||||
draggable={isEditMode}
|
|
||||||
onDragStart={isEditMode ? handleDragStart : undefined}
|
|
||||||
onDragOver={isEditMode ? handleDragOver : undefined}
|
|
||||||
onDragLeave={isEditMode ? handleDragLeave : undefined}
|
|
||||||
onDrop={isEditMode ? handleDrop : undefined}
|
|
||||||
>
|
>
|
||||||
{!isEditMode ? (
|
<Link href={`/category/${category.name}`} className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||||
<Link href={`/category/${category.name}`} className="flex-1 flex items-center gap-2.5">
|
|
||||||
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||||
<span>{category.name}</span>
|
<span className="truncate">{category.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex items-center gap-2.5">
|
|
||||||
<Move size={14} className="text-gray-400" />
|
|
||||||
<span>{category.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditMode && (
|
{hasChildren && (
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onAdd(category.id); }}
|
type="button"
|
||||||
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
onClick={(event) => {
|
||||||
title="하위 카테고리 추가"
|
event.preventDefault();
|
||||||
>
|
event.stopPropagation();
|
||||||
<Plus size={14} />
|
setIsExpanded((previous) => !previous);
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(category.id); }}
|
|
||||||
className="p-1 text-red-500 hover:bg-red-100 rounded"
|
|
||||||
title="카테고리 삭제"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isEditMode && category.children && category.children.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsExpanded(!isExpanded);
|
|
||||||
}}
|
}}
|
||||||
className="p-1 hover:bg-gray-200/50 rounded-full transition-colors ml-1"
|
className="ml-1 rounded-full p-1 transition-colors hover:bg-gray-200/50"
|
||||||
|
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
size={14}
|
size={14}
|
||||||
className={clsx(
|
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||||
"text-gray-400 transition-transform duration-200",
|
|
||||||
isExpanded && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && sortedChildren.length > 0 && (
|
{isChildrenVisible && hasChildren && (
|
||||||
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in">
|
<div className="ml-4 border-l-2 border-gray-100">
|
||||||
{sortedChildren.map((child) => (
|
{sortedChildren.map((child) => (
|
||||||
<CategoryItem
|
<CategoryItem
|
||||||
key={child.id}
|
key={child.id}
|
||||||
category={child}
|
category={child}
|
||||||
depth={0}
|
depth={depth + 1}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
isEditMode={isEditMode}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onAdd={onAdd}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,57 +111,18 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
|||||||
|
|
||||||
function SidebarContent() {
|
function SidebarContent() {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가
|
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { role, _hasHydrated } = useAuthStore();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// 🆕 검색 로직 추가
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const keyword = searchParams.get('keyword') || '';
|
const keyword = searchParams.get('keyword') || '';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true); // 🛠️ [Fix] 클라이언트 마운트 후 true로 설정
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSearch = (newKeyword: string) => {
|
|
||||||
if (newKeyword.trim()) {
|
|
||||||
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
|
|
||||||
} else {
|
|
||||||
router.push('/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
||||||
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
|
|
||||||
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
|
|
||||||
});
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [isCategoryEditMode, setIsCategoryEditMode] = useState(false);
|
|
||||||
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
|
||||||
|
|
||||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdmin) {
|
|
||||||
setIsCategoryEditMode(false);
|
|
||||||
setIsEditModalOpen(false);
|
|
||||||
}
|
|
||||||
}, [isAdmin]);
|
|
||||||
|
|
||||||
const { data: categories } = useQuery({
|
const { data: categories } = useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: getCategories,
|
queryFn: getCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedCategories = useMemo(() => {
|
const sortedCategories = useMemo(() => sortCategories(categories), [categories]);
|
||||||
if (!categories) return undefined;
|
|
||||||
return [...categories].sort((a, b) => a.id - b.id);
|
|
||||||
}, [categories]);
|
|
||||||
|
|
||||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile'],
|
||||||
@@ -252,143 +130,40 @@ function SidebarContent() {
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCategoryMutation = useMutation({
|
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||||
mutationFn: createCategory,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
|
||||||
toast.success('카테고리가 생성되었습니다.');
|
|
||||||
},
|
|
||||||
onError: (err: any) => toast.error('생성 실패: ' + (err.response?.data?.message || err.message)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCategoryMutation = useMutation({
|
const handleSearch = (newKeyword: string) => {
|
||||||
mutationFn: ({ id, data }: { id: number; data: any }) => updateCategory(id, data),
|
const trimmedKeyword = newKeyword.trim();
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
|
if (trimmedKeyword) {
|
||||||
onError: (err: any) => toast.error('이동 실패: ' + (err.response?.data?.message || err.message)),
|
router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
|
||||||
});
|
|
||||||
|
|
||||||
const deleteCategoryMutation = useMutation({
|
|
||||||
mutationFn: deleteCategory,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
|
||||||
toast.success('카테고리가 삭제되었습니다.');
|
|
||||||
},
|
|
||||||
onError: (err: any) => toast.error('삭제 실패: ' + (err.response?.data?.message || err.message)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateProfileMutation = useMutation({
|
|
||||||
mutationFn: updateProfile,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
|
||||||
setIsEditModalOpen(false);
|
|
||||||
toast.success('프로필이 수정되었습니다!');
|
|
||||||
},
|
|
||||||
onError: (error: any) => toast.error('수정 실패: ' + (error.response?.data?.message || error.message)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAddCategory = (parentId: number | null) => {
|
|
||||||
const name = prompt('새 카테고리 이름을 입력하세요:');
|
|
||||||
if (!name || !name.trim()) return;
|
|
||||||
createCategoryMutation.mutate({ name, parentId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCategory = (id: number) => {
|
|
||||||
if (confirm('정말로 이 카테고리를 삭제하시겠습니까?\n하위 카테고리와 게시글이 모두 삭제될 수 있습니다.')) {
|
|
||||||
deleteCategoryMutation.mutate(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveCategory = useCallback((draggedId: number, targetParentId: number | null) => {
|
|
||||||
if (draggedId === targetParentId) return;
|
|
||||||
if (!categories) return;
|
|
||||||
|
|
||||||
const currentName = findCategoryNameById(categories, draggedId);
|
|
||||||
if (!currentName) {
|
|
||||||
toast.error('카테고리 정보를 찾을 수 없습니다.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategoryMutation.mutate({
|
router.push('/');
|
||||||
id: draggedId,
|
|
||||||
data: { name: currentName, parentId: targetParentId }
|
|
||||||
});
|
|
||||||
}, [categories, updateCategoryMutation]);
|
|
||||||
|
|
||||||
const handleRootDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsRootDragOver(false);
|
|
||||||
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
|
||||||
if (!draggedId) return;
|
|
||||||
|
|
||||||
if (confirm('이 카테고리를 최상위로 이동하시겠습니까?')) {
|
|
||||||
handleMoveCategory(draggedId, null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProfile: Profile = {
|
|
||||||
name: 'Dev Park',
|
|
||||||
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
|
||||||
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
|
||||||
githubUrl: 'https://github.com',
|
|
||||||
email: 'user@example.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
|
||||||
|
|
||||||
const handleEditClick = () => {
|
|
||||||
setEditForm({
|
|
||||||
name: displayProfile.name,
|
|
||||||
bio: displayProfile.bio,
|
|
||||||
imageUrl: displayProfile.imageUrl,
|
|
||||||
githubUrl: displayProfile.githubUrl || '',
|
|
||||||
email: displayProfile.email || '',
|
|
||||||
});
|
|
||||||
setIsEditModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImage(file);
|
|
||||||
if (res.code === 'SUCCESS' && res.data) {
|
|
||||||
setEditForm((prev) => ({ ...prev, imageUrl: res.data }));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('이미지 업로드 오류');
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
updateProfileMutation.mutate(editForm);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setIsOpen(!isOpen)} className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((previous) => !previous)}
|
||||||
|
className="fixed left-4 top-4 z-50 rounded-full bg-white p-2 shadow-md transition-colors hover:bg-gray-100 md:hidden"
|
||||||
|
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
|
||||||
|
>
|
||||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 🛠️ [수정] overflow-y-auto 제거, overflow-hidden 추가 */}
|
<aside
|
||||||
<aside className={clsx('fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out flex flex-col overflow-hidden', isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0')}>
|
className={clsx(
|
||||||
|
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-gray-100 bg-white transition-all duration-300 ease-in-out',
|
||||||
{/* 🛠️ [수정] 고정 영역: 프로필 (shrink-0 추가) */}
|
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
|
||||||
<div className={clsx('p-6 text-center transition-opacity duration-200 relative group shrink-0', !isOpen && 'md:opacity-0 md:hidden')}>
|
|
||||||
{isAdmin && (
|
|
||||||
<button onClick={handleEditClick} className="absolute top-4 right-4 p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-all opacity-0 group-hover:opacity-100 z-10" title="프로필 수정">
|
|
||||||
<Edit3 size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
>
|
||||||
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
|
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}>
|
||||||
{/* 🛠️ [Fix] isMounted를 체크하여 첫 렌더링 시 무조건 스켈레톤을 보여줍니다. (서버와 일치시킴) */}
|
<Link href="/" className="block transition-opacity hover:opacity-80">
|
||||||
{!isMounted || isProfileLoading ? (
|
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-gray-200 shadow-inner ring-4 ring-gray-50">
|
||||||
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
{isProfileLoading ? (
|
||||||
|
<div className="h-full w-full animate-pulse bg-gray-200" />
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||||
@@ -401,28 +176,29 @@ function SidebarContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 🛠️ [Fix] 텍스트 영역도 동일하게 처리 */}
|
|
||||||
{!isMounted || isProfileLoading ? (
|
{isProfileLoading ? (
|
||||||
<div className="space-y-2 flex flex-col items-center"><div className="h-6 w-24 bg-gray-200 rounded animate-pulse" /><div className="h-4 w-32 bg-gray-100 rounded animate-pulse" /></div>
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" />
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
|
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-1 whitespace-pre-line leading-relaxed">{displayProfile.bio}</p>
|
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-gray-500">{displayProfile.bio}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🛠️ [수정] min-h-0 추가하여 내부 스크롤 동작 보장 */}
|
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
|
||||||
<nav className="flex-1 px-4 py-2 flex flex-col min-h-0">
|
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}>
|
||||||
<div className={clsx('flex flex-col items-center gap-4 mt-4 shrink-0', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
<Folder size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={clsx('flex flex-col h-full', !isOpen && 'md:hidden')}>
|
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
|
||||||
|
|
||||||
{/* 🛠️ [수정] 상단 고정 네비게이션 영역 (shrink-0) */}
|
|
||||||
<div className="shrink-0 space-y-1">
|
<div className="shrink-0 space-y-1">
|
||||||
{/* 🆕 전체 검색바 (Archives 위) */}
|
<div className="mb-6 mt-2 px-1">
|
||||||
<div className="px-1 mb-6 mt-2">
|
|
||||||
<PostSearch
|
<PostSearch
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
@@ -430,15 +206,14 @@ function SidebarContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. 아카이브 링크 */}
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link
|
<Link
|
||||||
href="/archive"
|
href="/archive"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all group',
|
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||||
pathname === '/archive'
|
pathname === '/archive'
|
||||||
? 'bg-blue-50 text-blue-600 font-medium'
|
? 'bg-blue-50 font-medium text-blue-600'
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
: 'text-gray-600 hover:bg-gray-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Archive size={16} />
|
<Archive size={16} />
|
||||||
@@ -446,111 +221,82 @@ function SidebarContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
<div className="mb-4 border-t border-gray-100" />
|
||||||
<div className="border-t border-gray-100 mb-4" />
|
|
||||||
|
|
||||||
{/* 1. 카테고리 섹션 헤더 (고정) */}
|
<div className="mb-3 flex h-8 items-center justify-between px-4">
|
||||||
<div className="flex items-center justify-between px-4 mb-3 h-8">
|
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Categories</p>
|
||||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
</div>
|
||||||
{isAdmin && (
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{!isCategoryEditMode ? (
|
<div className="-mx-2 flex-1 overflow-y-auto px-2 scrollbar-hide">
|
||||||
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
|
{!categories && (
|
||||||
) : (
|
<div className="space-y-2 px-4">
|
||||||
<>
|
{[1, 2, 3].map((item) => (
|
||||||
<button onClick={() => handleAddCategory(null)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="최상위 카테고리 추가"><Plus size={16} /></button>
|
<div key={item} className="h-8 animate-pulse rounded bg-gray-100" />
|
||||||
<button onClick={() => setIsCategoryEditMode(false)} className="p-1 text-gray-500 hover:bg-gray-100 rounded" title="관리 종료"><X size={16} /></button>
|
))}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🛠️ [수정] 스크롤 가능한 카테고리 리스트 영역 (flex-1, overflow-y-auto) */}
|
<div className="mb-6 min-h-[50px] rounded-lg">
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-hide -mx-2 px-2">
|
{sortedCategories?.map((category) => (
|
||||||
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>}
|
<CategoryItem
|
||||||
|
key={category.id}
|
||||||
<div
|
category={category}
|
||||||
className={clsx("min-h-[50px] transition-colors rounded-lg mb-6", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")}
|
depth={0}
|
||||||
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
|
pathname={pathname}
|
||||||
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
/>
|
||||||
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
|
||||||
>
|
|
||||||
{sortedCategories?.map((cat) => (
|
|
||||||
<CategoryItem key={cat.id} category={cat} depth={0} pathname={pathname} isEditMode={isCategoryEditMode} onDrop={handleMoveCategory} onAdd={handleAddCategory} onDelete={handleDeleteCategory} />
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!isCategoryEditMode && (
|
|
||||||
<div className="mb-1 mt-2">
|
<div className="mb-1 mt-2">
|
||||||
<Link
|
<Link
|
||||||
href="/category/uncategorized"
|
href="/category/uncategorized"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all',
|
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||||
pathname === '/category/uncategorized'
|
pathname === '/category/uncategorized'
|
||||||
? 'bg-blue-50 text-blue-600 font-medium'
|
? 'bg-blue-50 font-medium text-blue-600'
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
: 'text-gray-600 hover:bg-gray-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FileQuestion size={16} />
|
<FileQuestion size={16} />
|
||||||
<span>미분류</span>
|
<span>미분류</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 🛠️ [수정] 고정 영역: 푸터 (shrink-0 추가) */}
|
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}>
|
||||||
<div className={clsx('p-6 border-t border-gray-100 bg-white shrink-0', !isOpen && 'hidden')}>
|
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
<a href={displayProfile.githubUrl || '#'} target="_blank" rel="noreferrer" className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm"><Github size={18} /></a>
|
<a
|
||||||
<a href={`mailto:${displayProfile.email}`} className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm"><Mail size={18} /></a>
|
href={displayProfile.githubUrl || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-gray-800 hover:text-white"
|
||||||
|
aria-label="Github"
|
||||||
|
>
|
||||||
|
<Github size={18} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`mailto:${displayProfile.email}`}
|
||||||
|
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-blue-500 hover:text-white"
|
||||||
|
aria-label="Email"
|
||||||
|
>
|
||||||
|
<Mail size={18} />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.</p>
|
<p className="mt-4 text-center text-[10px] font-light text-gray-300">
|
||||||
|
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{isEditModalOpen && (
|
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
||||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
|
||||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2"><Edit3 size={18} className="text-blue-600" /> 프로필 수정</h3>
|
|
||||||
<button onClick={() => setIsEditModalOpen(false)} className="text-gray-400 hover:text-gray-600"><XCircle size={24} /></button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
||||||
<div className="flex flex-col items-center mb-6">
|
|
||||||
<div className="relative w-24 h-24 mb-3 group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
|
|
||||||
<Image src={editForm.imageUrl || defaultProfile.imageUrl!} alt="Preview" fill className="rounded-full object-cover border-2 border-gray-100 group-hover:border-blue-300 transition-colors" unoptimized />
|
|
||||||
<div className="absolute inset-0 bg-black/30 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"><Camera className="text-white" size={24} /></div>
|
|
||||||
</div>
|
|
||||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImageUpload} />
|
|
||||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-600 font-medium hover:underline" disabled={isUploading}>{isUploading ? '업로드 중...' : '이미지 변경'}</button>
|
|
||||||
</div>
|
|
||||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">이름 (Name)</label><input type="text" value={editForm.name} onChange={(e) => setEditForm({...editForm, name: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" required /></div>
|
|
||||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">소개 (Bio)</label><textarea value={editForm.bio} onChange={(e) => setEditForm({...editForm, bio: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm resize-none h-24" required /></div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">Github URL</label><input type="url" value={editForm.githubUrl || ''} onChange={(e) => setEditForm({...editForm, githubUrl: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
|
||||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">Email</label><input type="email" value={editForm.email || ''} onChange={(e) => setEditForm({...editForm, email: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 flex gap-2">
|
|
||||||
<button type="button" onClick={() => setIsEditModalOpen(false)} className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors">취소</button>
|
|
||||||
<button type="submit" disabled={updateProfileMutation.isPending || isUploading} className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-sm disabled:bg-gray-400 flex justify-center items-center gap-2">{updateProfileMutation.isPending ? '저장 중...' : <><Save size={16} /> 저장하기</>}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="w-72 h-screen bg-white border-r border-gray-100" />}>
|
<Suspense fallback={<div className="h-screen w-72 border-r border-gray-100 bg-white" />}>
|
||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,12 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useEffect, useState } from 'react';
|
import { LogOut, PenLine, Settings, User, UserPlus } from 'lucide-react';
|
||||||
import { LogOut, PenLine, User, UserPlus } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function TopHeader() {
|
export default function TopHeader() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isLoggedIn, role, logout } = useAuthStore(); // 👈 role 가져오기
|
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
|
||||||
const [mounted, setMounted] = useState(false);
|
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (confirm('로그아웃 하시겠습니까?')) {
|
if (confirm('로그아웃 하시겠습니까?')) {
|
||||||
@@ -24,21 +19,30 @@ export default function TopHeader() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!_hasHydrated) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
|
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
{/* 👇 관리자(ADMIN)일 때만 글쓰기 버튼 노출 */}
|
{isAdmin && (
|
||||||
{role && role.includes('ADMIN') && (
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/write"
|
href="/admin"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 text-sm font-semibold rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
<span className="hidden sm:inline">관리자 설정</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/admin/posts/new"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||||
>
|
>
|
||||||
<PenLine size={16} />
|
<PenLine size={16} />
|
||||||
<span>글쓰기</span>
|
<span>새 글</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPost, deletePost } from '@/api/posts';
|
import { getPost } from '@/api/posts';
|
||||||
import { getProfile } from '@/api/profile';
|
import { getProfile } from '@/api/profile';
|
||||||
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
|
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
|
||||||
import CommentList from '@/components/comment/CommentList';
|
import CommentList from '@/components/comment/CommentList';
|
||||||
import TOC from '@/components/post/TOC';
|
import TOC from '@/components/post/TOC';
|
||||||
import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Post } from '@/types'; // 타입 임포트
|
import { Post } from '@/types'; // 타입 임포트
|
||||||
|
|
||||||
@@ -17,11 +16,24 @@ interface PostDetailClientProps {
|
|||||||
initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수)
|
initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPostErrorInfo = (error: unknown) => {
|
||||||
|
if (typeof error === 'object' && error !== null && 'response' in error) {
|
||||||
|
const response = (error as { response?: { status?: number; data?: { message?: string } } }).response;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response?.status,
|
||||||
|
message: response?.data?.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: undefined,
|
||||||
|
message: error instanceof Error ? error.message : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) {
|
export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { role, _hasHydrated } = useAuthStore();
|
|
||||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
|
||||||
|
|
||||||
// 1. 게시글 상세 조회
|
// 1. 게시글 상세 조회
|
||||||
const { data: post, isLoading: isPostLoading, error } = useQuery({
|
const { data: post, isLoading: isPostLoading, error } = useQuery({
|
||||||
@@ -37,18 +49,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
queryFn: getProfile,
|
queryFn: getProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: deletePost,
|
|
||||||
onSuccess: () => {
|
|
||||||
alert('게시글이 삭제되었습니다.');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
||||||
router.push('/');
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
alert('삭제 실패: ' + (err.response?.data?.message || err.message));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다.
|
// 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다.
|
||||||
// initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다.
|
// initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다.
|
||||||
if (isPostLoading) {
|
if (isPostLoading) {
|
||||||
@@ -61,8 +61,8 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
|
|
||||||
// 에러 처리
|
// 에러 처리
|
||||||
if (error || !post) {
|
if (error || !post) {
|
||||||
const errorStatus = (error as any)?.response?.status;
|
const { status: errorStatus, message } = getPostErrorInfo(error);
|
||||||
const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.';
|
const errorMessage = message || '게시글을 찾을 수 없습니다.';
|
||||||
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,16 +87,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
const prevPost = post.prevPost;
|
const prevPost = post.prevPost;
|
||||||
const nextPost = post.nextPost;
|
const nextPost = post.nextPost;
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
|
|
||||||
deleteMutation.mutate(post.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
router.push(`/write?slug=${post.slug}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
|
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
|
||||||
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||||
@@ -114,12 +104,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
|||||||
<Folder size={14} />
|
<Folder size={14} />
|
||||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={handleEdit} className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"><Edit2 size={18} /></button>
|
|
||||||
<button onClick={handleDelete} className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"><Trash2 size={18} /></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
||||||
|
|||||||
69
src/proxy.ts
Normal file
69
src/proxy.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
||||||
|
const HSTS_HEADER = 'max-age=31536000; includeSubDomains';
|
||||||
|
|
||||||
|
const getForwardedProtocol = (request: NextRequest) => {
|
||||||
|
const forwardedProtocol = request.headers.get('x-forwarded-proto');
|
||||||
|
if (forwardedProtocol) {
|
||||||
|
return forwardedProtocol.split(',')[0]?.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.nextUrl.protocol.replace(':', '').toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequestHost = (request: NextRequest) => {
|
||||||
|
const forwardedHost = request.headers.get('x-forwarded-host');
|
||||||
|
if (forwardedHost) {
|
||||||
|
return forwardedHost.split(',')[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.headers.get('host') || request.nextUrl.host;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHostname = (host: string) => {
|
||||||
|
if (host.startsWith('[')) {
|
||||||
|
return host.slice(1, host.indexOf(']'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return host.split(':')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRedirectHost = (host: string) => {
|
||||||
|
if (host.startsWith('[')) {
|
||||||
|
return host.slice(0, host.indexOf(']') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHostname(host);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocalRequest = (hostname: string) => {
|
||||||
|
return LOCAL_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const protocol = getForwardedProtocol(request);
|
||||||
|
const requestHost = getRequestHost(request);
|
||||||
|
const hostname = getHostname(requestHost);
|
||||||
|
|
||||||
|
if (!isLocalRequest(hostname) && protocol === 'http') {
|
||||||
|
const redirectUrl = request.nextUrl.clone();
|
||||||
|
redirectUrl.protocol = 'https:';
|
||||||
|
redirectUrl.host = getRedirectHost(requestHost);
|
||||||
|
redirectUrl.port = '';
|
||||||
|
|
||||||
|
return NextResponse.redirect(redirectUrl, 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next();
|
||||||
|
|
||||||
|
if (!isLocalRequest(hostname)) {
|
||||||
|
response.headers.set('Strict-Transport-Security', HSTS_HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
@@ -26,12 +26,24 @@ export interface Post {
|
|||||||
nextPost?: PostNeighbor | null;
|
nextPost?: PostNeighbor | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 게시글 목록 페이징 응답
|
export interface PageMeta {
|
||||||
export interface PostListResponse {
|
|
||||||
content: Post[];
|
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalElements: number;
|
totalElements: number;
|
||||||
last: boolean;
|
number?: number;
|
||||||
|
last?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 게시글 목록 페이징 응답
|
||||||
|
export interface PostListResponse extends PageMeta {
|
||||||
|
content: Post[];
|
||||||
|
page?: PageMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostSaveRequest {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
categoryId: number;
|
||||||
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 로그인 응답
|
// 4. 로그인 응답
|
||||||
@@ -129,3 +141,19 @@ export interface CommentSaveRequest {
|
|||||||
export interface CommentDeleteRequest {
|
export interface CommentDeleteRequest {
|
||||||
guestPassword?: string;
|
guestPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminComment {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
author?: string;
|
||||||
|
guestNickname?: string;
|
||||||
|
memberNickname?: string;
|
||||||
|
postSlug?: string;
|
||||||
|
postTitle?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCommentListResponse extends PageMeta {
|
||||||
|
content: AdminComment[];
|
||||||
|
page?: PageMeta;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user