diff --git a/LOG.md b/LOG.md index afd7422..da08f3d 100644 --- a/LOG.md +++ b/LOG.md @@ -2,34 +2,27 @@ ## 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 `` 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. +- `docs/apple-style-frontend-development-guide.md` Phase 1 + Phase 2 기준으로 Apple 스타일 1차 MVP 프론트 리뉴얼을 적용했다. +- 전역 디자인 토큰과 작은 UI 컴포넌트(`Surface`, `StatusBadge`, `SegmentedControl`, `MetricCard`, `EmptyState`)를 추가했다. +- 공개 홈을 intro, notice, featured/latest, category shelves, popular reading, archive CTA 구조로 재구성하고 공개 운영 집계 노출을 피했다. +- 사이드바, 카드/list item, 아카이브, 글 상세, Markdown renderer, TOC의 영어 UI와 표면/본문 폭/간격을 한국어 중심 Apple 스타일로 정리했다. +- 관리자 dashboard 타입과 `getAdminDashboard()` API wrapper를 추가하고, `/admin`에 KPI, 트래픽 차트, 액션 센터, 콘텐츠 성과, 최근 활동, 카테고리 상태 골격과 fallback UI를 구현했다. +- 검증: `npm run build` 통과. `npm run lint`는 기존 baseline(`src/api/http.ts`, `src/app/signup/page.tsx`, `src/app/sitemap.ts`, 댓글 컴포넌트, `src/store/authStore.ts`, `tailwind.config.ts`) 오류로 실패하지만 이번에 수정한 파일의 lint 오류는 없음. Browser로 `/`, `/?keyword=test`, `/archive`, 로그아웃 상태 `/admin` 리다이렉트를 확인했고 콘솔 에러는 없었다. +- 다음 추천 작업: 기존 lint baseline을 먼저 정리한 뒤, 백엔드 `/api/admin/dashboard` 구현과 실제 관리자 계정 상태에서 dashboard range 전환을 검증한다. + +## 2026-05-28 + +- 기존 lint baseline을 정리했다. +- `src/api/http.ts`의 토큰 refresh queue, Web Locks 접근, localStorage 파싱을 명시 타입으로 좁혔다. +- providers, signup, sitemap, comment, auth store, Tailwind config의 unused 변수와 `any`/CommonJS import lint 오류를 정리했다. +- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 백엔드/네트워크 제한으로 기존처럼 경고를 출력하지만 실패하지 않는다. +- 다음 추천 작업: 실제 관리자 계정으로 `/admin` 대시보드 fallback과 range control을 확인하고, 백엔드 `/api/admin/dashboard` 구현을 연결한다. + +## 2026-05-28 + +- 배포 백엔드(`https://blogserver.wypark.me`) 기준으로 공개 posts/categories/category 필터 API 연결을 확인했다. +- `GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul`는 미인증 상태에서 `403`을 반환하고, 배포 프론트 Origin(`https://blog.wypark.me`) + Authorization preflight는 허용됨을 확인했다. +- 실제 posts 응답에 포함되는 `updatedAt`을 `Post` 타입에 반영했다. +- `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me npm run build`를 네트워크 허용 상태로 실행해 sitemap fetch까지 포함한 production build를 검증했다. +- 검증: `npm run lint` 통과, `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me npm run build` 통과. 로컬 `localhost:3000`에서 배포 API를 직접 붙이는 브라우저 검증은 백엔드 CORS 정책상 `https://blog.wypark.me`만 허용되어 제한된다. +- 다음 추천 작업: 프론트 배포 후 `https://blog.wypark.me/admin`에서 실제 관리자 로그인 상태로 dashboard 실데이터 렌더링과 range 전환을 최종 확인한다. diff --git a/docs/apple-style-frontend-development-guide.md b/docs/apple-style-frontend-development-guide.md new file mode 100644 index 0000000..82b1a0c --- /dev/null +++ b/docs/apple-style-frontend-development-guide.md @@ -0,0 +1,814 @@ +# Apple 스타일 프론트엔드 리뉴얼 개발 문서 + +작성일: 2026-05-28 + +대상 저장소: `blog-frontend` + +## 1. 목표 + +WYPark Blog의 공개 화면과 관리자 화면을 Apple의 macOS/iOS에서 느껴지는 정돈된 UI 방향으로 개선한다. 단순히 둥근 카드와 흐림 효과를 추가하는 것이 아니라, 콘텐츠를 읽는 화면은 조용하고 고급스럽게, 관리자 화면은 밀도 있는 운영 도구처럼 설계한다. + +이번 문서는 프론트엔드를 먼저 개발할 수 있도록 백엔드 예정 엔드포인트까지 미리 정의한다. 백엔드 API가 아직 없더라도 프론트는 타입, API 래퍼, 로딩/빈 상태, 임시 fallback을 먼저 구현할 수 있어야 한다. + +중요한 원칙: + +- 공개 홈 화면에는 하루 조회수, 일주일 조회수, 한달 조회수 같은 운영 집계를 노출하지 않는다. +- 조회수 집계와 콘텐츠 성과 모니터링은 `/admin` 대시보드에서만 보여준다. +- 공개 화면은 방문자가 글을 찾고 읽는 경험에 집중한다. +- 관리자 화면은 운영자가 오늘 확인할 일과 콘텐츠 성과를 빠르게 보는 경험에 집중한다. + +## 2. 현재 화면 진단 + +실제 배포 사이트 `https://blog.wypark.me` 기준으로 확인한 내용이다. + +### 장점 + +- 왼쪽 고정 사이드바, 홈 카드 그리드, 글 상세 TOC, 아카이브 타임라인, 카테고리 카드/리스트 전환 등 기본 정보 구조가 이미 갖춰져 있다. +- 글 상세 페이지는 이미지, 목차, 이전/다음 글, 댓글까지 블로그 핵심 기능을 포함한다. +- 관리자 라우트가 `/admin`, `/admin/posts`, `/admin/comments`, `/admin/categories`, `/admin/profile`로 분리되어 있어 확장하기 좋다. + +### 개선이 필요한 점 + +- 전체 UI가 매우 옅고 넓게 펼쳐져 콘텐츠의 중심감이 약하다. +- 홈 카드들이 비슷한 무게로 배치되어 첫 방문자가 무엇을 먼저 봐야 할지 흐릿하다. +- `Read more`, `views`, `ON THIS PAGE`, `Archives`, `CATEGORIES`처럼 영어 UI가 한국어 콘텐츠와 섞여 있다. +- 글 상세 본문 줄 길이가 길어 장문 읽기에서 시선 이동이 커진다. +- 관리자 화면은 기본 통계와 최근 목록은 있지만, 운영자가 매일 들어와 확인할 만한 지표와 할 일이 부족하다. + +## 3. 디자인 방향 + +### 3.1 Apple 스타일 해석 + +이 프로젝트에서의 Apple 스타일은 다음 키워드로 해석한다. + +- 선명한 위계: 제목, 메타, 본문, 보조 설명의 크기와 색을 명확히 구분한다. +- 부드러운 재질감: 흰색 또는 어두운 반투명 surface, 얇은 border, 절제된 shadow, `backdrop-blur`를 사용한다. +- 정교한 여백: 큰 빈 공간이 아니라 목적 있는 여백을 둔다. +- 조용한 색: 기본은 neutral 계열, 액션과 현재 상태에만 blue tint를 사용한다. +- 익숙한 컨트롤: segmented control, icon button, toolbar, list row, sidebar, sheet, toast 같은 OS 친화 패턴을 사용한다. +- 가벼운 모션: hover, pressed, page transition은 120-180ms 수준으로 짧게 처리한다. + +과하게 피해야 할 것: + +- 큰 마케팅형 hero +- 장식용 gradient blob +- 과한 glassmorphism +- 카드 안의 카드 중첩 +- 모든 요소가 파란색으로 보이는 단색 UI +- 공개 블로그에 운영 통계 노출 + +### 3.2 디자인 토큰 + +Tailwind CSS 4를 유지하되, 반복되는 스타일은 작은 컴포넌트와 CSS 변수로 정리한다. + +권장 전역 토큰: + +```css +:root { + --color-page: #f5f5f7; + --color-surface: rgba(255, 255, 255, 0.78); + --color-surface-strong: #ffffff; + --color-line: rgba(0, 0, 0, 0.08); + --color-text: #1d1d1f; + --color-text-muted: #6e6e73; + --color-text-subtle: #86868b; + --color-accent: #007aff; + --color-accent-soft: rgba(0, 122, 255, 0.1); + --shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.08); + --shadow-card: 0 10px 28px rgba(0, 0, 0, 0.06); +} +``` + +다크 모드는 나중에 확장하더라도 토큰 구조는 미리 다크 대응이 가능하게 둔다. + +```css +@media (prefers-color-scheme: dark) { + :root { + --color-page: #0b0b0c; + --color-surface: rgba(28, 28, 30, 0.72); + --color-surface-strong: #1c1c1e; + --color-line: rgba(255, 255, 255, 0.1); + --color-text: #f5f5f7; + --color-text-muted: #a1a1a6; + --color-text-subtle: #8e8e93; + --color-accent: #0a84ff; + --color-accent-soft: rgba(10, 132, 255, 0.16); + --shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.36); + --shadow-card: 0 10px 28px rgba(0, 0, 0, 0.28); + } +} +``` + +권장 Tailwind 패턴: + +```tsx +const surfaceClass = + 'border border-black/5 bg-white/75 shadow-[0_18px_50px_rgba(0,0,0,0.08)] backdrop-blur-xl'; + +const controlClass = + 'inline-flex h-9 items-center justify-center rounded-full border border-black/10 bg-white/70 px-3 text-sm font-medium text-gray-700 transition hover:bg-white'; +``` + +### 3.3 타이포그래피 + +폰트는 별도 웹폰트보다 시스템 폰트를 우선한다. + +권장 font-family: + +```css +font-family: + -apple-system, + BlinkMacSystemFont, + "SF Pro Display", + "SF Pro Text", + "Apple SD Gothic Neo", + "Noto Sans KR", + system-ui, + sans-serif; +``` + +본문 기준: + +- 글 상세 본문 최대 폭: 760-820px +- 글 상세 `h1`: 40-48px desktop, 30-34px mobile +- 글 상세 본문: 16-17px, line-height 1.8 전후 +- 카드 제목: 17-20px +- 메타 텍스트: 12-13px +- 버튼 텍스트: 13-14px, font-semibold + +한국어 문장에서는 letter-spacing을 음수로 줄이지 않는다. + +## 4. 공개 홈 화면 개선안 + +홈 화면은 풍성해져야 하지만 운영 통계는 숨긴다. 방문자에게 필요한 것은 블로그의 주제, 최신 글, 대표 글, 카테고리 탐색, 아카이브 진입이다. + +### 4.1 홈 화면 목표 + +- 블로그 주인이 어떤 글을 쓰는지 첫 화면에서 알 수 있게 한다. +- 최신 글과 대표 글을 분명하게 구분한다. +- 공지는 작지만 놓치지 않게 만든다. +- 카테고리와 아카이브 탐색을 쉽게 한다. +- 조회수 집계, 운영 지표, 일간/주간/월간 트래픽은 공개 홈에 노출하지 않는다. + +### 4.2 추천 레이아웃 + +```text +/ +├─ Intro band +│ ├─ 프로필 요약 +│ ├─ 블로그 한 줄 소개 +│ └─ 검색 CTA +├─ Notice strip +│ └─ 최신 공지 1-3개 +├─ Featured + Latest +│ ├─ 대표 글 1개 +│ └─ 최신 글 compact list 5-7개 +├─ Category shelves +│ ├─ 개발 공부 +│ ├─ 잡담 +│ └─ 공지 +├─ Popular reading +│ └─ 인기 글 목록, 단 공개 화면에서는 누적 조회수 숫자 노출은 선택사항 +└─ Archive preview + └─ 최근 월별 글 흐름과 아카이브 링크 +``` + +### 4.3 홈 섹션 상세 + +#### Intro band + +역할: + +- 현재 사이드바 프로필만으로는 홈 중앙의 첫인상이 약하므로, 콘텐츠 영역 상단에도 블로그 정체성을 짧게 보여준다. + +구성: + +- `박원엽의 개발 기록` +- 짧은 소개 문장: `백엔드, 네트워크, 운영 경험을 정리하는 개인 기술 블로그` +- 검색 입력 또는 `전체 글 둘러보기` 버튼 +- 최신 업데이트 날짜 + +주의: + +- hero처럼 과하게 크게 만들지 않는다. +- 첫 viewport에 최신 글 일부가 보이도록 높이를 제한한다. + +#### Notice strip + +역할: + +- 공지를 상단에 노출하되 홈 전체를 차지하지 않게 한다. + +구성: + +- `공지` badge +- 공지 제목 +- 날짜 +- 우측 chevron + +스타일: + +- 연한 red tint 또는 system pink tint +- 얇은 border +- hover 시 surface 강조 + +#### Featured post + +역할: + +- 최신 글 중 첫 번째 또는 운영자가 지정한 대표 글을 강조한다. + +백엔드에 대표 글 개념이 아직 없다면 1차는 최신 글 첫 번째를 사용한다. + +구성: + +- 카테고리 badge +- 제목 +- 2-3줄 요약 +- 날짜 +- `읽기` 버튼 + +향후 백엔드 확장: + +- `featured` boolean 또는 `pinnedRank` 필드 추가 가능 +- 이 문서의 MVP에서는 필수 아님 + +#### Latest list + +역할: + +- 현재 카드 3개보다 더 많은 최신 글을 빠르게 훑게 한다. + +구성: + +- 최신 글 6-8개 +- 각 row: 제목, 카테고리, 날짜, 짧은 요약 또는 없음 + +스타일: + +- macOS list row처럼 한 줄 또는 두 줄 중심 +- 각 row는 얇은 divider +- hover 시 `bg-black/[0.03]` 또는 다크 대응 surface + +#### Category shelves + +역할: + +- 카테고리 트리가 사이드바에만 있어 중앙 콘텐츠에서는 탐색 힌트가 약하다. + +구성: + +- 주요 카테고리 3-6개 +- 카테고리명 +- 최근 글 2-3개 +- 전체 보기 링크 + +데이터: + +- 1차는 `getCategories()`와 카테고리별 `getPostsByCategory(categoryName, 0, 3)` 사용 +- 너무 많은 요청이 부담되면 상위 카테고리 3개만 요청 +- 백엔드가 나중에 `GET /api/posts/grouped-by-category`를 제공하면 교체 가능 + +#### Archive preview + +역할: + +- 글이 100개 이상 있는 블로그의 자산을 보여준다. + +구성: + +- 최근 12개월의 월별 발행 수 +- `아카이브에서 102개 글 보기` + +데이터: + +- 1차는 `/archive` 페이지로 유도만 한다. +- 월별 발행 수는 백엔드 대시보드 API가 아니라 공개 홈용 별도 API가 필요할 때만 추가한다. +- 공개 통계에는 조회수 집계를 포함하지 않는다. + +### 4.4 공개 홈에서 사용 가능한 기존 API + +```http +GET /api/posts?page=0&size=8&sort=createdAt,desc +GET /api/posts?page=0&size=5&sort=viewCount,desc +GET /api/posts?page=0&size=3&category=공지&sort=createdAt,desc +GET /api/posts?page=0&size=3&category={categoryName}&sort=createdAt,desc +GET /api/categories +GET /api/profile +``` + +주의: + +- `sort=viewCount,desc`로 인기 글은 만들 수 있지만, 공개 홈에서는 `조회수 123`처럼 숫자를 강조하지 않는다. +- 공개 홈에 `todayViews`, `weekViews`, `monthViews`를 표시하지 않는다. + +## 5. 관리자 대시보드 개선안 + +관리자 대시보드는 `/admin`에 위치한다. 공개 블로그와 달리 정보 밀도를 높이고 운영 행동을 바로 이어갈 수 있게 한다. + +### 5.1 관리자 대시보드 목표 + +- 오늘 블로그가 어떻게 움직이는지 확인한다. +- 최근 7일, 30일 기준으로 트래픽 흐름을 본다. +- 어떤 글을 관리해야 하는지 알려준다. +- 댓글과 카테고리 상태를 빠르게 확인한다. +- 새 글 작성, 댓글 관리, 카테고리 정리로 바로 이동한다. + +### 5.2 추천 레이아웃 + +```text +/admin +├─ Dashboard header +│ ├─ 오늘 날짜 +│ ├─ 마지막 집계 시각 +│ └─ 새 글 작성 / 게시글 관리 quick actions +├─ KPI cards +│ ├─ 오늘 조회수 +│ ├─ 최근 7일 조회수 +│ ├─ 최근 30일 조회수 +│ └─ 총 게시글 / 총 댓글 / 카테고리 +├─ Traffic trend +│ └─ 최근 30일 일별 조회수 chart +├─ Content performance +│ ├─ 최근 7일 인기 글 +│ ├─ 상승 중인 글 +│ └─ 오래 업데이트 안 된 인기 글 +├─ Action center +│ ├─ 답변 필요한 댓글 +│ ├─ 미분류 글 +│ └─ 오래 방치된 글 +├─ Recent activity +│ ├─ 최근 글 +│ └─ 최근 댓글 +└─ Category health + └─ 카테고리별 글 수, 조회수, 최근 발행일 +``` + +### 5.3 KPI 카드 + +필수 KPI: + +- 오늘 조회수 +- 최근 7일 조회수 +- 최근 30일 조회수 +- 총 게시글 +- 총 댓글 +- 카테고리 수 + +표현: + +- 큰 숫자 +- 전 기간 대비 변화율 +- 작은 sparkline 또는 방향 badge + +예: + +```text +오늘 조회수 +311 +어제보다 +12.4% +``` + +API가 준비되지 않았을 때: + +- 오늘/7일/30일 조회수 카드는 skeleton 또는 `통계 API 연결 전` 상태를 보여준다. +- 총 게시글/댓글/카테고리는 기존 API로 fallback한다. + +### 5.4 Traffic trend + +차트는 신규 dependency 없이 시작한다. + +권장: + +- `svg` 또는 CSS grid bar chart +- 최근 30일 기본 +- segmented control: `7일`, `30일`, `90일` + +데이터: + +```ts +type AdminDashboardTrafficPoint = { + date: string; // YYYY-MM-DD, Asia/Seoul 기준 + views: number; +}; +``` + +### 5.5 Content performance + +위젯: + +- 최근 7일 인기 글 +- 최근 30일 인기 글 +- 상승 중인 글 +- 오래 업데이트 안 된 인기 글 + +`오래 업데이트 안 된 인기 글` 기준: + +- 최근 30일 조회수가 일정 기준 이상 +- `updatedAt` 또는 `createdAt`이 180일 이상 이전 + +백엔드가 `updatedAt`을 제공하지 않으면 `createdAt` 기준으로 1차 구현한다. + +### 5.6 Action center + +운영자가 해야 할 일을 모아 보여준다. + +권장 항목: + +- 답변 필요한 댓글 +- 미분류 글 +- 카테고리 없는 글 +- 오래 방치된 인기 글 +- 최근 댓글 중 삭제 검토가 필요한 글, 추후 신고 기능이 생길 경우 + +현재 댓글에 답변 상태가 없다면 1차 기준: + +- 부모 댓글이고 작성자가 글 작성자가 아니며, 자식 댓글 중 관리자 댓글이 없는 댓글 + +백엔드가 이 기준을 계산해서 `actionItems.unansweredComments`로 내려준다. + +### 5.7 Category health + +표시 항목: + +- 카테고리명 +- 글 수 +- 최근 30일 조회수 +- 최근 발행일 +- 하위 카테고리 수 + +목적: + +- 비어 있는 카테고리 찾기 +- 너무 많은 글이 몰린 카테고리 찾기 +- 오랫동안 업데이트되지 않은 카테고리 찾기 + +## 6. 프론트 API 계약 + +백엔드가 나중에 구현할 관리자 대시보드 API를 프론트에서 먼저 타입으로 정의한다. + +### 6.1 신규 파일 + +```text +src/api/dashboard.ts +src/components/admin/dashboard/AdminDashboardOverview.tsx +src/components/admin/dashboard/AdminDashboardTrafficChart.tsx +src/components/admin/dashboard/AdminDashboardActionCenter.tsx +src/components/admin/dashboard/AdminDashboardPostPerformance.tsx +src/components/admin/dashboard/AdminDashboardCategoryHealth.tsx +``` + +### 6.2 신규 타입 + +`src/types/index.ts`에 추가한다. + +```ts +export type DashboardRange = '7d' | '30d' | '90d'; + +export interface DashboardMetric { + value: number; + previousValue?: number; + changeRate?: number; +} + +export interface DashboardOverview { + todayViews: DashboardMetric; + weekViews: DashboardMetric; + monthViews: DashboardMetric; + totalPosts: number; + totalComments: number; + totalCategories: number; + lastPublishedAt?: string | null; + generatedAt: string; +} + +export interface DashboardTrafficPoint { + date: string; + views: number; +} + +export interface DashboardPostStat { + id: number; + title: string; + slug: string; + categoryName: string; + viewCount: number; + rangeViewCount: number; + commentCount?: number; + createdAt: string; + updatedAt?: string | null; +} + +export interface DashboardCategoryStat { + id: number; + name: string; + parentId?: number | null; + postCount: number; + viewCount: number; + recentViewCount: number; + lastPublishedAt?: string | null; + childrenCount: number; +} + +export interface DashboardActionItems { + unansweredComments: number; + uncategorizedPosts: number; + stalePopularPosts: number; +} + +export interface AdminDashboardResponse { + overview: DashboardOverview; + traffic: DashboardTrafficPoint[]; + topPosts: DashboardPostStat[]; + risingPosts: DashboardPostStat[]; + stalePopularPosts: DashboardPostStat[]; + recentPosts: Post[]; + recentComments: AdminComment[]; + categoryStats: DashboardCategoryStat[]; + actionItems: DashboardActionItems; +} +``` + +### 6.3 신규 API 래퍼 + +```ts +import { http } from './http'; +import { AdminDashboardResponse, ApiResponse, DashboardRange } from '@/types'; + +export const getAdminDashboard = async ( + range: DashboardRange = '30d', + timezone = 'Asia/Seoul', +) => { + const response = await http.get>('/api/admin/dashboard', { + params: { range, timezone }, + }); + + return response.data.data; +}; +``` + +### 6.4 신규 관리자 엔드포인트 + +프론트에서 먼저 정의하고 백엔드가 이후 맞춘다. + +```http +GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul +Authorization: Bearer {accessToken} +``` + +응답: + +```json +{ + "code": "SUCCESS", + "message": "OK", + "data": { + "overview": { + "todayViews": { "value": 311, "previousValue": 277, "changeRate": 12.27 }, + "weekViews": { "value": 1830, "previousValue": 1504, "changeRate": 21.68 }, + "monthViews": { "value": 7124, "previousValue": 6302, "changeRate": 13.04 }, + "totalPosts": 102, + "totalComments": 18, + "totalCategories": 14, + "lastPublishedAt": "2026-01-02T12:04:00+09:00", + "generatedAt": "2026-05-28T20:00:00+09:00" + }, + "traffic": [ + { "date": "2026-05-22", "views": 210 }, + { "date": "2026-05-23", "views": 245 } + ], + "topPosts": [ + { + "id": 1, + "title": "RTR (Refresh Token Rotation)", + "slug": "rtr-(refresh-token-rotation)", + "categoryName": "Network", + "viewCount": 126, + "rangeViewCount": 44, + "commentCount": 0, + "createdAt": "2026-01-02T12:04:00+09:00", + "updatedAt": null + } + ], + "risingPosts": [], + "stalePopularPosts": [], + "recentPosts": [], + "recentComments": [], + "categoryStats": [], + "actionItems": { + "unansweredComments": 0, + "uncategorizedPosts": 0, + "stalePopularPosts": 0 + } + } +} +``` + +### 6.5 백엔드 준비 전 fallback + +프론트를 먼저 개발할 때는 다음 순서로 처리한다. + +1. `getAdminDashboard()`를 호출한다. +2. 404, 403, 네트워크 오류가 발생하면 기존 API 기반 fallback을 사용한다. +3. fallback으로 채울 수 없는 통계는 `null` 또는 비활성 카드로 표시한다. + +Fallback으로 사용 가능한 기존 API: + +```http +GET /api/posts?page=0&size=5&sort=createdAt,desc +GET /api/posts?page=0&size=5&sort=viewCount,desc +GET /api/categories +GET /api/admin/comments?page=0&size=5 +``` + +Fallback UI 문구: + +- `통계 API 연결 전` +- `백엔드 집계가 준비되면 표시됩니다.` + +프론트 구현 시 주의: + +- fallback은 개발 편의용이며, 관리자 통계의 최종 데이터는 반드시 `/api/admin/dashboard`에서 받는다. +- 공개 홈에서는 이 fallback 통계도 사용하지 않는다. + +## 7. 화면별 구현 계획 + +### 7.1 공통 UI 컴포넌트 + +새로 만들거나 정리할 컴포넌트: + +```text +src/components/ui/Surface.tsx +src/components/ui/MetricCard.tsx +src/components/ui/SegmentedControl.tsx +src/components/ui/IconButton.tsx +src/components/ui/StatusBadge.tsx +src/components/ui/EmptyState.tsx +``` + +원칙: + +- 기존 로컬 패턴을 먼저 따른다. +- 너무 범용적인 디자인 시스템으로 크게 만들지 않는다. +- 관리자 대시보드와 공개 화면에서 반복되는 surface, badge, segmented control 정도만 추출한다. + +### 7.2 홈 화면 파일 + +대상: + +```text +src/app/page.tsx +src/components/post/PostCard.tsx +src/components/post/PostListItem.tsx +src/components/layout/Sidebar.tsx +src/components/layout/TopHeader.tsx +``` + +작업: + +- 홈 상단 intro band 추가 +- 공지 strip 재정리 +- 최신 글 대표 카드 1개 + compact list 구성 +- 카테고리 shelf 추가 +- 영어 UI를 한국어로 통일 +- 공개 홈에서 운영 집계 제거 +- 카드 border, shadow, radius를 Apple style token에 맞게 조정 + +### 7.3 글 상세 화면 파일 + +대상: + +```text +src/app/posts/[slug]/page.tsx +src/components/post/PostDetailClient.tsx +src/components/post/MarkdownRenderer.tsx +src/components/post/TOC.tsx +``` + +작업: + +- 본문 max-width를 760-820px로 제한 +- TOC active 상태를 선명하게 표시 +- `ON THIS PAGE`를 `목차`로 변경 +- `views`를 `조회`로 변경 +- 이미지 caption과 본문 이미지 간격 정리 +- code block, blockquote, table을 Apple style surface에 맞게 조정 + +### 7.4 관리자 대시보드 파일 + +대상: + +```text +src/app/admin/page.tsx +src/components/admin/AdminRouteShell.tsx +src/api/dashboard.ts +src/types/index.ts +``` + +작업: + +- 기존 `/admin` 위젯을 새 dashboard layout으로 재구성 +- KPI cards 추가 +- traffic chart 추가 +- action center 추가 +- content performance 추가 +- category health 추가 +- `range` segmented control 추가 +- `/api/admin/dashboard` 연결 +- API 미구현 시 fallback 표시 + +### 7.5 관리자 shell + +현재 `AdminRouteShell`은 상단 pill nav 형태다. 대시보드가 풍성해지면 다음처럼 조정한다. + +- desktop: 상단 toolbar + compact nav +- mobile: horizontal scroll nav 유지 +- active 상태를 `bg-gray-950 text-white`보다 Apple style selected pill로 변경 +- 관리자 화면 전체 max-width를 1180-1280px로 확장 + +## 8. 단계별 개발 순서 + +### Phase 1: 디자인 토큰과 공개 UI 정리 + +1. `globals.css`에 color/token 기반 style 추가 +2. 공통 `Surface`, `StatusBadge`, `SegmentedControl` 구현 +3. 홈 영어 UI 한국어화 +4. 홈 intro, notice, featured/latest 구조 구현 +5. 글 상세 본문 폭과 TOC 스타일 개선 + +검증: + +```bash +npm run lint +npm run build +``` + +가능하면 브라우저로 확인: + +- `/` +- `/archive` +- `/category/잡담` +- `/posts/rtr-(refresh-token-rotation)` + +### Phase 2: 관리자 대시보드 프론트 골격 + +1. `src/types/index.ts`에 dashboard 타입 추가 +2. `src/api/dashboard.ts` 추가 +3. `/admin`을 dashboard section 구조로 분리 +4. fallback 데이터 연결 +5. 통계 API 미구현 상태 UI 구현 + +검증: + +```bash +npm run lint +npm run build +``` + +확인: + +- 로그아웃 상태에서 `/admin`은 `/login?redirect=/admin`으로 이동 +- 관리자 상태에서 `/admin` dashboard가 보임 +- `/api/admin/dashboard`가 404여도 화면이 깨지지 않음 + +### Phase 3: 백엔드 통계 API 연결 + +1. 백엔드에서 `/api/admin/dashboard` 구현 +2. 프론트 fallback 대신 실제 데이터 표시 +3. range segmented control이 `7d`, `30d`, `90d`를 전환 +4. top/rising/stale posts 데이터 표시 + +확인: + +- 오늘 조회수, 최근 7일, 최근 30일이 관리자에게만 보임 +- 공개 홈과 글 상세에는 일간/주간/월간 집계가 보이지 않음 + +### Phase 4: 다듬기 + +1. 다크 모드 대비 +2. reduced motion 대응 +3. skeleton UI 정리 +4. 모바일 레이아웃 확인 +5. 기존 lint baseline 정리 + +## 9. 접근성과 UX 기준 + +- icon-only button에는 `aria-label`을 반드시 둔다. +- segmented control은 현재 선택 값을 스크린리더가 알 수 있게 한다. +- 색상만으로 상태를 전달하지 않는다. +- hover만 있는 UI는 mobile에서 접근 불가능하므로 click/tap 상태를 제공한다. +- 관리자 삭제/로그아웃/권한 변경 같은 위험 행동은 기존 확인 패턴을 유지한다. +- 운영 통계는 관리자 권한 확인 후 렌더링한다. + +## 10. 완료 기준 + +프론트 리뉴얼 1차 완료 기준: + +- 홈 화면이 공개 통계 없이도 풍성하게 보인다. +- 글 상세 본문 폭과 TOC가 읽기 좋은 형태로 정리된다. +- 영어 UI가 한국어로 통일된다. +- `/admin`에 오늘/7일/30일 조회수 카드가 들어갈 자리가 생긴다. +- `/api/admin/dashboard` 미구현 상태에서도 관리자 대시보드가 깨지지 않는다. +- 백엔드 구현 후 같은 타입으로 바로 연결할 수 있다. +- `npm run build`가 통과한다. + diff --git a/docs/renewal-plan.md b/docs/renewal-plan.md deleted file mode 100644 index 1f489e2..0000000 --- a/docs/renewal-plan.md +++ /dev/null @@ -1,621 +0,0 @@ -# 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`, `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 기준선을 먼저 잡는 것이 좋다. diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts new file mode 100644 index 0000000..8de0f66 --- /dev/null +++ b/src/api/dashboard.ts @@ -0,0 +1,13 @@ +import { http } from './http'; +import { AdminDashboardResponse, ApiResponse, DashboardRange } from '@/types'; + +export const getAdminDashboard = async ( + range: DashboardRange = '30d', + timezone = 'Asia/Seoul', +) => { + const response = await http.get>('/api/admin/dashboard', { + params: { range, timezone }, + }); + + return response.data.data; +}; diff --git a/src/api/http.ts b/src/api/http.ts index 70e5fa9..192f89e 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import { useAuthStore } from '@/store/authStore'; // 🛠️ 환경 변수 처리 (배포 환경 대응) @@ -26,10 +26,33 @@ http.interceptors.request.use( // --- 토큰 갱신 관련 변수 --- let isRefreshing = false; -let failedQueue: any[] = []; +type QueuedRequest = { + resolve: (token: string | null) => void; + reject: (error: unknown) => void; +}; + +type AuthStorageData = { + state?: { + accessToken?: string; + refreshToken?: string; + }; +}; + +type RetriableRequestConfig = AxiosRequestConfig & { + _retry?: boolean; + headers?: Record; +}; + +type NavigatorWithLocks = Navigator & { + locks?: { + request: (name: string, callback: () => Promise | T) => Promise; + }; +}; + +let failedQueue: QueuedRequest[] = []; // 실패한 요청들을 큐에 담아두었다가 토큰 갱신 후 재시도하는 함수 -const processQueue = (error: any, token: string | null = null) => { +const processQueue = (error: unknown, token: string | null = null) => { failedQueue.forEach((prom) => { if (error) { prom.reject(error); @@ -43,7 +66,7 @@ const processQueue = (error: any, token: string | null = null) => { // 실제 토큰 갱신을 수행하는 함수 (Lock 안에서 실행됨) async function handleTokenRefresh() { try { - const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login, logout } = useAuthStore.getState(); + const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login } = useAuthStore.getState(); // [1] 로컬 스토리지 확인 (다른 탭에서 이미 갱신했는지 체크) let actualRefreshToken = currentRefreshToken; @@ -53,12 +76,12 @@ async function handleTokenRefresh() { const storageData = localStorage.getItem('auth-storage'); if (storageData) { try { - const parsed = JSON.parse(storageData); + const parsed = JSON.parse(storageData) as AuthStorageData; const storedRefreshToken = parsed.state?.refreshToken; const storedAccessToken = parsed.state?.accessToken; // 저장된 토큰이 현재 메모리의 토큰과 다르다면? => 이미 다른 탭/요청이 갱신을 완료함! - if (storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) { + if (storedAccessToken && storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) { // 현재 탭의 스토어 상태를 스토리지와 동기화 login(storedAccessToken, storedRefreshToken); // 대기 중인 요청들 해소 @@ -70,7 +93,7 @@ async function handleTokenRefresh() { // 갱신 시도할 토큰 정보를 최신 스토리지 값으로 설정 if (storedRefreshToken) actualRefreshToken = storedRefreshToken; if (storedAccessToken) actualAccessToken = storedAccessToken; - } catch (e) { + } catch { // console.error('Storage parse error', e); } } @@ -113,9 +136,10 @@ async function handleTokenRefresh() { http.interceptors.response.use( (response) => response, async (error) => { - const originalRequest = error.config; + const originalRequest = error.config as RetriableRequestConfig | undefined; if (!error.response) return Promise.reject(error); + if (!originalRequest) return Promise.reject(error); const status = error.response.status; @@ -143,9 +167,11 @@ http.interceptors.response.use( try { let newToken; - if (typeof navigator !== 'undefined' && 'locks' in navigator) { + const locks = typeof navigator !== 'undefined' ? (navigator as NavigatorWithLocks).locks : undefined; + + if (locks) { // Lock을 획득한 놈만 handleTokenRefresh 실행 - newToken = await (navigator as any).locks.request('auth-refresh-lock', async () => { + newToken = await locks.request('auth-refresh-lock', async () => { return await handleTokenRefresh(); }); } else { @@ -167,4 +193,4 @@ http.interceptors.response.use( return Promise.reject(error); } -); \ No newline at end of file +); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2844469..23db692 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,22 +1,37 @@ 'use client'; import Link from 'next/link'; +import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { ArrowRight, - Eye, + CalendarClock, FileText, - FolderTree, Loader2, - MessageSquareText, PenLine, Settings, - Sparkles, } from 'lucide-react'; +import { getAdminDashboard } from '@/api/dashboard'; import { getCategories } from '@/api/category'; import { getAdminComments } from '@/api/comments'; import { getPosts } from '@/api/posts'; -import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types'; +import AdminDashboardActionCenter from '@/components/admin/dashboard/AdminDashboardActionCenter'; +import AdminDashboardCategoryHealth from '@/components/admin/dashboard/AdminDashboardCategoryHealth'; +import AdminDashboardOverview from '@/components/admin/dashboard/AdminDashboardOverview'; +import AdminDashboardPostPerformance from '@/components/admin/dashboard/AdminDashboardPostPerformance'; +import AdminDashboardTrafficChart from '@/components/admin/dashboard/AdminDashboardTrafficChart'; +import EmptyState from '@/components/ui/EmptyState'; +import StatusBadge from '@/components/ui/StatusBadge'; +import Surface from '@/components/ui/Surface'; +import { + AdminComment, + AdminCommentListResponse, + Category, + DashboardPostStat, + DashboardRange, + PageMeta, + Post, +} from '@/types'; const countCategories = (categories: Category[] = []): number => { return categories.reduce((count, category) => { @@ -31,10 +46,10 @@ const emptyPageMeta: PageMeta = { last: true, }; -const formatDate = (value?: string, withTime = false) => { +const formatDate = (value?: string | Date, withTime = false) => { if (!value) return '-'; - const date = new Date(value); + const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return '-'; return new Intl.DateTimeFormat('ko-KR', { @@ -56,40 +71,51 @@ const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => { }; }; +const getPostTotal = (data?: { page?: PageMeta; totalElements?: number }) => { + return data?.page?.totalElements ?? data?.totalElements ?? 0; +}; + const getAuthorName = (comment: AdminComment) => { return comment.author || comment.memberNickname || comment.guestNickname || '익명'; }; -function PostRow({ post }: { post: Post }) { +const toPostStat = (post: Post): DashboardPostStat => ({ + id: post.id, + title: post.title, + slug: post.slug, + categoryName: post.categoryName, + viewCount: post.viewCount, + rangeViewCount: post.viewCount, + createdAt: post.createdAt, +}); + +function RecentPostRow({ post }: { post: Post }) { return (
-

{post.title}

-

+

{post.title}

+

{post.categoryName || '미분류'} · {formatDate(post.createdAt)}

-
- - {post.viewCount.toLocaleString()} -
+ ); } -function CommentRow({ comment }: { comment: AdminComment }) { +function RecentCommentRow({ comment }: { comment: AdminComment }) { const content = ( <>
-

{comment.content}

-

+

{comment.content}

+

{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}

- + ); @@ -97,7 +123,7 @@ function CommentRow({ comment }: { comment: AdminComment }) { return ( {content} @@ -107,7 +133,86 @@ function CommentRow({ comment }: { comment: AdminComment }) { return
{content}
; } +function RecentActivity({ + posts, + comments, + isLoading, +}: { + posts: Post[]; + comments: AdminComment[]; + isLoading: boolean; +}) { + return ( +
+ +
+
+

최근 글

+

발행 흐름을 빠르게 확인합니다.

+
+ + 관리 + + +
+ + {isLoading ? ( +
+ +
+ ) : posts.length > 0 ? ( +
+ {posts.slice(0, 5).map((post) => ( + + ))} +
+ ) : ( + + )} +
+ + +
+
+

최근 댓글

+

새 반응을 대시보드에서 바로 봅니다.

+
+ + 관리 + + +
+ + {isLoading ? ( +
+ +
+ ) : comments.length > 0 ? ( +
+ {comments.slice(0, 5).map((comment) => ( + + ))} +
+ ) : ( + + )} +
+
+ ); +} + export default function AdminPage() { + const [range, setRange] = useState('30d'); + + const { + data: dashboard, + isLoading: isDashboardLoading, + } = useQuery({ + queryKey: ['admin-dashboard', range], + queryFn: () => getAdminDashboard(range), + retry: 0, + }); + const { data: latestData, isLoading: isLatestLoading } = useQuery({ queryKey: ['posts', 'admin', 'latest'], queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }), @@ -132,42 +237,52 @@ export default function AdminPage() { 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; + const recentComments = dashboard?.recentComments ?? commentsData?.content ?? []; + const recentPosts = dashboard?.recentPosts ?? latestPosts; + const totalPosts = dashboard?.overview.totalPosts ?? getPostTotal(latestData); + const totalCategories = dashboard?.overview.totalCategories ?? countCategories(categories); + const totalComments = dashboard?.overview.totalComments ?? getCommentListMeta(commentsData).totalElements; + const isFallback = !dashboard; + const isFallbackLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading; + const topPosts = useMemo( + () => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat), + [dashboard?.topPosts, popularData?.content], + ); return (
-
+
-

+ - Admin -

-

+ 관리자 + +

관리자 대시보드

-

- 공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다. +

+ 공개 화면과 분리된 운영 공간입니다. 통계 API가 준비되기 전에도 기존 데이터로 운영 흐름을 확인합니다. +

+

+ + 오늘 {formatDate(new Date())} + {isDashboardLoading && ' · 통계 API 확인 중'} + {isFallback && !isDashboardLoading && ' · fallback 데이터 사용 중'}

게시글 관리 새 글 작성 @@ -175,126 +290,46 @@ export default function AdminPage() {
-
-
-
- 총 게시글 - -
-

{totalPosts.toLocaleString()}

-
+ -
-
- 카테고리 - -
-

{totalCategories.toLocaleString()}

-
+ -
-
- 총 댓글 - -
-

{totalComments.toLocaleString()}

-
+
+ + +
-
-
- 최근 업데이트 - -
-

{formatDate(lastUpdatedAt)}

-
-
+ -
-
-
-
-

최근 글

-

발행 흐름을 빠르게 확인합니다.

-
- - 관리 - - -
- - {isLoading ? ( -
- -
- ) : latestPosts.length > 0 ? ( -
- {latestPosts.map((post) => ( - - ))} -
- ) : ( -
- 아직 작성된 글이 없습니다. -
- )} -
- -
-

인기 글

-

조회수가 높은 글을 확인합니다.

- - {isLoading ? ( -
- -
- ) : popularPosts.length > 0 ? ( -
- {popularPosts.map((post) => ( - - ))} -
- ) : ( -
- 아직 집계된 인기 글이 없습니다. -
- )} -
- -
-
-
-

최근 댓글 5개

-

새 반응을 대시보드에서 바로 봅니다.

-
- - 관리 - - -
- - {isCommentsLoading ? ( -
- -
- ) : recentComments.length > 0 ? ( -
- {recentComments.map((comment) => ( - - ))} -
- ) : ( -
- 아직 작성된 댓글이 없습니다. -
- )} -
-
+
); } diff --git a/src/app/archive/page.tsx b/src/app/archive/page.tsx index e5a96ae..581d79f 100644 --- a/src/app/archive/page.tsx +++ b/src/app/archive/page.tsx @@ -7,7 +7,11 @@ import { Loader2, Calendar, Archive, FileText, ChevronRight } from 'lucide-react import { useMemo } from 'react'; import Link from 'next/link'; import { format } from 'date-fns'; -import { clsx } from 'clsx'; +import { PostListResponse } from '@/types'; + +const getTotalElements = (data?: PostListResponse) => { + return data?.page?.totalElements ?? data?.totalElements ?? 0; +}; export default function ArchivePage() { // 1. 전체 게시글 조회 (최대 1000개) @@ -44,8 +48,7 @@ export default function ArchivePage() { // 🛠️ 총 게시글 수 수정 (백엔드 PagedModel 대응) // page 정보가 data 안에 직접 있거나, page 객체 안에 있을 수 있음 - const meta = (data as any)?.page || data; - const totalPosts = meta?.totalElements || 0; + const totalPosts = getTotalElements(data); if (isLoading) { return ( @@ -61,7 +64,7 @@ export default function ArchivePage() {

- Archives + 아카이브

지금까지 작성한 {totalPosts}개의 글이 기록되어 있습니다. @@ -149,4 +152,4 @@ export default function ArchivePage() { )}

); -} \ No newline at end of file +} diff --git a/src/app/category/[id]/page.tsx b/src/app/category/[id]/page.tsx index 5d09b59..259c9d9 100644 --- a/src/app/category/[id]/page.tsx +++ b/src/app/category/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { use, useState, useEffect } from 'react'; +import { use, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용 import PostCard from '@/components/post/PostCard'; @@ -18,17 +18,14 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string const [page, setPage] = useState(0); const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태 - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); - const PAGE_SIZE = 10; + const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => { + if (isNoticeCategory || typeof window === 'undefined') return isNoticeCategory ? 'list' : 'grid'; - useEffect(() => { - if (isNoticeCategory) { - setViewMode('list'); - return; - } - const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list'; - if (savedMode) setViewMode(savedMode); - }, [isNoticeCategory]); + const savedMode = localStorage.getItem('postViewMode'); + return savedMode === 'list' ? 'list' : 'grid'; + }); + const PAGE_SIZE = 10; + const activeViewMode = isNoticeCategory ? 'list' : viewMode; const handleViewModeChange = (mode: 'grid' | 'list') => { setViewMode(mode); @@ -75,8 +72,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string const posts = postsData?.content || []; // 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음) - // postsData가 any로 캐스팅되어 안전하게 접근 - const pagingData = (postsData as any)?.page || postsData; + const pagingData = postsData?.page || postsData; const totalElements = pagingData?.totalElements ?? 0; const totalPages = pagingData?.totalPages ?? 0; // page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용 @@ -103,7 +99,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string {/* 🔍 카테고리 내 검색바 */} @@ -113,9 +109,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string onClick={() => handleViewModeChange('grid')} className={clsx( "p-2 rounded-md transition-all duration-200", - viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600" + activeViewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600" )} title="카드형 보기" + aria-label="카드형 보기" > @@ -123,9 +120,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string onClick={() => handleViewModeChange('list')} className={clsx( "p-2 rounded-md transition-all duration-200", - viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600" + activeViewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600" )} title="리스트형 보기" + aria-label="리스트형 보기" > @@ -138,7 +136,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
- "{keyword}" 검색 결과: {totalElements}건 + 검색어 {keyword} 결과: {totalElements}
)} @@ -146,12 +144,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string {posts.length === 0 ? (

- {keyword ? `"${keyword}"에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'} + {keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}

) : ( <> - {viewMode === 'grid' ? ( + {activeViewMode === 'grid' ? (
{posts.map((post) => ( @@ -176,7 +174,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string - Page {page + 1} {totalPages > 0 && `/ ${totalPages}`} + 페이지 {page + 1} {totalPages > 0 && `/ ${totalPages}`}