This commit is contained in:
박원엽
2026-05-28 16:52:33 +09:00
parent 116b9a4fd2
commit f4481b88cb
13 changed files with 1853 additions and 454 deletions

116
AGENTS.md Normal file
View 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.

17
LOG.md Normal file
View File

@@ -0,0 +1,17 @@
# 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.

615
docs/renewal-plan.md Normal file
View File

@@ -0,0 +1,615 @@
# 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``/admin/posts/new` 또는 `/admin/write`로 편입하는 방향 검토
### 마크다운 렌더링 기능이 제한적임
현재 `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` 단일 페이지 안의 탭 UI로 시작해도 된다.
권장 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~10개
- 빠른 작업: 새 글 작성, 카테고리 관리, 프로필 수정
### 게시글 관리
기존 `/write` 기능을 재사용하되 관리자 흐름으로 재배치한다.
필요 UI:
- 검색/필터/정렬 가능한 게시글 테이블
- 제목, 카테고리, 날짜, 조회수, 작업 버튼
- 새 글 작성 버튼
- 수정 버튼
- 삭제 전 확인 모달
백엔드 유지 조건에서는 공개 `getPosts`를 관리 목록에도 사용한다. 비공개 글 같은 개념이 없다면 별도 admin list API 없이 충분하다.
### 카테고리 관리
사이드바에서 하던 기능을 `/admin/categories`로 이동한다.
필요 UI:
- 트리 형태 카테고리 목록
- 카테고리 생성
- 이름 변경
- 부모 변경 또는 드래그 이동
- 삭제 확인
- 변경 후 `['categories']`, `['posts']` 관련 쿼리 무효화
현재 API는 `updateCategory(id, { name, parentId })` 형식이므로 이름 변경과 이동 모두 가능하다.
### 프로필 관리
현재 사이드바 모달 기능을 `/admin/profile`로 이동한다.
필요 UI:
- 이름, 소개, 이미지, Github URL, Email 편집
- 이미지 업로드
- 미리보기
- 저장 후 프로필 쿼리 무효화
### 댓글 관리
이미 `getAdminComments``deleteAdminComment`가 존재하므로 관리자 화면에 편입할 수 있다.
필요 UI:
- 최근 댓글 목록
- 작성자, 게시글, 내용, 작성일
- 댓글 위치로 이동
- 관리자 삭제
단, `getAdminComments` 반환 타입이 현재 `any`이므로 실제 응답을 확인한 뒤 `AdminComment` 타입을 추가한다.
## 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 기준선을 먼저 잡는 것이 좋다.

View File

@@ -1,5 +1,5 @@
import { http } from './http';
import { ApiResponse, PostListResponse, Post } from '@/types';
import { ApiResponse, PostListResponse, Post, PostSaveRequest } from '@/types';
// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원)
export const getPosts = async (params?: {
@@ -37,13 +37,13 @@ export const getPost = async (slug: string) => {
};
// 4. 게시글 작성
export const createPost = async (data: any) => {
export const createPost = async (data: PostSaveRequest) => {
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
return response.data;
};
// 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);
return response.data;
};
@@ -52,4 +52,4 @@ export const updatePost = async (id: number, data: any) => {
export const deletePost = async (id: number) => {
const response = await http.delete<ApiResponse<null>>(`/api/admin/posts/${id}`);
return response.data;
};
};

231
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,231 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import {
ArrowRight,
Eye,
FileText,
FolderTree,
Loader2,
PenLine,
Settings,
Sparkles,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { getPosts } from '@/api/posts';
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
import { useAuthStore } from '@/store/authStore';
import { Category, Post } from '@/types';
const countCategories = (categories: Category[] = []): number => {
return categories.reduce((count, category) => {
return count + 1 + countCategories(category.children || []);
}, 0);
};
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: 'short',
day: 'numeric',
}).format(date);
};
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>
);
}
export default function AdminPage() {
const router = useRouter();
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
useEffect(() => {
if (!_hasHydrated) return;
if (!isLoggedIn) {
router.replace('/login');
return;
}
if (!isAdmin) {
toast.error('관리자 권한이 필요합니다.');
router.replace('/');
}
}, [_hasHydrated, isAdmin, isLoggedIn, router]);
const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'admin', 'latest'],
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
enabled: isAdmin,
retry: 0,
});
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'admin', 'popular'],
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
enabled: isAdmin,
retry: 0,
});
const { data: categories, isLoading: isCategoriesLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
enabled: isAdmin,
retry: 0,
});
if (!_hasHydrated || !isAdmin) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={36} />
</div>
);
}
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading;
const latestPosts = latestData?.content ?? [];
const popularPosts = popularData?.content ?? [];
const totalPosts = latestData?.totalElements ?? 0;
const totalCategories = countCategories(categories);
const lastUpdatedAt = latestPosts[0]?.createdAt;
return (
<div className="mx-auto max-w-6xl space-y-8 px-1 py-4 md:px-4">
<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>
<Link
href="/write"
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>
</header>
<section className="grid gap-4 md:grid-cols-3">
<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>
<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 lg:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.8fr)]">
<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="/archive"
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>
</section>
<section className="space-y-6">
<AdminProfilePanel />
<AdminCategoryPanel />
</section>
</div>
);
}

View File

@@ -6,4 +6,4 @@ export default function Loading() {
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}
}

View File

@@ -97,7 +97,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
{children}
<Toaster position="top-right" />
<ReactQueryDevtools initialIsOpen={false} />
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}
}

View File

@@ -7,6 +7,7 @@ import { createPost, updatePost, getPost } from '@/api/posts';
import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image';
import { useAuthStore } from '@/store/authStore';
import { PostSaveRequest } from '@/types';
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';
@@ -165,7 +166,7 @@ function WritePageContent() {
};
const mutation = useMutation({
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
mutationFn: (data: PostSaveRequest) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
onSuccess: async (response: any) => {
const savedPost = response?.data;
const newSlug = savedPost?.slug || editSlug;
@@ -521,4 +522,4 @@ export default function WritePage() {
<WritePageContent />
</Suspense>
);
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,189 +1,106 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo, Suspense } from 'react';
import { Suspense, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
// 🎨 이미지 최적화를 위해 next/image 사용
import Image from 'next/image';
import { useQuery, useMutation, useQueryClient } 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 { useQuery } from '@tanstack/react-query';
import { clsx } from 'clsx';
import { Profile, ProfileUpdateRequest, Category } from '@/types';
import { useAuthStore } from '@/store/authStore';
import toast from 'react-hot-toast';
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
for (const cat of categories) {
if (cat.id === id) return cat.name;
if (cat.children && cat.children.length > 0) {
const found = findCategoryNameById(cat.children, id);
if (found) return found;
}
}
return undefined;
};
import {
Archive,
ChevronRight,
FileQuestion,
Folder,
FolderOpen,
Github,
Mail,
Menu,
X,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { getProfile } from '@/api/profile';
import PostSearch from '@/components/post/PostSearch';
import { Category, Profile } from '@/types';
interface CategoryItemProps {
category: Category;
depth: number;
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 hasActiveChild = useMemo(() => {
const check = (cats: Category[] | undefined): boolean => {
if (!cats) return false;
return cats.some(c =>
decodeURIComponent(pathname) === `/category/${c.name}` || check(c.children)
);
const check = (categories: Category[] | undefined): boolean => {
if (!categories) return false;
return categories.some((child) => {
return decodeURIComponent(pathname) === `/category/${child.name}` || check(child.children);
});
};
return check(category.children);
}, [category.children, pathname]);
const [isExpanded, setIsExpanded] = useState(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]);
const isChildrenVisible = isExpanded || isActive || hasActiveChild;
return (
<div className="mb-1">
<div
<div
className={clsx(
'flex items-center justify-between px-4 py-2 text-sm rounded-lg transition-all group relative',
isActive && !isEditMode ? 'bg-blue-50 text-blue-600 font-medium' : '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'
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all',
isActive ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50',
)}
style={{ marginLeft: `${depth * 12}px` }}
draggable={isEditMode}
onDragStart={isEditMode ? handleDragStart : undefined}
onDragOver={isEditMode ? handleDragOver : undefined}
onDragLeave={isEditMode ? handleDragLeave : undefined}
onDrop={isEditMode ? handleDrop : undefined}
style={{ marginLeft: `${depth * 10}px` }}
>
{!isEditMode ? (
<Link href={`/category/${category.name}`} className="flex-1 flex items-center gap-2.5">
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
<span>{category.name}</span>
</Link>
) : (
<div className="flex-1 flex items-center gap-2.5">
<Move size={14} className="text-gray-400" />
<span>{category.name}</span>
</div>
)}
<Link href={`/category/${category.name}`} className="flex min-w-0 flex-1 items-center gap-2.5">
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
<span className="truncate">{category.name}</span>
</Link>
{isEditMode && (
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); onAdd(category.id); }}
className="p-1 text-green-600 hover:bg-green-100 rounded"
title="하위 카테고리 추가"
>
<Plus size={14} />
</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 && (
{hasChildren && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsExpanded((previous) => !previous);
}}
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
size={14}
className={clsx(
"text-gray-400 transition-transform duration-200",
isExpanded && "rotate-90"
)}
<ChevronRight
size={14}
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')}
/>
</button>
)}
</div>
{isExpanded && sortedChildren.length > 0 && (
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in">
{isChildrenVisible && hasChildren && (
<div className="ml-4 border-l-2 border-gray-100">
{sortedChildren.map((child) => (
<CategoryItem
key={child.id}
category={child}
depth={0}
<CategoryItem
key={child.id}
category={child}
depth={depth + 1}
pathname={pathname}
isEditMode={isEditMode}
onDrop={onDrop}
onAdd={onAdd}
onDelete={onDelete}
/>
))}
</div>
@@ -194,57 +111,18 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
function SidebarContent() {
const [isOpen, setIsOpen] = useState(true);
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가
const pathname = usePathname();
const { role, _hasHydrated } = useAuthStore();
const queryClient = useQueryClient();
// 🆕 검색 로직 추가
const router = useRouter();
const searchParams = useSearchParams();
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({
queryKey: ['categories'],
queryFn: getCategories,
});
const sortedCategories = useMemo(() => {
if (!categories) return undefined;
return [...categories].sort((a, b) => a.id - b.id);
}, [categories]);
const sortedCategories = useMemo(() => sortCategories(categories), [categories]);
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ['profile'],
@@ -252,147 +130,44 @@ function SidebarContent() {
retry: 0,
});
const createCategoryMutation = useMutation({
mutationFn: createCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
toast.success('카테고리가 생성되었습니다.');
},
onError: (err: any) => toast.error('생성 실패: ' + (err.response?.data?.message || err.message)),
});
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
const updateCategoryMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => updateCategory(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
onError: (err: any) => toast.error('이동 실패: ' + (err.response?.data?.message || err.message)),
});
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('카테고리 정보를 찾을 수 없습니다.');
const handleSearch = (newKeyword: string) => {
const trimmedKeyword = newKeyword.trim();
if (trimmedKeyword) {
router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
return;
}
updateCategoryMutation.mutate({
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);
router.push('/');
};
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} />}
</button>
{/* 🛠️ [수정] overflow-y-auto 제거, overflow-hidden 추가 */}
<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')}>
{/* 🛠️ [수정] 고정 영역: 프로필 (shrink-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">
{/* 🛠️ [Fix] isMounted를 체크하여 첫 렌더링 시 무조건 스켈레톤을 보여줍니다. (서버와 일치시킴) */}
{!isMounted || isProfileLoading ? (
<div className="w-full h-full bg-gray-200 animate-pulse" />
<aside
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',
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
)}
>
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}>
<Link href="/" className="block transition-opacity hover:opacity-80">
<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">
{isProfileLoading ? (
<div className="h-full w-full animate-pulse bg-gray-200" />
) : (
<Image
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
alt="Profile"
<Image
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
alt="Profile"
fill
sizes="96px"
className="object-cover"
@@ -401,44 +176,44 @@ function SidebarContent() {
/>
)}
</div>
{/* 🛠️ [Fix] 텍스트 영역도 동일하게 처리 */}
{!isMounted || 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>
{isProfileLoading ? (
<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>
<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>
</div>
{/* 🛠️ [수정] min-h-0 추가하여 내부 스크롤 동작 보장 */}
<nav className="flex-1 px-4 py-2 flex flex-col min-h-0">
<div className={clsx('flex flex-col items-center gap-4 mt-4 shrink-0', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
<div className={clsx('flex flex-col h-full', !isOpen && 'md:hidden')}>
{/* 🛠️ [수정] 상단 고정 네비게이션 영역 (shrink-0) */}
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}>
<Folder size={24} className="text-gray-400" />
</div>
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
<div className="shrink-0 space-y-1">
{/* 🆕 전체 검색바 (Archives 위) */}
<div className="px-1 mb-6 mt-2">
<PostSearch
onSearch={handleSearch}
placeholder="검색..."
<div className="mb-6 mt-2 px-1">
<PostSearch
onSearch={handleSearch}
placeholder="검색..."
initialKeyword={keyword}
/>
</div>
{/* 2. 아카이브 링크 */}
<div className="mb-4">
<Link
<Link
href="/archive"
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'
? 'bg-blue-50 text-blue-600 font-medium'
: 'text-gray-600 hover:bg-gray-50'
? 'bg-blue-50 font-medium text-blue-600'
: 'text-gray-600 hover:bg-gray-50',
)}
>
<Archive size={16} />
@@ -446,112 +221,83 @@ function SidebarContent() {
</Link>
</div>
{/* 구분선 */}
<div className="border-t border-gray-100 mb-4" />
<div className="mb-4 border-t border-gray-100" />
{/* 1. 카테고리 섹션 헤더 (고정) */}
<div className="flex items-center justify-between px-4 mb-3 h-8">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
{isAdmin && (
<div className="flex items-center gap-1">
{!isCategoryEditMode ? (
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
) : (
<>
<button onClick={() => handleAddCategory(null)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="최상위 카테고리 추가"><Plus size={16} /></button>
<button onClick={() => setIsCategoryEditMode(false)} className="p-1 text-gray-500 hover:bg-gray-100 rounded" title="관리 종료"><X size={16} /></button>
</>
)}
</div>
)}
<div className="mb-3 flex h-8 items-center justify-between px-4">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Categories</p>
</div>
</div>
{/* 🛠️ [수정] 스크롤 가능한 카테고리 리스트 영역 (flex-1, overflow-y-auto) */}
<div className="flex-1 overflow-y-auto scrollbar-hide -mx-2 px-2">
{!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>}
<div
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")}
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
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} />
<div className="-mx-2 flex-1 overflow-y-auto px-2 scrollbar-hide">
{!categories && (
<div className="space-y-2 px-4">
{[1, 2, 3].map((item) => (
<div key={item} className="h-8 animate-pulse rounded bg-gray-100" />
))}
</div>
)}
<div className="mb-6 min-h-[50px] rounded-lg">
{sortedCategories?.map((category) => (
<CategoryItem
key={category.id}
category={category}
depth={0}
pathname={pathname}
/>
))}
{!isCategoryEditMode && (
<div className="mb-1 mt-2">
<Link
href="/category/uncategorized"
className={clsx(
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all',
pathname === '/category/uncategorized'
? 'bg-blue-50 text-blue-600 font-medium'
: 'text-gray-600 hover:bg-gray-50'
)}
>
<FileQuestion size={16} />
<span></span>
</Link>
</div>
)}
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ .</div>}
<div className="mb-1 mt-2">
<Link
href="/category/uncategorized"
className={clsx(
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
pathname === '/category/uncategorized'
? 'bg-blue-50 font-medium text-blue-600'
: 'text-gray-600 hover:bg-gray-50',
)}
>
<FileQuestion size={16} />
<span></span>
</Link>
</div>
</div>
</div>
</div>
</nav>
{/* 🛠️ [수정] 고정 영역: 푸터 (shrink-0 추가) */}
<div className={clsx('p-6 border-t border-gray-100 bg-white shrink-0', !isOpen && 'hidden')}>
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}>
<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 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>
<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>
<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>
</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() {
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 />
</Suspense>
);
}
}

View File

@@ -4,17 +4,12 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { useEffect, useState } from 'react';
import { LogOut, PenLine, User, UserPlus } from 'lucide-react';
import { LogOut, PenLine, Settings, User, UserPlus } from 'lucide-react';
export default function TopHeader() {
const router = useRouter();
const { isLoggedIn, role, logout } = useAuthStore(); // 👈 role 가져오기
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
const handleLogout = () => {
if (confirm('로그아웃 하시겠습니까?')) {
@@ -24,21 +19,30 @@ export default function TopHeader() {
}
};
if (!mounted) return null;
if (!_hasHydrated) return null;
return (
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
{isLoggedIn ? (
<>
{/* 👇 관리자(ADMIN)일 때만 글쓰기 버튼 노출 */}
{role && role.includes('ADMIN') && (
<Link
href="/write"
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} />
<span></span>
</Link>
{isAdmin && (
<>
<Link
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="/write"
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} />
<span> </span>
</Link>
</>
)}
<button
@@ -70,4 +74,4 @@ export default function TopHeader() {
)}
</div>
);
}
}

View File

@@ -34,6 +34,13 @@ export interface PostListResponse {
last: boolean;
}
export interface PostSaveRequest {
title: string;
content: string;
categoryId: number;
tags: string[];
}
// 4. 로그인 응답
export interface AuthResponse {
grantType: string;
@@ -128,4 +135,4 @@ export interface CommentSaveRequest {
// 16. 댓글 삭제 요청 (비회원 검증용)
export interface CommentDeleteRequest {
guestPassword?: string;
}
}