This commit is contained in:
55
LOG.md
55
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 `<img>` warning in `PostDetailClient`; `tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; local dev server on port 3100 returned HTTP 200 for `/admin/posts/new`, `/admin/posts/sample/edit`, and `/write`. Full `npm run lint` still fails on known baseline issues outside this task.
|
||||
- Next task: continue with non-design quality work by cleaning the remaining lint baseline (`src/api/http.ts`, category/archive/home/login/signup/comment form/list, Markdown renderer, TOC, auth store, and Tailwind config).
|
||||
- Split admin management into dedicated routes under a shared guarded admin shell: `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/admin/profile`; `/admin` is now a dashboard only.
|
||||
- Added admin dashboard recent comments and total comments widgets, plus defensive `getAdminComments` normalization for possible `author`, `postSlug`, and `postTitle` response aliases.
|
||||
- Enhanced admin post management with category filtering, direct page-number navigation, current-page row selection, and bulk delete via the existing single-delete API.
|
||||
- Added login return handling with `/login?redirect=...`, kept `/write` as the chosen legacy compatibility redirect, and documented these decisions in `docs/renewal-plan.md`.
|
||||
- Validation: unauthenticated live-server request to `https://blogserver.wypark.me/api/admin/comments?page=0&size=1` returned HTTP 403 with an empty body, so exact admin comment fields still require an authenticated admin token/session; targeted ESLint passed for the changed admin/login/comment API files; `npx tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; full `npm run lint` still fails on known baseline issues outside this task; local dev route checks returned HTTP 200 for `/admin`, `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/login?redirect=%2Fadmin%2Fcomments`; in-app browser verification confirmed `/admin/comments` redirects to `/login?redirect=%2Fadmin%2Fcomments` when logged out.
|
||||
- Next task: authenticate against the real admin API to capture the exact `getAdminComments` payload, then clean the existing lint baseline so full `npm run lint` can become a reliable gate.
|
||||
- `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 전환을 최종 확인한다.
|
||||
|
||||
814
docs/apple-style-frontend-development-guide.md
Normal file
814
docs/apple-style-frontend-development-guide.md
Normal file
@@ -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<ApiResponse<AdminDashboardResponse>>('/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`가 통과한다.
|
||||
|
||||
@@ -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<T>`, `AdminComment` 등 누락 타입 추가
|
||||
- 페이지 메타 구조가 `data.page` 또는 기존 `data` 양쪽에 대응하는 부분을 타입으로 흡수
|
||||
- `loding.tsx`를 `loading.tsx`로 수정
|
||||
- React Query Devtools는 개발 환경에서만 렌더링
|
||||
- 브라우저 기본 `alert`, `confirm`, `prompt`를 주요 관리자 플로우에서 커스텀 모달/토스트로 교체
|
||||
|
||||
## 4. 제안하는 정보 구조
|
||||
|
||||
### 공개 페이지
|
||||
|
||||
```text
|
||||
/
|
||||
/archive
|
||||
/category/[id]
|
||||
/posts/[slug]
|
||||
/login
|
||||
/signup
|
||||
```
|
||||
|
||||
공개 페이지는 읽기 경험을 중심으로 유지한다. 관리자 전용 조작 버튼은 최소화하거나 관리자 페이지로 연결하는 정도로만 둔다.
|
||||
|
||||
### 관리자 페이지
|
||||
|
||||
```text
|
||||
/admin
|
||||
/admin/posts
|
||||
/admin/posts/new
|
||||
/admin/posts/[slug]/edit
|
||||
/admin/categories
|
||||
/admin/profile
|
||||
/admin/comments
|
||||
/admin/settings
|
||||
```
|
||||
|
||||
현재 구현에서는 `/admin` 대시보드와 세부 관리 라우트를 분리한다. 기존 `/write`는 호환 redirect로만 유지한다.
|
||||
|
||||
권장 MVP:
|
||||
|
||||
- `/admin` 대시보드
|
||||
- `/admin/posts` 게시글 관리 및 작성 버튼
|
||||
- `/admin/categories` 카테고리 관리
|
||||
- `/admin/profile` 프로필 관리
|
||||
|
||||
댓글 관리는 이후 단계로 분리해도 된다.
|
||||
|
||||
## 5. 홈 대시보드 리뉴얼 상세
|
||||
|
||||
### 현재 데이터 활용
|
||||
|
||||
기존 `getPosts`만으로 다음 데이터를 구성할 수 있다.
|
||||
|
||||
- 최신 글: `getPosts({ size: 8, sort: 'createdAt,desc' })`
|
||||
- 인기 글: `getPosts({ size: 5, sort: 'viewCount,desc' })`
|
||||
- 공지: `getPosts({ category: '공지', size: 3 })`
|
||||
- 전체 글 수: 응답의 `totalElements`
|
||||
- 카테고리: `getCategories()`
|
||||
|
||||
### 레이아웃 제안
|
||||
|
||||
```text
|
||||
홈
|
||||
├─ 상단: 블로그 이름, 짧은 소개, 검색
|
||||
├─ 공지 스트립: 최신 공지 1~3개
|
||||
├─ 메인 컬럼: 최신 글 목록 6~8개
|
||||
├─ 보조 컬럼: 인기 글, 카테고리 요약, 최근 업데이트
|
||||
└─ 하단: 아카이브 진입, 태그/카테고리 탐색
|
||||
```
|
||||
|
||||
디자인 방향:
|
||||
|
||||
- 최신 글은 카드보다 리스트형을 기본으로 하여 정보 밀도 확보
|
||||
- 대표 글 1개만 살짝 강조하고 나머지는 compact list
|
||||
- 인기 글은 조회수와 카테고리를 함께 표시
|
||||
- 공지는 빨간색 강조보다 얇은 라인과 작은 배지로 처리
|
||||
- 검색 결과 화면에서는 “검색어, 결과 수, 정렬, 목록”만 집중해서 표시
|
||||
|
||||
## 6. 관리자 페이지 설계
|
||||
|
||||
### 접근 제어
|
||||
|
||||
프론트에서는 `useAuthStore`의 `_hasHydrated`와 `role?.includes('ADMIN')`를 기준으로 관리자 버튼과 페이지 접근을 제어한다. 백엔드 admin API가 최종 권한 검사를 담당한다는 전제는 유지한다.
|
||||
|
||||
권장 동작:
|
||||
|
||||
- 비로그인: `/login?redirect=/admin...`로 이동하고 로그인 성공 후 원래 가려던 관리자 경로로 복귀
|
||||
- 일반 회원: “관리자 권한이 필요합니다” 토스트 후 홈으로 이동
|
||||
- 관리자: 관리자 메뉴 노출 및 `/admin` 접근 허용
|
||||
|
||||
### 상단 헤더 변경
|
||||
|
||||
현재 `TopHeader`는 관리자에게 글쓰기 버튼만 보여준다. 리뉴얼 후에는 다음처럼 바꾼다.
|
||||
|
||||
- 비로그인: 로그인, 회원가입
|
||||
- 일반 회원: 로그아웃
|
||||
- 관리자: 관리자 설정, 새 글, 로그아웃
|
||||
|
||||
“관리자 설정”은 명확히 `/admin`으로 이동한다. 일반 회원에게는 렌더링하지 않는다.
|
||||
|
||||
### 사이드바 변경
|
||||
|
||||
사이드바는 공개 탐색용으로만 둔다.
|
||||
|
||||
제거할 기능:
|
||||
|
||||
- 프로필 수정 모달
|
||||
- 카테고리 편집 모드
|
||||
- 카테고리 추가/삭제/이동 버튼
|
||||
- 관리자 전용 hover 편집 버튼
|
||||
|
||||
유지할 기능:
|
||||
|
||||
- 프로필 표시
|
||||
- 검색
|
||||
- 아카이브 링크
|
||||
- 카테고리 트리 탐색
|
||||
- Github/Email 링크
|
||||
|
||||
### 관리자 대시보드
|
||||
|
||||
`/admin` 첫 화면은 운영 현황을 빠르게 보여준다.
|
||||
|
||||
추천 위젯:
|
||||
|
||||
- 총 게시글 수
|
||||
- 카테고리 수
|
||||
- 최근 작성 글 5개
|
||||
- 인기 글 5개
|
||||
- 최근 댓글 5개
|
||||
- 빠른 작업: 새 글 작성, 카테고리 관리, 프로필 수정
|
||||
|
||||
### 게시글 관리
|
||||
|
||||
기존 `/write` 기능을 재사용하되 관리자 흐름으로 재배치한다.
|
||||
|
||||
필요 UI:
|
||||
|
||||
- 검색/필터/정렬 가능한 게시글 테이블
|
||||
- 카테고리 필터
|
||||
- 제목, 카테고리, 날짜, 조회수, 작업 버튼
|
||||
- 페이지 번호 직접 이동
|
||||
- 현재 페이지 선택 및 대량 삭제
|
||||
- 새 글 작성 버튼
|
||||
- 수정 버튼
|
||||
- 삭제 전 확인 모달
|
||||
|
||||
백엔드 유지 조건에서는 공개 `getPosts`를 관리 목록에도 사용한다. 비공개 글 같은 개념이 없다면 별도 admin list API 없이 충분하다.
|
||||
대량 삭제는 별도 bulk API가 없으므로 기존 단건 삭제 API를 선택 항목별로 호출한다.
|
||||
|
||||
### 카테고리 관리
|
||||
|
||||
사이드바에서 하던 기능을 `/admin/categories`로 이동한다.
|
||||
|
||||
필요 UI:
|
||||
|
||||
- 트리 형태 카테고리 목록
|
||||
- 카테고리 생성
|
||||
- 이름 변경
|
||||
- 부모 변경 또는 드래그 이동
|
||||
- 삭제 확인
|
||||
- 변경 후 `['categories']`, `['posts']` 관련 쿼리 무효화
|
||||
|
||||
현재 API는 `updateCategory(id, { name, parentId })` 형식이므로 이름 변경과 이동 모두 가능하다.
|
||||
|
||||
### 프로필 관리
|
||||
|
||||
현재 사이드바 모달 기능을 `/admin/profile`로 이동한다.
|
||||
|
||||
필요 UI:
|
||||
|
||||
- 이름, 소개, 이미지, Github URL, Email 편집
|
||||
- 이미지 업로드
|
||||
- 미리보기
|
||||
- 저장 후 프로필 쿼리 무효화
|
||||
|
||||
### 댓글 관리
|
||||
|
||||
이미 `getAdminComments`와 `deleteAdminComment`가 존재하므로 관리자 화면에 편입할 수 있다.
|
||||
|
||||
필요 UI:
|
||||
|
||||
- 최근 댓글 목록
|
||||
- 작성자, 게시글, 내용, 작성일
|
||||
- 댓글 위치로 이동
|
||||
- 관리자 삭제
|
||||
|
||||
공개 댓글 목록의 일반 사용자/비회원 댓글 삭제 UX는 공개 기능으로 유지한다. 관리자 전용 강제 삭제만 `/admin/comments`로 분리한다.
|
||||
|
||||
단, `getAdminComments` 실제 필드 검증에는 관리자 인증이 필요하다. 2026-05-28 비인증 실서버 요청은 HTTP 403과 빈 본문을 반환했으므로, 프론트에서는 `postSlug`, `postTitle`, `author` 외에 중첩 `post`, `postResponse`, `member`, `guest` 등 가능한 alias를 정규화해 방어적으로 대응한다.
|
||||
|
||||
## 7. 마크다운 렌더러 개선 상세
|
||||
|
||||
### 플러그인 구성
|
||||
|
||||
권장 파이프라인:
|
||||
|
||||
```ts
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkBreaks,
|
||||
remarkMath,
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeSlug,
|
||||
[rehypeAutolinkHeadings, { behavior: 'append' }],
|
||||
rehypeKatex,
|
||||
[rehypeSanitize, customSchema],
|
||||
]}
|
||||
```
|
||||
|
||||
보안상 `rehype-sanitize`는 반드시 유지한다. 다만 기본 schema가 너무 좁거나 플러그인 class를 제거할 수 있으므로 `customSchema`를 명시한다.
|
||||
|
||||
### 지원할 문법
|
||||
|
||||
- 제목 anchor 및 TOC 연동
|
||||
- GFM 표
|
||||
- 체크리스트
|
||||
- 취소선
|
||||
- inline code/code block
|
||||
- 수식: inline/block KaTeX
|
||||
- 일반 줄바꿈
|
||||
- 이미지 캡션
|
||||
- 외부 링크 새 탭
|
||||
- 코드 복사
|
||||
|
||||
### 본문 스타일
|
||||
|
||||
본문은 `MarkdownRenderer` 내부에 모든 스타일을 몰아넣기보다, `globals.css` 또는 전용 `markdown.css`에서 `.markdown-content`를 정리하는 편이 낫다.
|
||||
|
||||
권장 스타일:
|
||||
|
||||
- 본문 최대 너비: 720~820px
|
||||
- 한글 행간: `leading-8` 수준
|
||||
- 문단 간격: 과하지 않게 일정하게
|
||||
- heading은 큰 장식보다 명확한 계층 중심
|
||||
- blockquote는 callout처럼 보이되 색상 과다 사용 금지
|
||||
- code block은 가로 스크롤과 긴 줄 처리를 안정화
|
||||
- table은 모바일 overflow 대응
|
||||
|
||||
### TOC 개선
|
||||
|
||||
현재 TOC는 마크다운 원문을 정규식으로 다시 파싱한다. 렌더러와 TOC가 서로 다른 방식으로 heading을 만들 수 있으므로 slug 생성 유틸을 공유한다.
|
||||
|
||||
개선안:
|
||||
|
||||
- `src/utils/markdown.ts` 추가
|
||||
- `extractHeadings(content)` 제공
|
||||
- `MarkdownRenderer`와 `TOC`가 같은 slug 생성 규칙 사용
|
||||
- h1~h3 또는 h2~h3만 표시할지 정책 결정
|
||||
|
||||
## 8. 디자인 시스템 방향
|
||||
|
||||
### 디자인 레퍼런스
|
||||
|
||||
전체 디자인 방향은 Apple의 제품 UI와 웹사이트에서 느껴지는 미니멀하고 정제된 스타일을 참고한다. 단, Apple의 로고, 아이콘, 제품 이미지, 고유한 브랜드 표현을 직접 복제하지 않고, 다음 원칙을 블로그 맥락에 맞게 재해석한다.
|
||||
|
||||
- 넓고 명확한 여백
|
||||
- 얇은 선과 낮은 대비의 경계
|
||||
- 과장되지 않은 입체감
|
||||
- 선명한 타이포그래피 위계
|
||||
- 부드러운 전환과 즉각적인 피드백
|
||||
- 불필요한 장식보다 콘텐츠 자체를 돋보이게 하는 구성
|
||||
- 모바일과 데스크톱 모두에서 손에 익은 네이티브 앱 같은 조작감
|
||||
|
||||
### 전체 톤
|
||||
|
||||
목표는 “Apple-inspired 미니멀 블로그”다. 화면은 깨끗하고 조용하지만 비어 보이지 않아야 하며, 정보는 정돈된 레이어와 타이포그래피로 충분히 채운다.
|
||||
|
||||
키워드:
|
||||
|
||||
- Apple-inspired
|
||||
- 조용한 대비
|
||||
- 선명하고 큰 타이포그래피
|
||||
- 적당한 정보 밀도
|
||||
- 적은 장식
|
||||
- 빠른 탐색
|
||||
- 부드러운 물성
|
||||
- 정제된 인터랙션
|
||||
|
||||
### 색상
|
||||
|
||||
현재 파란색이 대부분의 강조 색으로 쓰인다. Apple 스타일을 참고해 흰색, off-white, neutral gray를 넓게 사용하고, 파란색은 주요 액션과 링크에만 선명하게 사용한다. 전체 화면이 파란색 테마처럼 보이지 않도록 중립색을 기본값으로 둔다.
|
||||
|
||||
권장:
|
||||
|
||||
- 배경: white, near-white, neutral-50 계열
|
||||
- 본문 텍스트: neutral/slate 계열의 높은 가독성 색상
|
||||
- 보조 텍스트: neutral-400~500 계열
|
||||
- 링크/주요 액션: Apple blue에 가까운 명확한 blue 계열
|
||||
- 위험 액션: red 계열을 작고 단호하게 사용
|
||||
- 성공/저장 완료: green 계열을 토스트/상태 표시에 제한적으로 사용
|
||||
- 공지: red 대신 amber 또는 muted red를 작은 배지/라인으로 제한 사용
|
||||
|
||||
### Radius와 Shadow
|
||||
|
||||
읽기 화면은 큰 radius와 강한 shadow를 줄인다. Apple UI처럼 표면이 떠 있는 느낌은 주되, 그림자가 먼저 보이지 않게 한다. 깊이감은 `border`, 배경 레이어, 아주 약한 shadow, blur가 있는 overlay로 만든다.
|
||||
|
||||
권장:
|
||||
|
||||
- 일반 버튼/입력: `rounded-lg` 또는 pill 형태
|
||||
- 목록 아이템: `rounded-md` 또는 border-only
|
||||
- 모달/드롭다운: `rounded-xl`
|
||||
- 반복 카드: shadow보다 border와 hover background 중심
|
||||
- floating panel: 얇은 border + 약한 shadow + 흰색/반투명 배경
|
||||
- destructive dialog: 시각적으로 단호하되 과한 빨간 배경은 피함
|
||||
|
||||
### Typography
|
||||
|
||||
Apple 스타일의 핵심은 장식보다 타이포그래피의 밀도와 리듬이다. 블로그에서는 글 제목, 섹션 제목, 본문, 보조 정보의 위계를 선명히 나누고, 한글 가독성을 우선한다.
|
||||
|
||||
권장:
|
||||
|
||||
- 큰 제목은 과감하게 쓰되 한 화면에 너무 많은 hero 텍스트를 두지 않음
|
||||
- 본문은 16~18px, 넉넉한 행간, 적절한 문단 간격 유지
|
||||
- 날짜/조회수/카테고리 같은 메타 정보는 작고 차분하게 처리
|
||||
- 버튼 라벨은 짧게 유지하고 아이콘과 함께 사용
|
||||
- letter spacing은 기본값에 가깝게 두고, 한글에 과한 자간을 적용하지 않음
|
||||
|
||||
### Motion
|
||||
|
||||
인터랙션은 빠르고 조용해야 한다. Apple UI처럼 화면 전환, hover, active, modal open/close에 미세한 움직임을 주되, 블로그 읽기를 방해하는 큰 애니메이션은 피한다.
|
||||
|
||||
권장:
|
||||
|
||||
- hover: 배경색 변화, 살짝 올라감, 아주 약한 shadow
|
||||
- active: 짧은 scale down 또는 색상 변화
|
||||
- modal/sheet: fade + subtle scale/slide
|
||||
- sidebar/mobile menu: 부드러운 slide
|
||||
- skeleton/loading: 과하게 번쩍이지 않는 pulse
|
||||
- motion duration: 150~250ms 중심
|
||||
|
||||
### 레이아웃
|
||||
|
||||
데스크톱:
|
||||
|
||||
- 좌측 사이드바 유지
|
||||
- 본문은 route별 최대 너비 분리
|
||||
- 홈은 2-column 대시보드
|
||||
- 글 상세는 본문 + 우측 TOC
|
||||
- 관리자 화면은 macOS 설정 앱처럼 좌측 섹션 내비게이션 + 우측 상세 패널 구조 우선 검토
|
||||
|
||||
모바일:
|
||||
|
||||
- 사이드바 토글을 유지하되 탐색 경험 단순화
|
||||
- 관리자 페이지는 탭/섹션을 세로 스택으로
|
||||
- 글 목록은 리스트형 우선
|
||||
- 버튼 라벨이 줄바꿈되어도 깨지지 않게 고정 폭/반응형 처리
|
||||
- 주요 액션은 터치 타겟을 충분히 확보하고, 하단 sheet/compact menu 패턴을 적극 사용
|
||||
|
||||
## 9. 코드 구조 리팩터링 제안
|
||||
|
||||
### 새 디렉터리
|
||||
|
||||
```text
|
||||
src/components/admin/
|
||||
src/components/common/
|
||||
src/components/markdown/
|
||||
src/components/post/list/
|
||||
src/lib/
|
||||
src/utils/
|
||||
```
|
||||
|
||||
### 주요 분리 대상
|
||||
|
||||
- `Sidebar.tsx`: 공개 사이드바와 관리자 기능 분리
|
||||
- `TopHeader.tsx`: auth action과 admin entry 분리
|
||||
- `write/page.tsx`: 에디터 로직, 임시저장, 이미지 업로드, 카테고리 선택을 컴포넌트로 분리
|
||||
- `MarkdownRenderer.tsx`: 렌더러, 코드 블록, 이미지, 링크, sanitize schema 분리
|
||||
- `PostCard`/`PostListItem`: 목록 UI 변형을 통합 또는 명확히 분리
|
||||
|
||||
### 타입 보강
|
||||
|
||||
추가 권장 타입:
|
||||
|
||||
```ts
|
||||
interface PageMeta {
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number?: number;
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
interface PostSaveRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface AdminComment {
|
||||
id: number;
|
||||
content: string;
|
||||
author: string;
|
||||
postSlug?: string;
|
||||
postTitle?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 단계별 실행 계획
|
||||
|
||||
### Phase 0. 기준선 정리
|
||||
|
||||
- `npm ci`로 의존성 설치
|
||||
- `npm run lint`, `npm run build` 현재 상태 확인
|
||||
- `src/app/posts/[slug]/loding.tsx` 파일명 오류 확인 후 수정 계획 반영
|
||||
- `ReactQueryDevtools` 개발 환경 조건부 렌더링 계획 반영
|
||||
- 현재 화면 스크린샷 기록
|
||||
|
||||
### Phase 1. 관리자 진입점과 라우트 신설
|
||||
|
||||
- `/admin` 라우트 추가
|
||||
- 관리자 가드 컴포넌트 추가
|
||||
- `TopHeader`에 관리자 설정 버튼 추가
|
||||
- 일반 회원/비로그인에서는 관리자 버튼 비노출
|
||||
- `robots.ts`의 `/admin` 차단 유지
|
||||
|
||||
### Phase 2. 공개 사이드바 정리
|
||||
|
||||
- `Sidebar`에서 관리자 편집 상태 제거
|
||||
- 카테고리 편집/프로필 수정 로직을 관리자 컴포넌트로 이동
|
||||
- 사이드바는 탐색, 검색, 프로필 표시만 담당
|
||||
- 모바일 사이드바 열림 기본값과 overlay UX 점검
|
||||
|
||||
### Phase 3. 관리자 기능 구현
|
||||
|
||||
- 관리자 대시보드 카드/리스트 구현
|
||||
- 게시글 관리 목록 구현
|
||||
- 기존 글쓰기 화면을 관리자 작성/수정 흐름으로 연결
|
||||
- 카테고리 관리 화면 구현
|
||||
- 프로필 관리 화면 구현
|
||||
- 댓글 관리 화면은 응답 타입 확인 후 구현
|
||||
|
||||
### Phase 4. 홈 대시보드 리뉴얼
|
||||
|
||||
- 최신 글 중심 레이아웃으로 개편
|
||||
- 공지/인기글/카테고리 요약 패널 추가
|
||||
- 검색 결과 모드 재정리
|
||||
- 빈 상태, 로딩 상태, 오류 상태 디자인 통일
|
||||
|
||||
### Phase 5. 마크다운 렌더러 개선
|
||||
|
||||
- markdown 관련 컴포넌트와 유틸 분리
|
||||
- `remark-breaks`, `remark-math`, `rehype-katex`, `rehype-autolink-headings` 적용
|
||||
- sanitize schema 확장
|
||||
- 본문 CSS 정리
|
||||
- TOC slug 공유
|
||||
- 코드/표/이미지/수식/체크리스트 테스트 글로 검증
|
||||
|
||||
### Phase 6. 디자인 폴리싱
|
||||
|
||||
- 버튼, 입력, 모달, 리스트, 배지 공통 스타일 정리
|
||||
- 카드 그림자와 radius 정리
|
||||
- 모바일 레이아웃 검증
|
||||
- 한국어 문구 톤 통일
|
||||
- 접근성: label, aria-label, focus ring, keyboard navigation 점검
|
||||
|
||||
### Phase 7. 검증과 배포 전 점검
|
||||
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
- 로컬 dev 서버에서 핵심 플로우 확인
|
||||
- 관리자 계정/일반 계정/비로그인 상태별 UI 확인
|
||||
- 백엔드 미연결 상태에서 graceful loading/error 확인
|
||||
- 배포 환경 `NEXT_PUBLIC_API_URL` 확인
|
||||
|
||||
## 11. 검증 체크리스트
|
||||
|
||||
### 인증/권한
|
||||
|
||||
- 비로그인 사용자는 관리자 버튼을 볼 수 없다.
|
||||
- 일반 회원은 관리자 버튼을 볼 수 없다.
|
||||
- 관리자만 `/admin`에 접근할 수 있다.
|
||||
- 관리자 API 실패 시 토큰이나 민감 정보가 노출되지 않는다.
|
||||
|
||||
### 공개 화면
|
||||
|
||||
- 홈이 비어 보이지 않고 최신 글 목록이 충분히 보인다.
|
||||
- 검색 결과가 명확하게 분리되어 보인다.
|
||||
- 카테고리 페이지의 grid/list 전환이 유지된다.
|
||||
- 글 상세의 본문 너비와 TOC 위치가 안정적이다.
|
||||
- 모바일에서 사이드바와 상단 버튼이 겹치지 않는다.
|
||||
|
||||
### 관리자 화면
|
||||
|
||||
- 게시글 작성/수정/삭제 후 관련 쿼리가 갱신된다.
|
||||
- 카테고리 생성/수정/삭제/이동 후 사이드바가 갱신된다.
|
||||
- 프로필 저장 후 공개 사이드바가 갱신된다.
|
||||
- 삭제 액션은 확인 모달을 거친다.
|
||||
- 로딩/비활성/오류/성공 상태가 모두 보인다.
|
||||
|
||||
### 마크다운
|
||||
|
||||
- heading anchor와 TOC가 같은 위치로 이동한다.
|
||||
- 코드 블록 언어 표시와 복사가 동작한다.
|
||||
- 표가 모바일에서 깨지지 않는다.
|
||||
- 이미지가 본문 폭을 넘지 않는다.
|
||||
- 수식이 렌더링된다.
|
||||
- 악성 HTML/script는 sanitize된다.
|
||||
|
||||
## 12. 우선순위
|
||||
|
||||
가장 먼저 할 일:
|
||||
|
||||
1. `/admin` 라우트와 관리자 버튼 추가
|
||||
2. 사이드바 관리자 기능 제거 및 관리자 페이지로 이동
|
||||
3. 홈 대시보드 정보 밀도 개선
|
||||
4. 마크다운 렌더러 개선
|
||||
5. 디자인 토큰/공통 컴포넌트 정리
|
||||
|
||||
리뉴얼 체감이 가장 큰 조합은 “관리자 기능 분리 + 홈 대시보드 개편 + 본문 타이포그래피 개선”이다. 이 세 가지를 먼저 끝내면 블로그가 보는 화면과 관리하는 화면 모두에서 확실히 달라진다.
|
||||
|
||||
## 13. 확인된 현재 검증 상태
|
||||
|
||||
현재 로컬에 `node_modules`가 없어 `npm run lint`는 실행되지 않았다. 오류는 다음과 같다.
|
||||
|
||||
```text
|
||||
'eslint' is not recognized as an internal or external command
|
||||
```
|
||||
|
||||
리뉴얼 작업을 시작하기 전 `npm ci`로 의존성을 설치한 뒤 lint/build 기준선을 먼저 잡는 것이 좋다.
|
||||
13
src/api/dashboard.ts
Normal file
13
src/api/dashboard.ts
Normal file
@@ -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<ApiResponse<AdminDashboardResponse>>('/api/admin/dashboard', {
|
||||
params: { range, timezone },
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
type NavigatorWithLocks = Navigator & {
|
||||
locks?: {
|
||||
request: <T>(name: string, callback: () => Promise<T> | T) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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"
|
||||
className="group flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<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">
|
||||
<p className="truncate text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">{post.title}</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
|
||||
{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>
|
||||
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" size={15} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
function RecentCommentRow({ comment }: { comment: AdminComment }) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
|
||||
<p className="mt-1 truncate text-xs text-gray-400">
|
||||
<p className="line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">{comment.content}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--color-text-subtle)]">
|
||||
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="shrink-0 text-gray-300" size={15} />
|
||||
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)]" size={15} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -97,7 +123,7 @@ function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${comment.postSlug}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
@@ -107,7 +133,86 @@ function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>;
|
||||
}
|
||||
|
||||
function RecentActivity({
|
||||
posts,
|
||||
comments,
|
||||
isLoading,
|
||||
}: {
|
||||
posts: Post[];
|
||||
comments: AdminComment[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<Surface className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최근 글</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/posts" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={28} />
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{posts.slice(0, 5).map((post) => (
|
||||
<RecentPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 작성된 글이 없습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최근 댓글</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">새 반응을 대시보드에서 바로 봅니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/comments" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={28} />
|
||||
</div>
|
||||
) : comments.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{comments.slice(0, 5).map((comment) => (
|
||||
<RecentCommentRow key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 작성된 댓글이 없습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [range, setRange] = useState<DashboardRange>('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 (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-col justify-between gap-5 border-b border-gray-200 pb-8 md:flex-row md:items-end">
|
||||
<header className="flex flex-col justify-between gap-5 border-b border-[var(--color-line)] 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">
|
||||
<StatusBadge tone="info" className="mb-3">
|
||||
<Settings size={14} />
|
||||
Admin
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
|
||||
관리자
|
||||
</StatusBadge>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-[var(--color-text)] md:text-4xl">
|
||||
관리자 대시보드
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500">
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다.
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
공개 화면과 분리된 운영 공간입니다. 통계 API가 준비되기 전에도 기존 데이터로 운영 흐름을 확인합니다.
|
||||
</p>
|
||||
<p className="mt-3 inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<CalendarClock size={14} />
|
||||
오늘 {formatDate(new Date())}
|
||||
{isDashboardLoading && ' · 통계 API 확인 중'}
|
||||
{isFallback && !isDashboardLoading && ' · fallback 데이터 사용 중'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
|
||||
>
|
||||
<FileText size={17} />
|
||||
게시글 관리
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-text)] px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
@@ -175,126 +290,46 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 게시글</span>
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalPosts.toLocaleString()}</p>
|
||||
</div>
|
||||
<AdminDashboardOverview
|
||||
overview={dashboard?.overview}
|
||||
fallbackTotals={{
|
||||
totalPosts,
|
||||
totalComments,
|
||||
totalCategories,
|
||||
lastPublishedAt: latestPosts[0]?.createdAt,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<AdminDashboardTrafficChart
|
||||
points={dashboard?.traffic ?? []}
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 댓글</span>
|
||||
<MessageSquareText size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalComments.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
|
||||
<AdminDashboardPostPerformance
|
||||
topPosts={topPosts}
|
||||
risingPosts={dashboard?.risingPosts ?? []}
|
||||
stalePopularPosts={dashboard?.stalePopularPosts ?? []}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
<AdminDashboardActionCenter
|
||||
actionItems={dashboard?.actionItems}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
</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>
|
||||
<RecentActivity
|
||||
posts={recentPosts}
|
||||
comments={recentComments}
|
||||
isLoading={isFallbackLoading}
|
||||
/>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-950">최근 글</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : latestPosts.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{latestPosts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 작성된 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="text-lg font-bold text-gray-950">인기 글</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">조회수가 높은 글을 확인합니다.</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : popularPosts.length > 0 ? (
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{popularPosts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 집계된 인기 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-950">최근 댓글 5개</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">새 반응을 대시보드에서 바로 봅니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/comments"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isCommentsLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : recentComments.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{recentComments.map((comment) => (
|
||||
<CommentRow key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 작성된 댓글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<AdminDashboardCategoryHealth
|
||||
categoryStats={dashboard?.categoryStats ?? []}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3">
|
||||
<Archive className="text-blue-600" size={32} />
|
||||
<span>Archives</span>
|
||||
<span>아카이브</span>
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
지금까지 작성한 <span className="text-blue-600 font-bold">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||
@@ -149,4 +152,4 @@ export default function ArchivePage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{/* 🔍 카테고리 내 검색바 */}
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder={`'${apiCategoryName}' 내 검색`}
|
||||
placeholder={`${apiCategoryName} 내 검색`}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
|
||||
@@ -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="카드형 보기"
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
@@ -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="리스트형 보기"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
@@ -138,7 +136,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
<div className="mb-6 flex items-center gap-2 text-sm text-gray-600 bg-blue-50 px-4 py-3 rounded-lg border border-blue-100">
|
||||
<SearchIcon size={16} className="text-blue-500" />
|
||||
<span>
|
||||
"{keyword}" 검색 결과: <strong>{totalElements}</strong>건
|
||||
검색어 <strong>{keyword}</strong> 결과: <strong>{totalElements}</strong>건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -146,12 +144,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-gray-400 mb-2">
|
||||
{keyword ? `"${keyword}"에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
{keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
{activeViewMode === 'grid' ? (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
@@ -176,7 +174,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Page <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
페이지 <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
@@ -192,4 +190,4 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--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);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -14,15 +25,40 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--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);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"Apple SD Gothic Neo",
|
||||
"Noto Sans KR",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.w-md-editor {
|
||||
@@ -34,4 +70,4 @@ body {
|
||||
.w-md-editor-toolbar {
|
||||
border-radius: 0.75rem 0.75rem 0 0 !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function RootLayout({
|
||||
<head>
|
||||
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
|
||||
</head>
|
||||
<body className="bg-[#f8f9fa] text-gray-800">
|
||||
<body className="bg-[var(--color-page)] text-[var(--color-text)]">
|
||||
{/* 🌟 Google Analytics 스크립트 */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
||||
@@ -56,4 +56,4 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
562
src/app/page.tsx
562
src/app/page.tsx
@@ -1,184 +1,478 @@
|
||||
'use client';
|
||||
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import PostCard from '@/components/post/PostCard';
|
||||
import PostListItem from '@/components/post/PostListItem';
|
||||
// PostSearch 컴포넌트 제거 (사이드바로 이동)
|
||||
import { Post } from '@/types';
|
||||
import { Loader2, Megaphone, Flame, Clock, ChevronRight, Search as SearchIcon } from 'lucide-react';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { Category, Post, PostListResponse } from '@/types';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const getSummary = (content?: string, maxLength = 118) => {
|
||||
if (!content) return '아직 요약할 본문이 없습니다. 글을 열어 전체 내용을 확인해 보세요.';
|
||||
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
};
|
||||
|
||||
const flattenCategories = (categories: Category[] = []): Category[] => {
|
||||
return categories.flatMap((category) => [
|
||||
category,
|
||||
...flattenCategories(category.children || []),
|
||||
]);
|
||||
};
|
||||
|
||||
const getHomeCategories = (categories: Category[] = []) => {
|
||||
return flattenCategories(categories)
|
||||
.filter((category) => {
|
||||
const name = category.name.toLowerCase();
|
||||
return category.name !== '공지' && name !== 'notice' && name !== '미분류';
|
||||
})
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const getTotalElements = (data?: PostListResponse) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
|
||||
function SearchResults({
|
||||
keyword,
|
||||
data,
|
||||
isLoading,
|
||||
}: {
|
||||
keyword: string;
|
||||
data?: PostListResponse;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const searchResults = data?.content || [];
|
||||
const searchTotalElements = getTotalElements(data);
|
||||
|
||||
return (
|
||||
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="mb-6 flex flex-col gap-2 border-b border-[var(--color-line)] pb-5">
|
||||
<div className="flex items-center gap-2 text-[var(--color-accent)]">
|
||||
<Search size={22} />
|
||||
<h1 className="text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||
검색 결과
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
검색어 <span className="font-semibold text-[var(--color-text)]">{keyword}</span>에 대한 글 {searchTotalElements.toLocaleString()}건
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-60 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={30} />
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{searchResults.map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeStrip({ notices }: { notices: Post[] }) {
|
||||
if (notices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2" aria-label="공지">
|
||||
{notices.slice(0, 3).map((notice) => (
|
||||
<Link key={notice.id} href={`/posts/${notice.slug}`} className="group block">
|
||||
<Surface
|
||||
interactive
|
||||
className="flex items-center justify-between gap-4 border-red-500/10 bg-red-500/[0.06] px-4 py-3 shadow-none"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<StatusBadge tone="danger">공지</StatusBadge>
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
{notice.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<time>{formatDate(notice.createdAt)}</time>
|
||||
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</Surface>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedPost({ post }: { post?: Post }) {
|
||||
if (!post) {
|
||||
return (
|
||||
<Surface as="section" strong className="flex min-h-72 items-center justify-center p-6">
|
||||
<EmptyState title="대표 글을 기다리고 있습니다." description="새 글이 발행되면 이곳에 먼저 표시됩니다." />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface as="article" strong interactive className="flex h-full min-h-72 flex-col p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone="info">{post.categoryName || '미분류'}</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<p className="mb-3 text-sm font-semibold text-[var(--color-accent)]">대표 글</p>
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-3xl">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="mt-4 line-clamp-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content, 160)}
|
||||
</p>
|
||||
<span className="mt-6 inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
읽기
|
||||
<ChevronRight size={16} className="transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</Surface>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactPostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-4 transition duration-150 hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<StatusBadge tone={isNoticePost(post) ? 'danger' : 'neutral'} className="shrink-0">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<time className="text-xs text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="line-clamp-1 text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content, 96)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={18} className="mt-7 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryShelf({
|
||||
category,
|
||||
posts,
|
||||
isLoading,
|
||||
}: {
|
||||
category: Category;
|
||||
posts: Post[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Surface as="article" className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-bold text-[var(--color-text)]">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">최근 글</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/category/${category.name}`}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--color-accent)]"
|
||||
>
|
||||
전체 보기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div key={item} className="h-10 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{posts.slice(0, 3).map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-semibold text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</span>
|
||||
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-subtle)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 글이 없습니다." className="min-h-28 py-6" />
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리 스트링에서 검색어 가져오기
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
|
||||
// 1. 공지사항 조회
|
||||
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
|
||||
queryKey: ['posts', 'notices'],
|
||||
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// 2. 최신 게시글 조회
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'latest'],
|
||||
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
|
||||
queryFn: () => getPosts({ size: 8, sort: 'createdAt,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// 3. 인기 게시글 조회
|
||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||
queryKey: ['posts', 'popular'],
|
||||
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
|
||||
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
// 4. 검색 결과 조회
|
||||
const { data: searchData, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: ['posts', 'search', keyword],
|
||||
queryFn: () => getPosts({ keyword, size: 20 }),
|
||||
enabled: !!keyword,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (isNoticesLoading || isLatestLoading || isPopularLoading) {
|
||||
const shelfCategories = useMemo(() => getHomeCategories(categories), [categories]);
|
||||
const categoryPostQueries = useQueries({
|
||||
queries: shelfCategories.map((category) => ({
|
||||
queryKey: ['posts', 'category-shelf', category.name],
|
||||
queryFn: () => getPosts({ category: category.name, size: 3, sort: 'createdAt,desc' }),
|
||||
enabled: !keyword,
|
||||
retry: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const notices = noticesData?.content || [];
|
||||
const latestPosts = (latestData?.content || []).filter((post) => !isNoticePost(post));
|
||||
const popularPosts = (popularData?.content || []).filter((post) => !isNoticePost(post));
|
||||
const featuredPost = latestPosts[0];
|
||||
const latestList = latestPosts.slice(1, 7);
|
||||
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading || isPopularLoading);
|
||||
|
||||
if (keyword) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<main className="mx-auto max-w-5xl px-4 py-8 md:px-6">
|
||||
<SearchResults keyword={keyword} data={searchData} isLoading={isSearchLoading} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHomeLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterPosts = (posts: Post[] | undefined, limit: number) => {
|
||||
if (!posts) return [];
|
||||
return posts
|
||||
.filter((post) => post.categoryName !== '공지' && post.categoryName.toLowerCase() !== 'notice')
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const notices = noticesData?.content || [];
|
||||
const latestPosts = filterPosts(latestData?.content, 3);
|
||||
const popularPosts = filterPosts(popularData?.content, 3);
|
||||
|
||||
const searchResults = searchData?.content || [];
|
||||
const searchMeta = (searchData as any)?.page || searchData;
|
||||
const searchTotalElements = searchMeta?.totalElements ?? 0;
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12">
|
||||
|
||||
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */}
|
||||
{keyword ? (
|
||||
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex items-center gap-2 mb-6 border-b border-gray-100 pb-4">
|
||||
<SearchIcon className="text-blue-500" size={24} />
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
"{keyword}" 검색 결과 <span className="text-blue-600 text-lg ml-1">{searchTotalElements}</span>건
|
||||
</h2>
|
||||
<main className="mx-auto max-w-6xl space-y-12 px-4 py-8 md:px-6">
|
||||
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-end">
|
||||
<div className="max-w-2xl">
|
||||
<StatusBadge tone="info" className="mb-5">
|
||||
WYPark Blog
|
||||
</StatusBadge>
|
||||
<h1 className="text-4xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">
|
||||
박원엽의 개발 기록
|
||||
</h1>
|
||||
<p className="mt-5 text-base leading-7 text-[var(--color-text-muted)] md:text-lg">
|
||||
백엔드, 네트워크, 운영 경험을 차분하게 정리하는 개인 기술 블로그입니다.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-text)] px-4 text-sm font-semibold text-white transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
>
|
||||
전체 글 둘러보기
|
||||
<Archive size={16} />
|
||||
</Link>
|
||||
<Link
|
||||
href="#blog-search"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
|
||||
>
|
||||
검색으로 찾기
|
||||
<Search size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Surface className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[var(--color-text)]">최근 업데이트</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{featuredPost ? formatDate(featuredPost.createdAt) : '새 글을 준비 중입니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<NoticeStrip notices={notices} />
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<FeaturedPost post={featuredPost} />
|
||||
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={18} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최신 글</h2>
|
||||
</div>
|
||||
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
전체 보기
|
||||
<ChevronRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isSearchLoading ? (
|
||||
<div className="py-20 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="flex flex-col gap-0 border-t border-gray-100">
|
||||
{searchResults.map((post) => (
|
||||
<PostListItem key={post.id} post={post} />
|
||||
{latestList.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{latestList.map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-xl text-gray-400">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
/* 🅱️ 대시보드 모드: 검색어가 없을 때 기존 화면 표시 */
|
||||
<div className="space-y-16 animate-in fade-in duration-500">
|
||||
{/* 공지사항 섹션 */}
|
||||
{notices.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
|
||||
<Megaphone className="text-red-500" size={20} />
|
||||
<h2 className="text-xl font-bold text-gray-800">공지사항</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{notices.map((post) => (
|
||||
<PostListItem key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<EmptyState title="최신 글 목록을 기다리고 있습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* 최신 포스트 섹션 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-blue-500" size={20} />
|
||||
<h2 className="text-xl font-bold text-gray-800">최신 포스트</h2>
|
||||
</div>
|
||||
<Link href="/archive" className="text-sm text-gray-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
|
||||
전체보기 <ChevronRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{latestPosts.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{latestPosts.map((post) => (
|
||||
<div key={post.id} className="h-full">
|
||||
<PostCard post={post} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
|
||||
아직 작성된 글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 인기 포스트 섹션 */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Flame className="text-orange-500" size={20} />
|
||||
<h2 className="text-xl font-bold text-gray-800">인기 포스트</h2>
|
||||
</div>
|
||||
|
||||
{popularPosts.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{popularPosts.map((post) => (
|
||||
<div key={post.id} className="h-full">
|
||||
<PostCard post={post} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
|
||||
아직 인기 글이 집계되지 않았습니다.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 하단 아카이브 링크 */}
|
||||
{/* <div className="pt-8 pb-4 text-center">
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200 transition-colors font-medium text-sm"
|
||||
>
|
||||
모든 글 보러가기 <ChevronRight size={16} />
|
||||
</Link>
|
||||
</div> */}
|
||||
<section>
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<FolderTree size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-xl font-bold text-[var(--color-text)]">카테고리별 글</h2>
|
||||
</div>
|
||||
)}
|
||||
{shelfCategories.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{shelfCategories.map((category, index) => (
|
||||
<CategoryShelf
|
||||
key={category.id}
|
||||
category={category}
|
||||
posts={categoryPostQueries[index]?.data?.content || []}
|
||||
isLoading={categoryPostQueries[index]?.isLoading || false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="카테고리를 불러오지 못했습니다." description="카테고리 API가 연결되면 이곳에 탐색 선반이 표시됩니다." />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">많이 읽힌 글</h2>
|
||||
</div>
|
||||
{popularPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{popularPosts.slice(0, 4).map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="인기 글을 집계 중입니다." description="조회수 숫자는 공개 홈에서 강조하지 않습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface as="section" className="flex flex-col justify-between gap-8 p-6">
|
||||
<div>
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
|
||||
<Archive size={20} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-normal text-[var(--color-text)]">기록은 아카이브에 쌓입니다.</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
월별 글 흐름과 지난 글은 아카이브에서 한 번에 살펴볼 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 py-2 text-sm font-semibold text-[var(--color-text)] transition hover:bg-white dark:bg-white/10"
|
||||
>
|
||||
아카이브 보기
|
||||
<ChevronRight size={16} />
|
||||
</Link>
|
||||
</Surface>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" size={40} /></div>}>
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<HomeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function isTokenExpired(token: string) {
|
||||
// 현재 시간(초)이 만료 시간(exp)보다 크거나 같으면 만료됨
|
||||
// 안전 마진 60초 추가 (만료 1분 전이면 미리 갱신)
|
||||
return Date.now() / 1000 >= exp - 60;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function AuthInitializer() {
|
||||
} else {
|
||||
throw new Error('Token refresh response invalid');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// console.error('❌ Failed to refresh token on init:', error);
|
||||
// 갱신 실패 시 깔끔하게 로그아웃 처리하여 꼬임 방지
|
||||
logout();
|
||||
|
||||
@@ -7,6 +7,19 @@ import { signup, verifyEmail } from '@/api/auth';
|
||||
import { SignupRequest } from '@/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
const responseError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
return responseError.response?.data?.message || fallbackMessage;
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM'); // 단계 관리
|
||||
@@ -14,7 +27,7 @@ export default function SignupPage() {
|
||||
const [registeredEmail, setRegisteredEmail] = useState(''); // 인증할 이메일 저장
|
||||
|
||||
// React Hook Form 설정
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm<SignupRequest>();
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<SignupRequest>();
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
// 1단계: 회원가입 정보 제출
|
||||
@@ -29,8 +42,8 @@ export default function SignupPage() {
|
||||
} else {
|
||||
alert('회원가입 실패: ' + res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('오류 발생: ' + (error.response?.data?.message || error.message));
|
||||
} catch (error) {
|
||||
alert('오류 발생: ' + getErrorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -50,8 +63,8 @@ export default function SignupPage() {
|
||||
} else {
|
||||
alert('인증 실패: ' + res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('오류 발생: ' + (error.response?.data?.message || error.message));
|
||||
} catch (error) {
|
||||
alert('오류 발생: ' + getErrorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,4 +173,4 @@ export default function SignupPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
interface SitemapPost {
|
||||
slug: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = 'https://blog.wypark.me';
|
||||
// API 주소 환경변수 사용 (없으면 로컬)
|
||||
@@ -33,14 +39,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
throw new Error('Failed to fetch posts');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const json = await response.json() as { data?: { content?: SitemapPost[] } };
|
||||
const posts = json.data?.content || [];
|
||||
|
||||
// 3. 게시글 데이터를 사이트맵 형식으로 변환
|
||||
const postRoutes = posts.map((post: any) => ({
|
||||
const postRoutes = posts.map((post) => ({
|
||||
// 🛠️ 핵심 수정: slug를 encodeURIComponent로 감싸서 특수문자(&, 한글 등)를 안전하게 처리합니다.
|
||||
url: `${baseUrl}/posts/${encodeURIComponent(post.slug)}`,
|
||||
lastModified: new Date(post.updatedAt),
|
||||
lastModified: new Date(post.updatedAt || post.createdAt || Date.now()),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
@@ -51,4 +57,4 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
console.error('Sitemap generation error:', error);
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
||||
if (!_hasHydrated || !isAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 px-1 py-4 md:px-4">
|
||||
<nav className="flex gap-2 overflow-x-auto border-b border-gray-200 pb-3" aria-label="관리자 메뉴">
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-1 py-4 md:px-4">
|
||||
<nav className="flex gap-2 overflow-x-auto border-b border-[var(--color-line)] pb-3" aria-label="관리자 메뉴">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
@@ -69,8 +69,8 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
||||
className={clsx(
|
||||
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
|
||||
isActive
|
||||
? 'bg-gray-950 text-white'
|
||||
: 'border border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-950',
|
||||
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
|
||||
: 'border border-[var(--color-line)] bg-white/70 text-[var(--color-text-muted)] hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Link from 'next/link';
|
||||
import { AlertCircle, ArrowRight, MessageSquareText, RefreshCcw, Tags } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardActionItems } from '@/types';
|
||||
|
||||
interface AdminDashboardActionCenterProps {
|
||||
actionItems?: DashboardActionItems;
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDashboardActionCenter({
|
||||
actionItems,
|
||||
isFallback,
|
||||
}: AdminDashboardActionCenterProps) {
|
||||
const items = [
|
||||
{
|
||||
label: '답변 필요한 댓글',
|
||||
value: actionItems?.unansweredComments,
|
||||
href: '/admin/comments',
|
||||
icon: MessageSquareText,
|
||||
},
|
||||
{
|
||||
label: '미분류 글',
|
||||
value: actionItems?.uncategorizedPosts,
|
||||
href: '/admin/posts',
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
label: '오래 방치된 인기 글',
|
||||
value: actionItems?.stalePopularPosts,
|
||||
href: '/admin/posts',
|
||||
icon: RefreshCcw,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<AlertCircle size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">액션 센터</h2>
|
||||
</div>
|
||||
|
||||
{actionItems ? (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<span className="flex items-center gap-3 text-sm font-semibold text-[var(--color-text-muted)]">
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||
{(item.value ?? 0).toLocaleString()}
|
||||
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="액션 집계 연결 전"
|
||||
description={isFallback ? '관리자 대시보드 API가 준비되면 처리할 일을 모아 보여줍니다.' : '처리할 항목이 없습니다.'}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, FolderTree } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardCategoryStat } from '@/types';
|
||||
|
||||
interface AdminDashboardCategoryHealthProps {
|
||||
categoryStats: DashboardCategoryStat[];
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
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);
|
||||
};
|
||||
|
||||
export default function AdminDashboardCategoryHealth({
|
||||
categoryStats,
|
||||
isFallback,
|
||||
}: AdminDashboardCategoryHealthProps) {
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<FolderTree size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">카테고리 상태</h2>
|
||||
</div>
|
||||
|
||||
{categoryStats.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--color-line)]">
|
||||
<div className="min-w-[680px]">
|
||||
<div className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 bg-black/[0.03] px-4 py-3 text-xs font-bold text-[var(--color-text-subtle)] dark:bg-white/10">
|
||||
<span>카테고리</span>
|
||||
<span>글</span>
|
||||
<span>최근 조회</span>
|
||||
<span>최근 발행</span>
|
||||
<span />
|
||||
</div>
|
||||
{categoryStats.slice(0, 6).map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/category/${category.name}`}
|
||||
className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 border-t border-[var(--color-line)] px-4 py-3 text-sm transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<span className="truncate font-semibold text-[var(--color-text)]">{category.name}</span>
|
||||
<span className="text-[var(--color-text-muted)]">{category.postCount.toLocaleString()}</span>
|
||||
<span className="text-[var(--color-text-muted)]">{category.recentViewCount.toLocaleString()}</span>
|
||||
<span className="truncate text-[var(--color-text-subtle)]">{formatDate(category.lastPublishedAt)}</span>
|
||||
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={isFallback ? '카테고리 통계 API 연결 전' : '카테고리 통계가 없습니다.'}
|
||||
description={isFallback ? '글 수, 최근 조회수, 최근 발행일은 dashboard API 연결 후 표시됩니다.' : undefined}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
82
src/components/admin/dashboard/AdminDashboardOverview.tsx
Normal file
82
src/components/admin/dashboard/AdminDashboardOverview.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { CalendarClock, Eye, FileText, FolderTree, MessageSquareText } from 'lucide-react';
|
||||
import MetricCard from '@/components/ui/MetricCard';
|
||||
import { DashboardOverview } from '@/types';
|
||||
|
||||
interface AdminDashboardOverviewProps {
|
||||
overview?: DashboardOverview;
|
||||
fallbackTotals: {
|
||||
totalPosts: number;
|
||||
totalComments: number;
|
||||
totalCategories: number;
|
||||
lastPublishedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '최근 발행 정보 없음';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '최근 발행 정보 없음';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardOverview({
|
||||
overview,
|
||||
fallbackTotals,
|
||||
}: AdminDashboardOverviewProps) {
|
||||
const totalPosts = overview?.totalPosts ?? fallbackTotals.totalPosts;
|
||||
const totalComments = overview?.totalComments ?? fallbackTotals.totalComments;
|
||||
const totalCategories = overview?.totalCategories ?? fallbackTotals.totalCategories;
|
||||
const lastPublishedAt = overview?.lastPublishedAt ?? fallbackTotals.lastPublishedAt;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MetricCard
|
||||
label="오늘 조회수"
|
||||
value={overview?.todayViews.value ?? null}
|
||||
changeRate={overview?.todayViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="최근 7일 조회수"
|
||||
value={overview?.weekViews.value ?? null}
|
||||
changeRate={overview?.weekViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="최근 30일 조회수"
|
||||
value={overview?.monthViews.value ?? null}
|
||||
changeRate={overview?.monthViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard label="총 게시글" value={totalPosts} icon={<FileText size={18} />} />
|
||||
<MetricCard label="총 댓글" value={totalComments} icon={<MessageSquareText size={18} />} />
|
||||
<MetricCard
|
||||
label="카테고리"
|
||||
value={totalCategories}
|
||||
helper={formatDateTime(lastPublishedAt)}
|
||||
icon={<FolderTree size={18} />}
|
||||
/>
|
||||
{overview?.generatedAt && (
|
||||
<div className="sm:col-span-2 xl:col-span-3">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<CalendarClock size={14} />
|
||||
마지막 집계 {formatDateTime(overview.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Flame, TrendingUp } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardPostStat } from '@/types';
|
||||
|
||||
interface AdminDashboardPostPerformanceProps {
|
||||
topPosts: DashboardPostStat[];
|
||||
risingPosts: DashboardPostStat[];
|
||||
stalePopularPosts: DashboardPostStat[];
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
function PostStatRow({ post, isFallback }: { post: DashboardPostStat; isFallback: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StatusBadge>{post.categoryName || '미분류'}</StatusBadge>
|
||||
<span className="text-xs text-[var(--color-text-subtle)]">
|
||||
{isFallback ? '누적 조회' : '기간 조회'} {(isFallback ? post.viewCount : post.rangeViewCount).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={15} className="mt-5 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboardPostPerformance({
|
||||
topPosts,
|
||||
risingPosts,
|
||||
stalePopularPosts,
|
||||
isFallback,
|
||||
}: AdminDashboardPostPerformanceProps) {
|
||||
const secondaryPosts = risingPosts.length > 0 ? risingPosts : stalePopularPosts;
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Flame size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">콘텐츠 성과</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||
<Flame size={16} />
|
||||
인기 글
|
||||
</div>
|
||||
{topPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{topPosts.slice(0, 5).map((post) => (
|
||||
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="인기 글 데이터가 없습니다." className="min-h-40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
|
||||
<TrendingUp size={16} />
|
||||
{risingPosts.length > 0 ? '상승 중인 글' : '관리 후보 글'}
|
||||
</div>
|
||||
{secondaryPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{secondaryPosts.slice(0, 5).map((post) => (
|
||||
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={isFallback ? '통계 API 연결 전' : '관리 후보가 없습니다.'}
|
||||
description={isFallback ? '상승/방치 기준은 dashboard API 연결 후 표시됩니다.' : undefined}
|
||||
className="min-h-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardRange, DashboardTrafficPoint } from '@/types';
|
||||
|
||||
interface AdminDashboardTrafficChartProps {
|
||||
points: DashboardTrafficPoint[];
|
||||
range: DashboardRange;
|
||||
onRangeChange: (range: DashboardRange) => void;
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: '7일', value: '7d' },
|
||||
{ label: '30일', value: '30d' },
|
||||
{ label: '90일', value: '90d' },
|
||||
] satisfies { label: string; value: DashboardRange }[];
|
||||
|
||||
const formatDay = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardTrafficChart({
|
||||
points,
|
||||
range,
|
||||
onRangeChange,
|
||||
isFallback,
|
||||
}: AdminDashboardTrafficChartProps) {
|
||||
const maxViews = Math.max(...points.map((point) => point.views), 1);
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-6 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">트래픽 흐름</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
최근 기간별 조회수 흐름을 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
ariaLabel="트래픽 기간"
|
||||
options={rangeOptions}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{points.length > 0 ? (
|
||||
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-white/50 px-3 py-4 dark:bg-white/5">
|
||||
{points.map((point) => {
|
||||
const height = Math.max((point.views / maxViews) * 100, 4);
|
||||
|
||||
return (
|
||||
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
||||
/>
|
||||
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
||||
{formatDay(point.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="통계 API 연결 전"
|
||||
description={isFallback ? '백엔드 집계가 준비되면 기간별 그래프가 표시됩니다.' : '표시할 트래픽 데이터가 없습니다.'}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createComment } from '@/api/comments';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CommentFormProps {
|
||||
postSlug: string;
|
||||
@@ -15,7 +14,7 @@ interface CommentFormProps {
|
||||
}
|
||||
|
||||
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
|
||||
const { isLoggedIn, role } = useAuthStore();
|
||||
const { isLoggedIn } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
@@ -29,8 +28,16 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
|
||||
onError: (error: unknown) => {
|
||||
const responseError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
alert('댓글 작성 실패: ' + (responseError.response?.data?.message || fallbackMessage));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,4 +105,4 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getComments } from '@/api/comments';
|
||||
import CommentForm from './CommentForm';
|
||||
import CommentItem from './CommentItem';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
import { Comment } from '@/types';
|
||||
|
||||
interface CommentListProps {
|
||||
postSlug: string;
|
||||
@@ -18,7 +19,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
});
|
||||
|
||||
// 총 댓글 수 계산 (재귀)
|
||||
const countComments = (list: any[]): number => {
|
||||
const countComments = (list: Comment[]): number => {
|
||||
if (!list) return 0;
|
||||
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||
};
|
||||
@@ -61,4 +62,4 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
<div
|
||||
className={clsx(
|
||||
'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',
|
||||
isActive
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
style={{ marginLeft: `${depth * 10}px` }}
|
||||
>
|
||||
@@ -82,19 +84,19 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((previous) => !previous);
|
||||
}}
|
||||
className="ml-1 rounded-full p-1 transition-colors hover:bg-gray-200/50"
|
||||
className="ml-1 rounded-full p-1 transition-colors hover:bg-black/[0.06] dark:hover:bg-white/10"
|
||||
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||
className={clsx('text-[var(--color-text-subtle)] transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isChildrenVisible && hasChildren && (
|
||||
<div className="ml-4 border-l-2 border-gray-100">
|
||||
<div className="ml-4 border-l border-[var(--color-line)]">
|
||||
{sortedChildren.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
@@ -147,7 +149,7 @@ function SidebarContent() {
|
||||
<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"
|
||||
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-surface-strong)] p-2 shadow-md transition-colors hover:bg-white md:hidden"
|
||||
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
|
||||
>
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
@@ -155,15 +157,15 @@ function SidebarContent() {
|
||||
|
||||
<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',
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]/95 backdrop-blur-xl 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">
|
||||
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-black/[0.04] shadow-inner ring-4 ring-white/70 dark:bg-white/10 dark:ring-white/10">
|
||||
{isProfileLoading ? (
|
||||
<div className="h-full w-full animate-pulse bg-gray-200" />
|
||||
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
|
||||
) : (
|
||||
<Image
|
||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||
@@ -179,13 +181,13 @@ function SidebarContent() {
|
||||
|
||||
{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 className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-gray-500">{displayProfile.bio}</p>
|
||||
<h2 className="text-xl font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
@@ -193,12 +195,12 @@ function SidebarContent() {
|
||||
|
||||
<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" />
|
||||
<Folder size={24} className="text-[var(--color-text-subtle)]" />
|
||||
</div>
|
||||
|
||||
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
|
||||
<div className="shrink-0 space-y-1">
|
||||
<div className="mb-6 mt-2 px-1">
|
||||
<div id="blog-search" className="mb-6 mt-2 px-1">
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder="검색..."
|
||||
@@ -212,19 +214,19 @@ function SidebarContent() {
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
pathname === '/archive'
|
||||
? 'bg-blue-50 font-medium text-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50',
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Archive size={16} />
|
||||
<span>Archives</span>
|
||||
<span>아카이브</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-t border-gray-100" />
|
||||
<div className="mb-4 border-t border-[var(--color-line)]" />
|
||||
|
||||
<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>
|
||||
<p className="text-xs font-bold text-[var(--color-text-subtle)]">카테고리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +234,7 @@ function SidebarContent() {
|
||||
{!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 key={item} className="h-8 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -253,8 +255,8 @@ function SidebarContent() {
|
||||
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',
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<FileQuestion size={16} />
|
||||
@@ -266,26 +268,26 @@ function SidebarContent() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}>
|
||||
<div className={clsx('shrink-0 border-t border-[var(--color-line)] bg-[var(--color-surface-strong)]/80 p-6', !isOpen && 'hidden')}>
|
||||
<div className="flex justify-center gap-3">
|
||||
<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"
|
||||
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-text)] hover:text-white dark:bg-white/10 dark:hover:bg-white dark:hover:text-black"
|
||||
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"
|
||||
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-accent)] hover:text-white dark:bg-white/10"
|
||||
aria-label="이메일"
|
||||
>
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] font-light text-gray-300">
|
||||
<p className="mt-4 text-center text-[10px] font-light text-[var(--color-text-subtle)]">
|
||||
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
@@ -296,7 +298,7 @@ function SidebarContent() {
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-gray-100 bg-white" />}>
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]" />}>
|
||||
<SidebarContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -29,15 +29,15 @@ export default function TopHeader() {
|
||||
<>
|
||||
<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"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-[var(--color-accent)] dark:bg-white/10"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span className="hidden sm:inline">관리자 설정</span>
|
||||
<span className="hidden sm:inline">관리자</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||
className="flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<PenLine size={16} />
|
||||
<span>새 글</span>
|
||||
@@ -47,7 +47,7 @@ export default function TopHeader() {
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-600 text-sm font-medium rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-red-500 transition-colors"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-red-500 dark:bg-white/10"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="hidden sm:inline">로그아웃</span>
|
||||
@@ -57,7 +57,7 @@ export default function TopHeader() {
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 text-sm font-medium hover:text-blue-600 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]"
|
||||
>
|
||||
<User size={18} />
|
||||
<span>로그인</span>
|
||||
@@ -65,7 +65,7 @@ export default function TopHeader() {
|
||||
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-blue-600 text-sm font-bold rounded-full border border-blue-100 shadow-sm hover:bg-blue-50 hover:shadow-md transition-all"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-bold text-[var(--color-accent)] shadow-sm backdrop-blur-xl transition-all hover:bg-white hover:shadow-md dark:bg-white/10"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
<span>회원가입</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
@@ -15,128 +16,129 @@ interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
const components: Components = {
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="mx-1 break-words rounded-md bg-black/[0.05] px-1.5 py-0.5 font-mono text-[0.9em] font-medium text-red-600 dark:bg-white/10 dark:text-red-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="my-7 rounded-r-lg border-l-4 border-[var(--color-accent)] bg-[var(--color-accent-soft)] py-3 pl-5 pr-4 text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="inline-flex items-center gap-0.5 font-semibold text-[var(--color-accent)] underline-offset-4 transition-colors hover:underline"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-white/70 shadow-sm dark:bg-white/10">
|
||||
<table className="w-full text-left text-sm text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="border-b border-[var(--color-line)] bg-black/[0.03] text-xs text-[var(--color-text-muted)] dark:bg-white/10">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-5 py-3 font-bold text-[var(--color-text)]">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="border-b border-[var(--color-line)] px-5 py-4 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
<span className="my-8 flex flex-col items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-auto max-h-[700px] max-w-full rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-card)] transition-transform duration-150 hover:scale-[1.005]"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{alt && <span className="mt-3 block w-full text-center text-sm text-[var(--color-text-subtle)]">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="my-4 list-disc space-y-2 pl-6 text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]">{children}</ul>;
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return (
|
||||
<ol
|
||||
className="my-4 list-decimal space-y-2 pl-6 font-medium text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일
|
||||
h1({ children, ...props }) {
|
||||
return <h1 className="mt-12 mb-6 border-b border-[var(--color-line)] pb-4 text-3xl font-extrabold tracking-normal text-[var(--color-text)]" {...props}>{children}</h1>;
|
||||
},
|
||||
h2({ children, ...props }) {
|
||||
return <h2 className="mt-11 mb-5 text-2xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h2>;
|
||||
},
|
||||
h3({ children, ...props }) {
|
||||
return <h3 className="mt-9 mb-4 border-l-4 border-[var(--color-accent)] pl-3 text-xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h3>;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeSlug]}
|
||||
components={{
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-red-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-blue-500 bg-blue-50 pl-4 py-3 my-6 text-gray-700 rounded-r-lg italic shadow-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium underline-offset-4 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm">
|
||||
<table className="w-full text-sm text-left text-gray-700 bg-white">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3 font-bold text-gray-900">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지 (비율 유지 및 중앙 정렬)
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬
|
||||
// items-center 추가: 가로축 중앙 정렬
|
||||
<span className="block my-8 flex flex-col items-center justify-center">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="rounded-xl shadow-lg border border-gray-100 max-w-full h-auto max-h-[700px] mx-auto hover:scale-[1.01] transition-transform duration-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{alt && <span className="block text-center text-sm text-gray-400 mt-2 w-full">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
|
||||
},
|
||||
ol({ children, ...props }: any) {
|
||||
return (
|
||||
<ol
|
||||
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함)
|
||||
h1({ children, ...props }: any) {
|
||||
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900" {...props}>{children}</h1>;
|
||||
},
|
||||
h2({ children, ...props }: any) {
|
||||
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
|
||||
},
|
||||
h3({ children, ...props }: any) {
|
||||
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1" {...props}>{children}</h3>;
|
||||
},
|
||||
}}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
@@ -155,7 +157,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
|
||||
<div className="group relative my-8 overflow-hidden rounded-lg border border-gray-700/50 bg-[#1e1e1e] shadow-[var(--shadow-card)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[#2d2d2d] border-b border-gray-700 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -182,7 +184,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
title="코드 복사"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
|
||||
<span>{isCopied ? '복사됨' : '복사'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -204,4 +206,4 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,71 @@
|
||||
import { Post } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
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 getSummary(content?: string) {
|
||||
if (!content) return '';
|
||||
|
||||
if (!content) return '본문을 열어 전체 내용을 확인해 보세요.';
|
||||
|
||||
return content
|
||||
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자
|
||||
.replace(/\n/g, ' ') // 줄바꿈을 공백으로
|
||||
.substring(0, 120); // 120자까지만 자르기
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
// 요약문 생성
|
||||
const summary = getSummary(post.content);
|
||||
|
||||
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNotice = isNoticePost(post);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group h-full">
|
||||
<article className={clsx(
|
||||
"flex flex-col h-full bg-white rounded-2xl p-6 transition-all duration-300 border",
|
||||
// 공지글이면 테두리에 살짝 붉은 기운을 줌
|
||||
isNotice
|
||||
? "border-red-100 shadow-[0_2px_8px_rgba(239,68,68,0.08)] hover:shadow-[0_8px_24px_rgba(239,68,68,0.12)]"
|
||||
: "border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:-translate-y-1"
|
||||
)}>
|
||||
|
||||
{/* 상단 카테고리 & 날짜 */}
|
||||
<div className="flex items-center justify-between text-xs mb-4">
|
||||
<span className={clsx(
|
||||
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||
isNotice
|
||||
? "bg-red-50 text-red-600 font-bold border border-red-100" // 🔴 공지 스타일
|
||||
: "bg-slate-100 text-slate-600" // 기본 스타일
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
<time className="text-gray-400 font-light">
|
||||
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface
|
||||
as="article"
|
||||
interactive
|
||||
className="flex h-full flex-col p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h2 className={clsx(
|
||||
"text-xl font-bold mb-3 transition-colors line-clamp-2",
|
||||
isNotice ? "text-gray-900 group-hover:text-red-600" : "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
|
||||
<h2 className="line-clamp-2 text-xl font-bold leading-snug tracking-normal text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{/* 요약글 */}
|
||||
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
||||
{summary}
|
||||
|
||||
<p className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content)}
|
||||
</p>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium flex items-center gap-1",
|
||||
isNotice ? "text-red-500" : "text-blue-500"
|
||||
)}>
|
||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
<div className="mt-6 flex items-center justify-between border-t border-[var(--color-line)] pt-4">
|
||||
<span className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
읽기
|
||||
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Surface>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import TOC from '@/components/post/TOC';
|
||||
import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Post } from '@/types'; // 타입 임포트
|
||||
|
||||
interface PostDetailClientProps {
|
||||
@@ -66,18 +67,18 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
||||
<div className="mx-auto max-w-4xl px-4 py-20 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<AlertCircle className="text-gray-300" size={64} />
|
||||
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
<h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
|
||||
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<p className="mb-6 text-[var(--color-text-muted)]">
|
||||
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors">메인으로</button>
|
||||
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-white/70 px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-white">메인으로</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -88,59 +89,70 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
const nextPost = post.nextPost;
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
|
||||
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8">
|
||||
<Link href="/" className="mb-8 inline-flex items-center gap-1 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
|
||||
<ArrowLeft size={18} />
|
||||
<span className="text-sm font-medium">목록으로</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
|
||||
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-16">
|
||||
|
||||
<main className="min-w-0 xl:flex-1">
|
||||
<main className="min-w-0">
|
||||
<article>
|
||||
<header className="mb-10 border-b border-gray-100 pb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
|
||||
<header className="mb-10 border-b border-[var(--color-line)] pb-8">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
<Folder size={14} />
|
||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||
<span>{post.categoryName || '미분류'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
||||
<h1 className="mb-6 break-keep text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">{post.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{profile?.imageUrl ? <img src={profile.imageUrl} alt="Author" className="w-8 h-8 rounded-full object-cover border border-gray-100 shadow-sm" /> : <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"><User size={16} /></div>}
|
||||
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
|
||||
{profile?.imageUrl ? (
|
||||
<Image
|
||||
src={profile.imageUrl}
|
||||
alt="작성자"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full border border-[var(--color-line)] object-cover shadow-sm"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10"><User size={16} /></div>
|
||||
)}
|
||||
<span className="font-bold text-[var(--color-text)]">{profile?.name || 'Dev Park'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString()}</div>
|
||||
<div className="flex items-center gap-1.5"><Eye size={16} />{post.viewCount} views</div>
|
||||
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString('ko-KR')}</div>
|
||||
<div className="flex items-center gap-1.5"><Eye size={16} />조회 {post.viewCount.toLocaleString()}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg max-w-none prose-headings:font-bold prose-a:text-blue-600 prose-img:rounded-2xl prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 mb-20">
|
||||
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-a:text-[var(--color-accent)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
|
||||
<MarkdownRenderer content={post.content || ''} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
|
||||
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
|
||||
{prevPost ? (
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex flex-col items-start gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span>
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{prevPost.title}</span>
|
||||
</Link>
|
||||
) : <div className="hidden md:block p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1"><ChevronLeft size={16} /> 이전 글 없음</span></div>}
|
||||
) : <div className="hidden w-full cursor-not-allowed rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> 이전 글 없음</span></div>}
|
||||
|
||||
{nextPost ? (
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{nextPost.title}</span>
|
||||
</Link>
|
||||
) : <div className="hidden md:flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1">다음 글 없음 <ChevronRight size={16} /></span></div>}
|
||||
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">다음 글 없음 <ChevronRight size={16} /></span></div>}
|
||||
</nav>
|
||||
|
||||
<CommentList postSlug={post.slug} />
|
||||
</main>
|
||||
|
||||
<aside className="hidden 2xl:block w-[220px] shrink-0">
|
||||
<aside className="hidden w-[220px] shrink-0 xl:block">
|
||||
<div className="sticky top-24">
|
||||
<TOC content={post.content || ''} />
|
||||
</div>
|
||||
|
||||
@@ -1,60 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import { format } from 'date-fns';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { ChevronRight, Eye } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
showViews?: boolean;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post }: PostListItemProps) {
|
||||
// 📢 공지 카테고리 여부 확인
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function PostListItem({ post, showViews = false }: PostListItemProps) {
|
||||
const isNotice = isNoticePost(post);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group">
|
||||
<div className={clsx(
|
||||
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors",
|
||||
isNotice
|
||||
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게
|
||||
: "border-gray-100 hover:bg-gray-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* 카테고리 라벨 */}
|
||||
<span className={clsx(
|
||||
"hidden sm:inline-block px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap",
|
||||
isNotice
|
||||
? "bg-red-100 text-red-600 font-bold" // 🔴 공지 스타일 강조
|
||||
: "bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className={clsx(
|
||||
"text-base font-medium truncate transition-colors",
|
||||
isNotice
|
||||
? "text-gray-900 group-hover:text-red-600 font-semibold"
|
||||
: "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
<Link href={`/posts/${post.slug}`} className="group block">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
|
||||
isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-black/[0.03] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
<time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap">
|
||||
{/* 조회수 */}
|
||||
<div className="hidden sm:flex items-center gap-1.5" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">{post.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* 날짜 */}
|
||||
<time className="font-light tabular-nums text-xs sm:text-sm">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
</time>
|
||||
<div className="ml-3 flex shrink-0 items-center gap-4 text-sm text-[var(--color-text-subtle)]">
|
||||
{showViews && (
|
||||
<div className="hidden items-center gap-1.5 sm:flex" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">조회 {post.viewCount.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={18} className="transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
|
||||
// npm install github-slugger 실행 필요
|
||||
@@ -18,23 +18,21 @@ interface HeadingItem {
|
||||
|
||||
export default function TOC({ content }: TOCProps) {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const [headings, setHeadings] = useState<HeadingItem[]>([]);
|
||||
|
||||
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
|
||||
useEffect(() => {
|
||||
const headings = useMemo(() => {
|
||||
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
|
||||
const lines = content.split('\n');
|
||||
|
||||
// 코드 블럭 내의 #은 무시
|
||||
let inCodeBlock = false;
|
||||
|
||||
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
|
||||
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
|
||||
// 코드 블럭 진입/이탈 체크 (```)
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
return acc;
|
||||
return {
|
||||
...acc,
|
||||
inCodeBlock: !acc.inCodeBlock,
|
||||
};
|
||||
}
|
||||
if (inCodeBlock) return acc;
|
||||
if (acc.inCodeBlock) return acc;
|
||||
|
||||
// 헤딩 매칭 (# 1~3개)
|
||||
const match = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
@@ -45,12 +43,15 @@ export default function TOC({ content }: TOCProps) {
|
||||
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
|
||||
const slug = slugger.slug(text);
|
||||
|
||||
acc.push({ text, level, slug });
|
||||
return {
|
||||
...acc,
|
||||
headings: [...acc.headings, { text, level, slug }],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, { headings: [], inCodeBlock: false });
|
||||
|
||||
setHeadings(matches);
|
||||
return result.headings;
|
||||
}, [content]);
|
||||
|
||||
// 2. 스크롤 감지 (IntersectionObserver)
|
||||
@@ -103,18 +104,18 @@ export default function TOC({ content }: TOCProps) {
|
||||
|
||||
return (
|
||||
<aside className="w-full">
|
||||
<div className="border-l-2 border-gray-100 pl-4 py-2">
|
||||
<h4 className="font-bold text-gray-900 mb-4 text-sm uppercase tracking-wider text-opacity-80">On this page</h4>
|
||||
<div className="rounded-lg border border-[var(--color-line)] bg-white/65 p-4 shadow-sm backdrop-blur-xl dark:bg-white/10">
|
||||
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]">목차</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{headings.map((heading, index) => (
|
||||
<li
|
||||
key={`${heading.slug}-${index}`}
|
||||
className={clsx(
|
||||
"text-sm transition-all duration-200",
|
||||
heading.level === 3 ? "pl-4 text-xs" : "", // h3는 들여쓰기
|
||||
"border-l-2 pl-3 text-sm transition-all duration-200",
|
||||
heading.level === 3 ? "ml-3 text-xs" : "", // h3는 들여쓰기
|
||||
activeId === heading.slug
|
||||
? "text-blue-600 font-bold translate-x-1"
|
||||
: "text-gray-500 hover:text-gray-900"
|
||||
? "border-[var(--color-accent)] font-bold text-[var(--color-accent)]"
|
||||
: "border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
)}
|
||||
>
|
||||
<a
|
||||
@@ -130,4 +131,4 @@ export default function TOC({ content }: TOCProps) {
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/components/ui/EmptyState.tsx
Normal file
33
src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 px-5 py-10 text-center dark:bg-white/5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && <div className="mb-3 text-[var(--color-text-subtle)]">{icon}</div>}
|
||||
<p className="text-sm font-semibold text-[var(--color-text-muted)]">{title}</p>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-[var(--color-text-subtle)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/MetricCard.tsx
Normal file
54
src/components/ui/MetricCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import Surface from './Surface';
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value?: number | string | null;
|
||||
icon?: ReactNode;
|
||||
helper?: string;
|
||||
changeRate?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatValue = (value?: number | string | null) => {
|
||||
if (value === null || value === undefined) return '통계 API 연결 전';
|
||||
if (typeof value === 'number') return value.toLocaleString();
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
helper,
|
||||
changeRate,
|
||||
disabled = false,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
const hasChange = typeof changeRate === 'number';
|
||||
const isPositive = hasChange && changeRate >= 0;
|
||||
|
||||
return (
|
||||
<Surface className={clsx('p-5', disabled && 'opacity-70', className)}>
|
||||
<div className="flex items-center justify-between gap-3 text-[var(--color-text-muted)]">
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p
|
||||
className={clsx(
|
||||
'mt-4 break-keep text-3xl font-bold tracking-normal text-[var(--color-text)]',
|
||||
disabled && 'text-base leading-7 text-[var(--color-text-muted)]',
|
||||
)}
|
||||
>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
{(helper || hasChange) && (
|
||||
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{hasChange ? `${isPositive ? '+' : ''}${changeRate.toFixed(1)}%` : helper}
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/SegmentedControl.tsx
Normal file
54
src/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
ariaLabel: string;
|
||||
options: SegmentedControlOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-white/60 p-1 shadow-sm backdrop-blur-xl dark:bg-white/10',
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={clsx(
|
||||
'min-w-14 rounded-full px-3 text-sm font-semibold transition duration-150',
|
||||
isSelected
|
||||
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/StatusBadge.tsx
Normal file
33
src/components/ui/StatusBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: BadgeTone;
|
||||
}
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
|
||||
info: 'border-blue-500/15 bg-blue-500/10 text-blue-700 dark:text-blue-300',
|
||||
success: 'border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
danger: 'border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
};
|
||||
|
||||
export default function StatusBadge({
|
||||
tone = 'neutral',
|
||||
className,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold leading-none',
|
||||
toneClass[tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/Surface.tsx
Normal file
30
src/components/ui/Surface.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
type SurfaceElement = 'div' | 'section' | 'article' | 'aside';
|
||||
|
||||
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
|
||||
as?: SurfaceElement;
|
||||
strong?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export default function Surface({
|
||||
as: Component = 'div',
|
||||
strong = false,
|
||||
interactive = false,
|
||||
className,
|
||||
...props
|
||||
}: SurfaceProps) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
|
||||
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-panel)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
|
||||
interactive && 'transition duration-150 hover:border-black/10 hover:bg-white/90 hover:shadow-[var(--shadow-panel)] dark:hover:border-white/15 dark:hover:bg-white/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface JwtPayload {
|
||||
nickname?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -50,7 +50,7 @@ const parseToken = (token: string): { role: string; user: UserInfo | null } => {
|
||||
email: decoded.sub || '',
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// console.error('Token parsing error:', e);
|
||||
return { role: 'USER', user: null };
|
||||
}
|
||||
@@ -98,4 +98,4 @@ export const useAuthStore = create(
|
||||
// partialize: (state) => ({ accessToken: state.accessToken, isLoggedIn: state.isLoggedIn, user: state.user, role: state.role }),
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface Post {
|
||||
categoryName: string;
|
||||
viewCount: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
|
||||
@@ -157,3 +158,68 @@ export interface AdminCommentListResponse extends PageMeta {
|
||||
content: AdminComment[];
|
||||
page?: PageMeta;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import typography from "@tailwindcss/typography";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
@@ -15,8 +16,6 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'), // 👈 아까 추가하려던 게 이겁니다!
|
||||
],
|
||||
plugins: [typography],
|
||||
};
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user