Compare commits

37 Commits

Author SHA1 Message Date
박원엽
dbfd84a8ab Refine chess board visual style
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-06-24 09:55:30 +09:00
박원엽
cc4fb0f947 feat: add chess resign and undo actions
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m16s
2026-06-24 09:01:13 +09:00
박원엽
924dd8b372 feat: add Maia chess game UI
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m11s
2026-06-19 14:49:28 +09:00
wypark
a828be323a .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m13s
2026-06-07 12:20:18 +09:00
wypark
8fedc8657e .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s
2026-06-07 11:43:52 +09:00
wypark
b466fa98c9 Simplify macOS layout structure
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m7s
2026-06-07 00:14:52 +09:00
wypark
eb2e713b24 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-06-06 23:59:25 +09:00
wypark
091f97968b .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m9s
2026-06-06 23:22:46 +09:00
박원엽
f1668f08f2 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m7s
2026-06-02 16:58:36 +09:00
박원엽
3fe1b0e6d5 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m9s
2026-06-02 16:40:39 +09:00
박원엽
49fc7b5331 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-06-02 15:34:30 +09:00
박원엽
d1a0340eb0 . 2026-06-02 15:26:54 +09:00
박원엽
895b19d284 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m7s
2026-06-02 15:07:00 +09:00
박원엽
a56e95e6de .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-06-02 14:54:18 +09:00
박원엽
0b6ce404e1 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m11s
2026-06-02 14:39:23 +09:00
박원엽
e3654a7bd5 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m15s
2026-06-02 14:09:38 +09:00
박원엽
f26713e0ea .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m9s
2026-05-29 14:51:46 +09:00
박원엽
b371200515 Polish macOS layer styling
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-05-29 14:18:02 +09:00
박원엽
8e854a1afa . 2026-05-29 12:56:23 +09:00
박원엽
d264a7a139 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m12s
2026-05-29 12:30:28 +09:00
박원엽
3ecccbe28f .
Some checks failed
Deploy blog-frontend / build-and-deploy (push) Failing after 8s
2026-05-29 11:55:49 +09:00
박원엽
5e37995fbb .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m15s
2026-05-29 11:29:28 +09:00
박원엽
98dfb651a2 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m9s
2026-05-29 10:40:33 +09:00
박원엽
7535fb6ac6 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m9s
2026-05-29 10:29:46 +09:00
박원엽
7f9bc1f83b Redesign blog as desktop OS
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-05-29 09:46:00 +09:00
wypark
ab585b5faa feat: add daily chess puzzle page
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m29s
2026-05-29 00:18:05 +09:00
wypark
4bc0441490 Fix new post button theme color
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m3s
2026-05-28 23:30:56 +09:00
wypark
f5bef6eeed Remove home hero copy
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m7s
2026-05-28 23:26:36 +09:00
wypark
fa3eb96d08 Replace featured post with popular list
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s
2026-05-28 23:16:19 +09:00
wypark
caa7196608 Refine top header menu
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m8s
2026-05-28 23:04:43 +09:00
wypark
701bbdea4a .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m4s
2026-05-28 22:49:38 +09:00
wypark
7bc75a32b2 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s
2026-05-28 22:32:19 +09:00
wypark
8a2e05b003 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s
2026-05-28 22:27:29 +09:00
wypark
390778da3e .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m7s
2026-05-28 22:01:57 +09:00
wypark
0273cae6e4 .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m30s
2026-05-28 21:45:30 +09:00
wypark
58a012621a .
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
2026-05-28 19:29:54 +09:00
박원엽
f4481b88cb . 2026-05-28 16:52:33 +09:00
85 changed files with 10488 additions and 2167 deletions

116
AGENTS.md Normal file
View File

@@ -0,0 +1,116 @@
# AGENTS.md
Repository-specific instructions for coding agents working on `blog-frontend`.
## Project Overview
- This is the frontend for WYPark Blog, built with Next.js 16 App Router, React 19, TypeScript, Tailwind CSS 4, TanStack React Query, Zustand, and Axios.
- The app talks to a backend API through `NEXT_PUBLIC_API_URL`.
- Local default: `http://localhost:8080`
- Production API used by deploy: `https://blogserver.wypark.me`
- Public site metadata uses: `https://blog.wypark.me`
- Node 20 is the production baseline. The Docker build uses `node:20-alpine`.
## Repository Layout
- `src/app/`: Next.js App Router routes, root layout, providers, metadata, sitemap, and robots.
- `src/components/layout/`: persistent layout components such as `Sidebar` and `TopHeader`.
- `src/components/post/`: post listing, detail, Markdown rendering, search, and TOC components.
- `src/components/comment/`: comment form, list, and item components.
- `src/api/`: API client wrappers. Use these instead of calling backend endpoints directly from components.
- `src/api/http.ts`: shared Axios instance, auth header injection, and token refresh handling.
- `src/store/authStore.ts`: persisted auth state with Zustand.
- `src/types/index.ts`: shared API and domain types.
- `public/`: static assets.
- `.gitea/workflows/deploy.yml`: Gitea deployment pipeline.
- `DockerFile`: production Docker image definition. Keep the current filename casing unless changing the deploy workflow too.
## Package Manager And Commands
- Prefer `npm` for scripts and dependency work because `package-lock.json`, Docker, and CI use npm.
- Do not update both `package-lock.json` and `yarn.lock` for routine changes. If dependencies must change, update the npm lockfile and leave a note about the lockfile decision.
- Common commands:
- `npm ci` to install dependencies from the lockfile.
- `npm run dev` to start local development on port 3000.
- `npm run lint` for ESLint.
- `npm run build` for production build validation.
- `npm run start` after a successful build.
## Environment
- Use `NEXT_PUBLIC_API_URL` when testing against a non-local backend.
- Features that fetch categories, posts, profile, comments, auth, or uploads require the backend API to be reachable.
- If the backend is not running, prefer validating static rendering, linting, and isolated UI logic instead of inventing mock API behavior.
## Coding Conventions
- Keep TypeScript strict. Add or refine shared types in `src/types/index.ts` instead of spreading untyped `any` through components.
- Use the `@/*` alias for imports from `src`.
- Prefer existing local patterns over new abstractions.
- Keep components focused and colocate small UI-only helpers near the component that uses them.
- Preserve the existing Tailwind-first styling approach. Use global CSS only for app-wide behavior or third-party component overrides.
- Prefer icons from the existing icon libraries (`lucide-react`, `@heroicons/react`) instead of custom inline SVGs.
- User-facing copy is Korean-first. When editing Korean text, save files as UTF-8 and verify the rendered text or diff so strings do not become mojibake.
- Some existing files contain Korean comments and UI strings. Do not mass-rewrite text while making unrelated changes; fix only the text needed for the task.
## Next.js App Router Rules
- Use Server Components by default.
- Add `'use client'` only when a component uses hooks, browser APIs, localStorage, React Query hooks, Zustand hooks, events, or client-only libraries.
- Components that use `useSearchParams` should remain under a `Suspense` boundary.
- Dynamic route params follow the current Next.js 16 style in this repo, where route components may receive `params` as a `Promise`.
- Keep metadata, sitemap, and robots changes aligned with the production domain `blog.wypark.me`.
## Data Fetching And API Rules
- Use `src/api/*` functions from UI code. Add new backend calls there before consuming them in components.
- Use `http` from `src/api/http.ts` for authenticated JSON API calls so auth headers, credentials, and refresh behavior stay consistent.
- Direct `axios` calls should be limited to token refresh paths or cases where the shared interceptor would recurse.
- API responses generally follow `ApiResponse<T>` with useful data under `response.data.data`.
- Keep React Query keys stable and descriptive, following existing patterns such as `['posts', ...]`, `['post', slug]`, `['categories']`, `['profile']`, and `['comments', postSlug]`.
- After mutations, invalidate or reset the related queries rather than relying on stale cache state.
## Auth And Permissions
- Auth state lives in `useAuthStore` and is persisted under `auth-storage`.
- Admin-only UI checks generally use `_hasHydrated` plus `role?.includes('ADMIN')`.
- Avoid reading localStorage during server rendering. Gate browser-only auth logic behind client components and effects.
- Be careful around token refresh. There is queueing and Web Locks logic in `src/api/http.ts`; do not simplify it without testing concurrent 401 behavior.
- Do not expose refresh tokens or auth tokens in logs, UI, or error messages.
## Markdown And Content Rendering
- Render post content through `src/components/post/MarkdownRenderer.tsx`.
- Keep `rehype-sanitize` in the Markdown pipeline unless there is a deliberate, reviewed security change.
- When adding Markdown features, consider XSS, external links, image behavior, syntax highlighting, TOC generation, and SSR/client compatibility.
- Uploaded images are inserted as Markdown image syntax by the write page. Preserve that convention unless changing backend upload semantics too.
## UI And UX Guidelines
- Preserve the main layout: fixed sidebar on larger screens, mobile sidebar toggle, top-right auth/write controls, content area with responsive widths.
- Keep blog reading surfaces calm and content-focused. Avoid large decorative sections unless the user explicitly asks for a redesign.
- Ensure text fits on mobile and desktop, especially Korean labels in buttons, cards, modals, and sidebar items.
- For admin flows, keep destructive actions confirmed and visually distinct.
- For forms, preserve loading, disabled, error, and success states with `react-hot-toast` or the existing local patterns.
## Validation Expectations
- For most code changes, run `npm run lint`.
- Run `npm run build` when changing routing, layout, metadata, data fetching, auth, Markdown rendering, or build/deploy config.
- For visible UI changes, start `npm run dev` and verify the affected route in a browser when feasible.
- If validation cannot run because dependencies or the backend are unavailable, state that clearly and explain what was checked instead.
## Deployment Notes
- The production Docker image uses standalone Next output. Keep `next.config.ts` `output: 'standalone'` unless the deployment strategy changes.
- The Gitea workflow builds on pushes to `main`, passes `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me`, runs the container as `blog-frontend`, and maps host port `3005` to container port `3000`.
- Keep Docker, Gitea workflow, and Next config changes synchronized.
## Change Discipline
- Keep edits scoped to the requested task.
- Record completed work in `LOG.md` as part of each task. Include the date, a brief summary of changes, and any validation performed or skipped.
- When finishing a task and updating `LOG.md`, also leave a concise note about the recommended next task so future agents can continue smoothly.
- Do not rewrite generated files, `.next`, `node_modules`, build output, or unrelated lockfiles.
- Do not silently change public URLs, analytics IDs, SEO behavior, auth storage keys, or backend endpoint paths.
- When touching fragile areas such as auth refresh, Markdown sanitization, deployment, or route params, include a short explanation and stronger validation.

562
DESIGN.md Normal file
View File

@@ -0,0 +1,562 @@
---
version: alpha
name: Apple-design-analysis
description: A photography-first interface that turns marketing into a museum gallery. Edge-to-edge product tiles alternate light and dark canvases, framed by SF Pro Display headlines with negative letter-spacing and a single Action Blue (#0066cc) interactive color. UI chrome recedes so the product can speak — no decorative gradients, no shadows on chrome, only the one signature drop-shadow under product imagery resting on a surface.
colors:
primary: "#0066cc"
primary-focus: "#0071e3"
primary-on-dark: "#2997ff"
ink: "#1d1d1f"
body: "#1d1d1f"
body-on-dark: "#ffffff"
body-muted: "#cccccc"
ink-muted-80: "#333333"
ink-muted-48: "#7a7a7a"
divider-soft: "#f0f0f0"
hairline: "#e0e0e0"
canvas: "#ffffff"
canvas-parchment: "#f5f5f7"
surface-pearl: "#fafafc"
surface-tile-1: "#272729"
surface-tile-2: "#2a2a2c"
surface-tile-3: "#252527"
surface-black: "#000000"
surface-chip-translucent: "#d2d2d7"
on-primary: "#ffffff"
on-dark: "#ffffff"
typography:
hero-display:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 56px
fontWeight: 600
lineHeight: 1.07
letterSpacing: -0.28px
display-lg:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 40px
fontWeight: 600
lineHeight: 1.1
letterSpacing: 0
display-md:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 34px
fontWeight: 600
lineHeight: 1.47
letterSpacing: -0.374px
lead:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 28px
fontWeight: 400
lineHeight: 1.14
letterSpacing: 0.196px
lead-airy:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 24px
fontWeight: 300
lineHeight: 1.5
letterSpacing: 0
tagline:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 21px
fontWeight: 600
lineHeight: 1.19
letterSpacing: 0.231px
body-strong:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 17px
fontWeight: 600
lineHeight: 1.24
letterSpacing: -0.374px
body:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 17px
fontWeight: 400
lineHeight: 1.47
letterSpacing: -0.374px
dense-link:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 17px
fontWeight: 400
lineHeight: 2.41
letterSpacing: 0
caption:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.43
letterSpacing: -0.224px
caption-strong:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 14px
fontWeight: 600
lineHeight: 1.29
letterSpacing: -0.224px
button-large:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 18px
fontWeight: 300
lineHeight: 1.0
letterSpacing: 0
button-utility:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.29
letterSpacing: -0.224px
fine-print:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 12px
fontWeight: 400
lineHeight: 1.0
letterSpacing: -0.12px
micro-legal:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 10px
fontWeight: 400
lineHeight: 1.3
letterSpacing: -0.08px
nav-link:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 12px
fontWeight: 400
lineHeight: 1.0
letterSpacing: -0.12px
rounded:
none: 0px
xs: 5px
sm: 8px
md: 11px
lg: 18px
pill: 9999px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 17px
lg: 24px
xl: 32px
xxl: 48px
section: 80px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.body}"
rounded: "{rounded.pill}"
padding: 11px 22px
button-primary-focus:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.pill}"
button-primary-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.pill}"
button-secondary-pill:
backgroundColor: "{colors.canvas}"
textColor: "{colors.primary}"
typography: "{typography.body}"
rounded: "{rounded.pill}"
padding: 11px 22px
button-dark-utility:
backgroundColor: "{colors.ink}"
textColor: "{colors.on-dark}"
typography: "{typography.button-utility}"
rounded: "{rounded.sm}"
padding: 8px 15px
button-pearl-capsule:
backgroundColor: "{colors.surface-pearl}"
textColor: "{colors.ink-muted-80}"
typography: "{typography.caption}"
rounded: "{rounded.md}"
padding: 8px 14px
button-store-hero:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.button-large}"
rounded: "{rounded.pill}"
padding: 14px 28px
button-icon-circular:
backgroundColor: "{colors.surface-chip-translucent}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
size: 44px
text-link:
backgroundColor: transparent
textColor: "{colors.primary}"
typography: "{typography.body}"
text-link-on-dark:
backgroundColor: transparent
textColor: "{colors.primary-on-dark}"
typography: "{typography.body}"
global-nav:
backgroundColor: "{colors.surface-black}"
textColor: "{colors.on-dark}"
typography: "{typography.nav-link}"
height: 44px
sub-nav-frosted:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink}"
typography: "{typography.tagline}"
height: 52px
product-tile-light:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
product-tile-parchment:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
product-tile-dark:
backgroundColor: "{colors.surface-tile-1}"
textColor: "{colors.on-dark}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
product-tile-dark-2:
backgroundColor: "{colors.surface-tile-2}"
textColor: "{colors.on-dark}"
rounded: "{rounded.none}"
product-tile-dark-3:
backgroundColor: "{colors.surface-tile-3}"
textColor: "{colors.on-dark}"
rounded: "{rounded.none}"
store-utility-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-strong}"
rounded: "{rounded.lg}"
padding: 24px
configurator-option-chip:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.caption}"
rounded: "{rounded.pill}"
padding: 12px 16px
configurator-option-chip-selected:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.pill}"
search-input:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.pill}"
padding: 12px 20px
height: 44px
floating-sticky-bar:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink}"
typography: "{typography.body}"
height: 64px
padding: 12px 32px
environment-quote-card:
backgroundColor: "{colors.surface-tile-1}"
textColor: "{colors.on-dark}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
footer:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink-muted-80}"
typography: "{typography.fine-print}"
padding: 64px
---
## Overview
Apple's web presence is a masterclass in **reverent product photography framed by near-invisible UI**. Every page is a stack of edge-to-edge product "tiles" — alternating light and dark canvases, each centered on a hero headline, a one-line tagline, two tiny blue pill CTAs, and an impossibly crisp product render. Nothing competes with the product. Typography is confident but quiet; color is either pure white, an off-white parchment, or a near-black tile; interactive elements are a single, quiet blue.
Density is unusually low even by contemporary SaaS standards. Each tile occupies roughly one viewport, and there is no decorative chrome — no borders, no gradients, no decorative frames, no shadows on headlines. Elevation appears only when a product image rests on a surface (a single soft `rgba(0, 0, 0, 0.22) 3px 5px 30px` drop for visual weight). The result is a catalog that feels more like a museum gallery: the wall disappears and the artifact takes over.
Store and shop surfaces retain the same chassis but switch modes. The product configurator (iPhone 17 Pro, accessories grid) introduces a tight grid of white utility cards at `{rounded.lg}` (18px) radius with a thin border, paired with a persistent thin sub-nav strip. The environment page leans darker and more editorial. Across all five surfaces the typographic system, spacing rhythm, and the single blue accent are consistent — this is one design language expressed at different volumes.
**Key Characteristics:**
- Photography-first presentation; UI recedes so the product can speak.
- Alternating full-bleed tile sections: white/parchment ↔ near-black, with the color change itself acting as the section divider.
- Single blue accent (`{colors.primary}`#0066cc) carries every interactive element. No second brand color exists.
- Two button grammars: tiny blue pill CTAs (`{rounded.pill}`) and compact utility rects (`{rounded.sm}`).
- SF Pro Display + SF Pro Text — negative letter-spacing at display sizes for the signature "Apple tight" headline feel.
- Whisper-soft elevation used only when a product image needs to breathe — exactly one drop-shadow in the entire system.
- Tight two-row nav: slim `{component.global-nav}` + product-specific `{component.sub-nav-frosted}` with persistent right-aligned primary CTA.
- Section rhythm across multiple pages: light hero → dark product tile → light utility tile → dark tile → parchment footer — a predictable pulse.
## Colors
> **Source pages analyzed:** homepage, environment, store, iPhone 17 Pro buy page, accessories index. The color system is identical across all five surfaces; only the surface-mode mix differs.
### Brand & Accent
- **Action Blue** (`{colors.primary}`#0066cc): The single brand-level interactive color. All text links, all blue pill CTAs ("Learn more", "Buy"), and the focus ring root. This is Apple's quiet but universal "click me" signal. Press state shifts to a slightly darker variant via the active scale transform rather than a hex change.
- **Focus Blue** (`{colors.primary-focus}`#0071e3): A marginally brighter sibling of Action Blue, reserved for the keyboard focus ring on buttons (`outline: 2px solid`).
- **Sky Link Blue** (`{colors.primary-on-dark}`#2997ff): A brighter blue used on dark surfaces for in-copy links and inline callouts, where Action Blue would disappear against the tile background.
### Surface
- **Pure White** (`{colors.canvas}`#ffffff): The dominant canvas. Content, utility cards, store tiles, configurator grids.
- **Parchment** (`{colors.canvas-parchment}`#f5f5f7): The signature Apple off-white. Used for alternating light tiles, footer region, and the default page canvas in store utility sections. Just different enough from white to create rhythm.
- **Pearl Button** (`{colors.surface-pearl}`#fafafc): A near-white used as the fill for secondary "ghost" buttons — lighter than the parchment canvas so the button still reads as a button against `{colors.canvas-parchment}`.
- **Near-Black Tile 1** (`{colors.surface-tile-1}`#272729): The primary dark-tile surface on the homepage product grid.
- **Near-Black Tile 2** (`{colors.surface-tile-2}`#2a2a2c): A micro-step lighter — used where a dark tile sits directly above or below Tile 1 to create the faintest separation.
- **Near-Black Tile 3** (`{colors.surface-tile-3}`#252527): A micro-step darker — used at the bottom of the stack and in embedded video/player frames.
- **Pure Black** (`{colors.surface-black}`#000000): Reserved for true void — video player backgrounds, edge-to-edge photographic overlays, the global nav bar background.
- **Translucent Chip Gray** (`{colors.surface-chip-translucent}`#d2d2d7): The base hex of the translucent gray chip used over photography for circular control buttons. In production, applied at ~64% alpha as `rgba(210, 210, 215, 0.64)`.
### Text
- **Near-Black Ink** (`{colors.ink}`#1d1d1f): The voice of every headline, every body paragraph, and the dark utility button's fill. Chosen instead of pure black to keep the page feeling photographic rather than printed.
- **Body** (`{colors.body}`#1d1d1f): Same hex as ink — Apple uses one near-black tone for all text on light surfaces.
- **Body On Dark** (`{colors.body-on-dark}`#ffffff): All text on dark tiles and on the global nav bar.
- **Body Muted** (`{colors.body-muted}`#cccccc): Secondary copy on dark tiles where pure white would be too loud.
- **Ink Muted 80** (`{colors.ink-muted-80}`#333333): Body text on the white Pearl Button surface — slightly softer than pure black.
- **Ink Muted 48** (`{colors.ink-muted-48}`#7a7a7a): Disabled button text and legal fine-print.
### Hairlines & Borders
- **Divider Soft** (`{colors.divider-soft}`#f0f0f0): The "border" tone on secondary buttons — functions as a ring shadow rather than a hard line. In production, often applied as `rgba(0, 0, 0, 0.04)`.
- **Hairline** (`{colors.hairline}`#e0e0e0): The 1px hairline border on store utility cards and configurator chips.
### Brand Gradient
**No decorative gradients.** Atmospheric depth on product photography (the iPhone 17 Pro camera plate, the Apple Watch bands, AirPods reflections) is inherent to the imagery, not a CSS gradient overlay. The environment page's hero uses photographic atmosphere (mountain vista at dawn) but no gradient tokens are defined. Apple is the rare luxury-brand site with zero gradient-based design tokens.
## Typography
### Font Family
- **Display**: `SF Pro Display, system-ui, -apple-system, sans-serif` — Apple's proprietary display face, optimized for sizes ≥ 19px. Defines the voice of every headline.
- **Body / UI**: `SF Pro Text, system-ui, -apple-system, sans-serif` — the text-optimized variant used for body copy, captions, buttons, and links below 20px.
- **OpenType features**: `font-variant-numeric: numerator` is enabled on numeric links (pricing tables, spec sheets). Display sizes rely on tight tracking rather than contextual ligatures.
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.hero-display}` | 56px | 600 | 1.07 | -0.28px | Hero headline; the signature "Apple tight" tracking |
| `{typography.display-lg}` | 40px | 600 | 1.10 | 0 | Tile headlines atop every product tile |
| `{typography.display-md}` | 34px | 600 | 1.47 | -0.374px | Section heads (SF Pro Text at display proportions) |
| `{typography.lead}` | 28px | 400 | 1.14 | 0.196px | Product tile subcopy |
| `{typography.lead-airy}` | 24px | 300 | 1.5 | 0 | Environment-page lead paragraphs (the rare weight 300) |
| `{typography.tagline}` | 21px | 600 | 1.19 | 0.231px | Sub-tile tagline; sub-nav category name |
| `{typography.body-strong}` | 17px | 600 | 1.24 | -0.374px | Inline strong emphasis |
| `{typography.body}` | 17px | 400 | 1.47 | -0.374px | Default paragraph |
| `{typography.dense-link}` | 17px | 400 | 2.41 | 0 | Footer / store utility link lists (relaxed leading) |
| `{typography.caption}` | 14px | 400 | 1.43 | -0.224px | Secondary captions, button text |
| `{typography.caption-strong}` | 14px | 600 | 1.29 | -0.224px | Emphasized captions |
| `{typography.button-large}` | 18px | 300 | 1.0 | 0 | Store hero CTAs (the rare weight 300) |
| `{typography.button-utility}` | 14px | 400 | 1.29 | -0.224px | Utility/nav button labels |
| `{typography.fine-print}` | 12px | 400 | 1.0 | -0.12px | Fine-print, footer body |
| `{typography.micro-legal}` | 10px | 400 | 1.3 | -0.08px | Micro legal disclaimers |
| `{typography.nav-link}` | 12px | 400 | 1.0 | -0.12px | Global nav menu items |
### Principles
- **Negative letter-spacing at display sizes.** Every headline at 17px and up carries a slight tracking tighten (`-0.12 → -0.374px`). This produces the iconic "Apple tight" headline cadence. Never used at 12px or below.
- **Body copy at 17px, not 16px.** Apple breaks the SaaS convention and runs paragraph text at 17px. The extra pixel gives the page an unmistakable "reading, not scanning" pace.
- **Weight 300 is real and rare.** Used deliberately on a handful of large-size reads (`{typography.button-large}` at 18px/300 and `{typography.lead-airy}` at 24px/300). It's not an accident — it's a light-atmosphere cue reserved for moments where the content should feel airy.
- **Weight 600, not 700, for headlines.** Apple's headlines sit at weight 600. Weight 700 is used sparingly for `{typography.tagline}` (21px) when a touch more assertion is needed.
- **Line-height is context-specific.** Display sizes use 1.071.19 (tight). Body uses 1.47. Utility link stacks in the footer/store use an unusually relaxed 2.41 (`{typography.dense-link}`). The 2.41 is not a bug — it's how the footer's dense link columns breathe.
- **Weight 500 is deliberately absent.** The ladder is 300 / 400 / 600 / 700. Mid-weight readings always use 600.
### Note on Font Substitutes
SF Pro is Apple's proprietary system font. When building off-system:
- Use `system-ui, -apple-system, BlinkMacSystemFont` as the first stack entry — on macOS/iOS/Safari this resolves to the real SF Pro.
- For non-Apple platforms, **Inter** (Google Fonts, variable) is the closest open-source equivalent. Inter at weight 600 with `font-feature-settings: "ss03"` approximates SF Pro's rounded "a" character.
- Nudge `letter-spacing` down by `-0.01em` on display sizes to re-create the Apple tight feel; Inter's default tracking runs slightly wider than SF Pro.
- For body text, tighten line-height by `0.03` (from 1.47 → 1.44) when substituting Inter — Inter's taller x-height needs less leading.
## Layout
### Spacing System
- **Base unit:** 8px. Sub-base values (2, 4, 5, 6, 7) are used for tight typographic adjustments; structural layout snaps to 8/12/16/20/24.
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 17px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px.
- **Section vertical padding:** `{spacing.section}` (80px) inside a product tile; tiles stack edge-to-edge with 0 gap (the color change provides the break).
- **Card padding:** `{spacing.lg}` (24px) inside utility grid cards.
- **Button padding:** 811px vertical, 1522px horizontal.
- **Universal rhythm constants:** the 17px body line-height multiplier (~25px line) and 21px tagline size show up on every analyzed page.
### Grid & Container
- **Max content width:** ~980px on text-heavy sections (environment), ~1440px on product grids (store, accessories), full-bleed for product tiles (homepage).
- **Column patterns:** 3 to 5 column utility card grid on store/accessories; 2-column side-by-side tiles on homepage occasional sections; single-column centered stack on product tile heroes.
- **Gutters:** 2024px between cards in a utility grid.
### Whitespace Philosophy
Apple's whitespace is the product's pedestal. Every tile begins with at least 64px of air above its headline and 4864px below. Product renders are never crowded; the nearest content to a product image is at least 40px away. The footer is the only area that breaks this — there, Apple goes deliberately dense to make the full information architecture visible at a glance.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| Flat | No shadow, no border | Full-bleed tiles, global nav, footer, body sections |
| Soft hairline | 1px `rgba(0, 0, 0, 0.08)` border | Utility cards, sub-nav frosted-glass separator |
| Backdrop blur | `backdrop-filter: blur(N)` on Parchment 80% | Sub-nav and the iPhone buy floating sticky bar |
| Product shadow | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0` | Product renders resting on a surface (the only true "shadow" in the system) |
**Shadow philosophy.** Apple uses **exactly one** drop-shadow, and it is applied to photographic product imagery — never to cards, never to buttons, never to text. Elevation in the UI comes from (a) surface-color change (light tile ↔ dark tile) and (b) backdrop-blur on sticky bars. The single shadow is about giving the product weight, not about UI hierarchy.
### Decorative Depth
- **Atmospheric imagery** on the environment page (photographic vista) supplies mood; no CSS gradient involved.
- **Edge-to-edge tile alternation** creates rhythm without borders or shadows — the color change itself is the divider.
- **Backdrop-filter blur** on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` creates a "floating over content" effect that's functional, not decorative.
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.none}` | 0px | Full-bleed product tiles (no corner rounding) |
| `{rounded.xs}` | 5px | Inline links when styled as subtle chips (rare) |
| `{rounded.sm}` | 8px | Dark utility buttons (Sign In, Bag), inline card imagery |
| `{rounded.md}` | 11px | White Pearl Button capsules |
| `{rounded.lg}` | 18px | Store utility cards, accessories grid cards |
| `{rounded.pill}` | 9999px | Primary blue pill CTAs, sub-nav buy button, configurator option chips, search input — the signature Apple pill |
| `{rounded.full}` | 9999px / 50% | Circular control chips floating over photography |
### Photography Geometry
- **Hero imagery**: full-bleed, 21:9 or taller on the homepage; 16:9 on environment and shop pages. Product renders are photographic-realistic, often shot on a tinted surface that becomes the tile background.
- **Product renders**: PNG/WebP with transparency; rest on a surface tile and pick up the system shadow.
- **Accessory grid**: square 1:1 crops at `{rounded.lg}` (18px) radius, light neutral backgrounds, product centered with 2040px internal padding.
- **No rounded imagery in hero tiles** — images are full-bleed rectangular. Rounding (`{rounded.sm}`, `{rounded.lg}`) appears only on inline card imagery.
- Lazy-loading via responsive `srcset` and `sizes` across all breakpoints; CDN-optimized WebP.
## Components
### Top Navigation
**`global-nav`** — Persistent, ultra-thin black nav bar pinned to the top of every page. Background `{colors.surface-black}`, height 44px, text `{colors.on-dark}` in `{typography.nav-link}` (12px / 400 / -0.12px tracking). Links are quiet, spaced ~20px apart, running edge-to-edge across the top. Right-aligned cluster: Search, Bag icons — always visible. On mobile, collapses to hamburger at ~834px and the Apple logo centers.
**`sub-nav-frosted`** — Surface-specific nav that sticks below the global nav. Background `{colors.canvas-parchment}` at 80% opacity with backdrop-filter blur, creating a frosted-glass effect. Height 52px. Content on left: product category name ("iPhone", "Store", "Accessories") in `{typography.tagline}` (21px / 600). Content right: inline nav links in `{typography.button-utility}` (14px), ending in a persistent `{component.button-primary}` ("Buy") or a utility link.
### Buttons
**`button-primary`** — The signature Apple action. Background `{colors.primary}` (Action Blue #0066cc), text `{colors.on-primary}` in `{typography.body}` (SF Pro Text 17px / 400), rounded `{rounded.pill}` (full pill — capsule-shaped), padding 11px × 22px. The full-pill radius IS the brand action signal.
- Active state: `{component.button-primary-active}``transform: scale(0.95)` (the system-wide micro-interaction).
- Focus state: `{component.button-primary-focus}` — 2px solid `{colors.primary-focus}` outline.
**`button-secondary-pill`** — Used as the second CTA when two blue pills appear together ("Learn more" / "Buy"). Background transparent, text `{colors.primary}`, 1px solid `{colors.primary}` border, rounded `{rounded.pill}`, padding 11px × 22px. Reads as a "ghost pill."
**`button-dark-utility`** — Global nav actions (Sign In, Bag, language selector). Background `{colors.ink}` (#1d1d1f), text `{colors.on-dark}` in `{typography.button-utility}` (14px / 400 / -0.224px tracking), rounded `{rounded.sm}` (8px), padding 8px × 15px. Active state shrinks via `transform: scale(0.95)`.
**`button-pearl-capsule`** — Product-card secondary button. Background `{colors.surface-pearl}` (#fafafc), text `{colors.ink-muted-80}` in `{typography.caption}` (14px), 3px solid `{colors.divider-soft}` border (functions as a soft ring rather than a visible line), rounded `{rounded.md}` (11px), padding 8px × 14px.
**`button-store-hero`** — A larger primary CTA used on store hero surfaces. Same Action Blue + Paper White as `{component.button-primary}`, but with `{typography.button-large}` (18px / 300 — note the rare weight 300) and slightly more padding (14px × 28px). Used sparingly on the store landing.
**`button-icon-circular`** — Floats over photography. 44 × 44px, background `{colors.surface-chip-translucent}` at ~64% alpha, icon in `{colors.ink}`, rounded `{rounded.full}`. Used for carousel controls, close buttons, and in-image controls (product image thumbnails on the iPhone buy page).
**`text-link`** — Inline body links in `{colors.primary}` (Action Blue). Underlined or non-underlined per context.
**`text-link-on-dark`** — Inline body links on dark tiles in `{colors.primary-on-dark}` (Sky Link Blue #2997ff) — Action Blue would disappear against `{colors.surface-tile-1}`.
### Cards & Containers
**`product-tile-light`** — Full-bleed light tile. Background `{colors.canvas}` (white), text `{colors.ink}`, rounded `{rounded.none}` (0 — tiles touch edges), vertical padding `{spacing.section}` (80px). Centered stack: product name in `{typography.display-lg}` (40px / 600) → one-line tagline in `{typography.lead}` (28px / 400) → two `{component.button-primary}` CTAs ("Learn more" / "Buy") → product render resting on the surface with the system shadow.
**`product-tile-parchment`** — Same as `{component.product-tile-light}` but on `{colors.canvas-parchment}` (#f5f5f7). Used to break two consecutive white tiles.
**`product-tile-dark`** — Full-bleed dark tile. Background `{colors.surface-tile-1}` (#272729), text `{colors.on-dark}`, rounded `{rounded.none}`, vertical padding `{spacing.section}` (80px). Same content stack as the light tile but with `{component.text-link-on-dark}` for inline copy and `{component.button-primary}` (Action Blue still works on the dark surface). Used on the homepage product grid as the alternating dark band.
**`product-tile-dark-2`** — Variant on `{colors.surface-tile-2}` (#2a2a2c). Used where a dark tile sits directly above or below `{component.product-tile-dark}` to create the faintest separation through micro-step lightness change.
**`product-tile-dark-3`** — Variant on `{colors.surface-tile-3}` (#252527). Used at the bottom of the stack and in embedded video/player frames.
**`store-utility-card`** — Used in store grid and accessories grid. Background `{colors.canvas}` (white), 1px solid `{colors.hairline}` border, rounded `{rounded.lg}` (18px), padding `{spacing.lg}` (24px). Top: product image (1:1 crop with `{rounded.sm}` (8px) inner image radius). Below: product name in `{typography.body-strong}` (17px / 600), price in `{typography.body}` (17px / 400), and a `{component.text-link}` ("Buy" or "Learn more"). No shadow by default; product render itself carries the system product-shadow.
**`configurator-option-chip`** — Pill-shaped tappable cell used in the iPhone 17 Pro buy page. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.caption}`, rounded `{rounded.pill}`, padding 12px × 16px. Contains a small product thumbnail + label + price delta. Arranged in a grid of 45 options per row.
**`configurator-option-chip-selected`** — Selected state. Border upgrades to 2px solid `{colors.primary-focus}`. Same shape, same content.
**`environment-quote-card`** — A photographic-canvas hero specific to the environment page. Dark photographic backdrop (mountain vista at dawn) with `{colors.surface-tile-1}` as the fallback color, centered white-text headline in `{typography.display-lg}` (40px), small green "Apple 2030" pictographic logo above the headline, single `{component.button-primary}` below. Padding `{spacing.section}` (80px).
**`floating-sticky-bar`** — Floats at the bottom of the viewport on the iPhone 17 Pro buy page during scroll. Background `{colors.canvas-parchment}` at 80% opacity with `backdrop-filter: blur(N)`, height 64px, padding 12px × 32px. Left: running price total in `{typography.body}`. Right: `{component.button-primary}` ("Add to Bag").
### Inputs & Forms
**`search-input`** — The accessories search input. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.body}` (17px), 1px solid `rgba(0, 0, 0, 0.08)` border, rounded `{rounded.pill}` (full pill — search is also pill-shaped, matching the CTA grammar), padding 12px × 20px, height 44px. Leading icon: search glyph at 14px, muted tint.
Error and validation states were not surfaced in the analyzed pages.
### Footer
**`footer`** — Background `{colors.canvas-parchment}` (#f5f5f7), text `{colors.ink-muted-80}`. Link columns in `{typography.dense-link}` (17px / 400 / 2.41 line-height — the relaxed leading is what makes the dense columns scannable). Column headings in `{typography.caption-strong}` (14px / 600). Legal row at the very bottom in `{typography.fine-print}` (12px / 400) with `{colors.ink-muted-48}` text. Vertical padding 64px.
## Do's and Don'ts
### Do
- Use `{colors.primary}` (Action Blue #0066cc) for every interactive element — links, pill CTAs, focus signals — and nothing else. The single accent is non-negotiable.
- Set headlines in `{typography.hero-display}` or `{typography.display-lg}` with negative letter-spacing (`-0.28 → -0.374px`) to get the signature "Apple tight" cadence.
- Run body copy at `{typography.body}` (17px / 400 / 1.47 / -0.374px) — not 16px. The extra pixel defines the brand's reading pace.
- Alternate `{component.product-tile-light}` (or parchment) and `{component.product-tile-dark}` for full-bleed section rhythm. The color change IS the divider.
- Reserve `{rounded.pill}` for the primary blue CTA and any other element that should read as an "action" (configurator chips, search input, sticky bar CTA).
- Apply the single product-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) only to product renders resting on a surface — never on cards, buttons, or text.
- Use `transform: scale(0.95)` as the active/press state on every button — it's the system-wide micro-interaction.
- Keep the global nav `{colors.surface-black}` (true black) — it's the only place pure black appears on most pages.
### Don't
- Don't introduce a second accent color; every "click me" signal is `{colors.primary}` (Action Blue).
- Don't add shadows to cards, buttons, or text — shadow is reserved for product imagery.
- Don't use gradients as decorative backgrounds; atmosphere comes from photography.
- Don't set body copy at weight 500 — Apple's ladder is 300 / 400 / 600 / 700, with 500 deliberately absent. Body is always 400; strong inline is 600; display is 600.
- Don't round full-bleed tiles — tiles are rectangular and edge-to-edge; the color change is the divider.
- Don't tighten line-height below 1.47 for body copy — the editorial leading is part of the brand.
- Don't mix radii grammars — use `{rounded.sm}` for compact utility, `{rounded.lg}` for utility cards, `{rounded.pill}` for pills, and nothing in between (except the rare `{rounded.md}` Pearl Button).
- Don't use `{colors.primary-on-dark}` (Sky Link Blue) on light surfaces — it's the dark-tile-only variant. Action Blue is for light surfaces.
## Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Small phone | ≤ 419px | Single-column tiles; sub-nav collapses to category name + primary CTA only; hero typography drops to 28px |
| Phone | 420640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px |
| Large phone | 641735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps |
| Tablet portrait | 736833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA |
| Tablet landscape | 8341023px | Global nav returns fully expanded; 3-column utility grids become 2-column |
| Small desktop | 10241068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px |
| Desktop | 10691440px | Full layout; 45 column store grids; 1440px content max |
| Wide desktop | ≥ 1441px | Content locks at 1440px, margins absorb extra width |
The structural breakpoints that matter for agents: 1440px (content lock), 1068px (small-desktop), 833px (tablet landscape switch), 734px (tablet portrait), 640px (phone), 480px (small phone).
### Touch Targets
- Minimum 44 × 44px. `{component.button-primary}` lands at ~44 × 100px (with the full-pill radius making the visible hit area more generous than the label suggests).
- `{component.button-icon-circular}` is exactly 44 × 44px.
- Global nav utility links are smaller (~32 × 80px) — they deliberately sit at a tighter target because they're precision desktop actions, and the mobile hamburger replaces them at ≤ 833px.
### Collapsing Strategy
- **Global nav**: full horizontal link row on desktop → collapses to Apple logo + hamburger + bag icon at 834px and below.
- **Sub-nav**: category name + inline links + primary CTA → category name + primary CTA only at mobile; inline links move into a hamburger tray.
- **Product tiles**: stack from 2-column to 1-column at 834px; vertical padding tightens from 80px → 48px at small-phone.
- **Utility grids** (store, accessories): 5-col → 4-col (1440px) → 3-col (1068px) → 2-col (834px) → 1-col (640px).
- **Hero typography**: `{typography.hero-display}` (56px) → `{typography.display-lg}` (40px) at 1068px → 34px at 640px → 28px at 419px.
### Image Behavior
- All product imagery uses responsive `srcset` with breakpoint-matched crops.
- Hero photography may switch art direction at mobile (e.g., the environment page's vista crops to a taller aspect ratio on mobile, framing the subject differently).
- Product renders maintain their 1:1 or 4:3 aspect ratios across breakpoints; only scale changes.
- Lazy-loading is default; the above-fold hero loads eagerly.
## Iteration Guide
1. Focus on ONE component at a time. Reference its YAML key directly (`{component.product-tile-dark}`, `{component.search-input}`).
2. Variants of an existing component (`-active`, `-focus`, `-2`, `-3`) live as separate entries in `components:`.
3. Use `{token.refs}` everywhere — never inline hex.
4. Never document hover. Default and Active/Pressed states only.
5. Display headlines stay SF Pro Display 600 with negative letter-spacing. Body stays SF Pro Text 400 at 17px. The boundary is unbreakable.
6. The single drop-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) is reserved for product photography only.
7. When in doubt about emphasis: alternate surface (light → dark tile) before adding chrome.
## Known Gaps
- Form validation and error states were not surfaced on the analyzed pages; only the neutral search input is documented.
- The homepage's embedded video/player frame uses `{colors.surface-black}`; interior player controls are not documented (they're a platform widget, not a web-design token).
- Some component imagery is dynamic (rotating product hero) and its specific copy varies per surface — component specs name the structure, not the rotating content.
- Dark-mode counterparts for store and accessories utility cards were not surfaced on the analyzed pages; the system documented is the daytime/light-dominant variant Apple ships by default.
- Atmospheric photography (environment page mountain vista) is a content asset, not a design token; the documented `{component.environment-quote-card}` describes the structural surface only.
- The exact backdrop-filter blur radius on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` is platform-dependent; production CSS uses `saturate(180%) blur(20px)` as a typical baseline but the value isn't formalized as a token.

121
LOG.md Normal file
View File

@@ -0,0 +1,121 @@
# Work Log
## 2026-06-24
- Refined the chess board visual style to match the app's glass/control surfaces instead of the previous heavy wood-and-token treatment.
- Removed the circular chip backgrounds from pieces, kept high-contrast glyphs with subtle outlines, softened square/highlight colors, and tightened coordinate labels for a cleaner chess UI.
- Rebalanced chess board sizing and layout columns so the board window, actual board, side panel, and Dock spacing feel less awkward on a 1280x720 desktop viewport.
- Validation: `npm run lint` passed and `npm run build` passed. Browser verification on `http://localhost:3124/play/chess` with a temporary local puzzle API confirmed 64 squares, 32 rendered pieces, a 350px visible board above the Dock, balanced 480px/320px board-panel windows, and no console errors. The temporary API was stopped after verification.
- Recommended next task: verify the real authenticated `/chess/play/{gameId}` board against the Maia backend at common laptop heights and tune only the board size formula if the live game info panel changes vertical rhythm.
## 2026-06-19
- Added Maia game resign and undo API wrappers, then wired the play screen with in-progress-only "무르기" and "기권" actions plus a persistent "새 게임" link back to the setup screen.
- Locked board/actions during move, undo, and resign requests, updated the game cache from backend responses as the single source of truth, and refreshed history/stat queries after successful state changes.
- Added resigned-loss labeling so play, recent games, and history can show `기권패` for `RESIGNED` losses while still grouping them under losses.
- Validation: `npm run lint` passed and `npm run build` passed. Authenticated browser verification against the live Maia backend was not run in this session.
- Recommended next task: test resign and undo with an authenticated real game, including black-player games where Maia's opening white move must remain after undo.
- Reworked the shared chess board UI with a bordered board surface, high-contrast chip-style pieces, stronger selected/legal/last-move highlights, and transform-based piece movement transitions so player and Maia moves read as motion instead of instant jumps.
- Connected the daily puzzle board to the shared `ChessBoard` component, removing duplicated piece rendering and adding drag/drop support for puzzle moves.
- Updated Maia play state so optimistic player moves are highlighted locally while pending, then Maia's returned move can animate from the server response; changed the pending overlay into a small status pill so it no longer hides the board.
- Validation: `npm run lint` passed and `npm run build` passed. A local dev server is running on `http://localhost:3124`; browser verification with a temporary local puzzle API confirmed 64 squares, 32 piece-layer nodes, 0.42s piece transition styling, square selection/legal target markers, and no console errors. The temporary API was stopped after verification.
- Recommended next task: verify `/chess/play/{gameId}` against the real Maia backend with an authenticated account to tune the exact Maia response timing and capture/promotion edge cases.
- Added the authenticated Maia chess frontend: `/chess` lobby with rating/color/model and advanced settings, `/chess/play/[gameId]` playable board with backend FEN synchronization and PGN copy, and `/chess/history` account game history with outcome filters.
- Extended the chess API client and shared types for Maia game create/list/stats/detail/PGN/move endpoints while leaving the public `/api/chess-puzzles/**` puzzle request unchanged.
- Updated the Dock/menu/shell route handling so Chess opens the Maia lobby, with the existing public puzzle page still available at `/play/chess`.
- Validation: `npm run lint` passed and `npm run build` passed. A foreground `next dev --port 3122` startup reached Ready, but persistent detached/browser verification could not be completed because sandbox-launched background dev processes exited or hung before serving requests.
- Recommended next task: verify `/chess`, `/chess/play/{gameId}`, and `/chess/history` in an authenticated browser session against a reachable backend, especially real Maia move latency/error responses.
## 2026-06-07
- Fixed admin pages being pushed outside the visible content area by removing viewport-based `md:w-[80vw]` widths from the admin route shell and Markdown editor container.
- Changed admin containers to use the remaining shell content width (`w-full` with `max-w-[1400px]`) so the sidebar margin no longer combines with an extra viewport-width layout.
- Validation: `npm run lint` passed and `npm run build` passed. Authenticated browser verification was not possible in the current session because `/admin` redirects to login without an admin token.
- Recommended next task: verify `/admin` and `/admin/posts/new` in an authenticated browser session at desktop widths around 1280px and 1440px.
- Fixed the admin dashboard traffic chart visibility by giving bar columns a real height context, so percentage-based bar heights render instead of collapsing.
- Added fallback traffic points from recent/popular post data when the dashboard API or `traffic` array is empty, keeping the graph visible while the backend traffic aggregation is unavailable.
- Validation: `npm run lint` passed and `npm run build` passed. Browser navigation to `/admin` was attempted on `http://localhost:3112/`, but the current browser session was unauthenticated and rendered the login screen, so the admin chart itself was validated by code path and build checks rather than a live authenticated screenshot.
- Recommended next task: verify `/admin` with an authenticated admin session against the production API to confirm the real `traffic` payload replaces the fallback graph.
- Simplified the macOS-inspired layout by removing duplicated navigation surfaces: the desktop shortcut icon layer, the home page's inner Finder sidebar, and app navigation links from the system menu bar.
- Reworked the global sidebar into a cleaner library panel focused on profile, search, and categories, with a quieter compact profile row and no duplicated app shortcuts.
- Kept app-level movement in the Dock, category/search movement in the sidebar, and current-context/status information in the menu bar so each shell area has a clearer role.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3112/` confirmed no horizontal overflow, no desktop shortcut duplicates, the simplified home window, the Korean sidebar header, and no browser console warnings/errors. The local backend remained unavailable, so the home page showed empty post states.
- Recommended next task: test the simplified sidebar with real production categories and long category names, then tune category row spacing if the populated tree feels too dense.
## 2026-06-06
- Reworked the macOS-inspired shell into a fuller desktop illusion: full-width system menu bar, floating Finder-style sidebar window, viewport-centered Dock, desktop wallpaper layers, and desktop shortcut icons for major destinations.
- Rebuilt the public home screen as a Finder window with a locations sidebar, disk/status strip, file-like post rows, Spotlight-style search results, and stronger shared window glass treatment.
- Extended the OS treatment across archive, category, reader, login/signup, admin shell, post cards, and list rows, including file icons, Reader window framing, and cleanup of a stale mojibake notice-category check.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3112/`, `/archive`, and `/login` confirmed no horizontal overflow, desktop/mobile menu and Dock visibility rules, shared window surfaces, and no browser console warnings/errors. The default local API data rendered empty post states during the browser pass.
- Recommended next task: run a visual pass against `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me` so populated post lists and one real reader page can be tuned with long Korean titles and real content.
- Fixed the desktop Dock so route changes from Dock menu links no longer leave focus-held open state behind when the Dock is not pinned.
- Kept the saved `dock-pinned-v2` preference unchanged during navigation and only released transient Dock focus/hover state after menu clicks and pathname changes.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3111/` confirmed that after unpinning the Dock, clicking the Dock archive menu navigates to `/archive`, the pin button remains `aria-pressed=false`, and the Dock collapses after the pointer leaves.
- Recommended next task: do one visual pass on the Dock at narrow desktop widths to confirm the collapse animation still feels smooth around the sidebar offset.
## 2026-06-02
- Refined the public layout after desktop/mobile review: the shell now guards horizontal overflow, uses route-aware bottom padding, hides the top menubar on mobile, and opens the mobile sidebar/search panel from the Dock.
- Reworked the Dock so mobile uses it as the primary navigation hub, while desktop keeps a folded Dock that expands on hover/focus and includes a pin toggle.
- Smoothed the desktop Dock hide/show interaction by separating hover-open, closing, and fully-collapsed states; the Dock now keeps the expanded hit area during the closing animation so re-entering mid-collapse reverses smoothly.
- Changed the desktop Dock's default state to pinned/open and added distinct soft pastel tones to Dock buttons, including stronger visual treatment for pin/unpin and auth actions.
- Softened the Dock button palette to quieter pastel tones and made the light-mode Dock bar read as a cleaner white glass surface.
- Fixed the Dock pin toggle so mouse-based unpinning no longer leaves button focus holding the Dock open, and changed the Dock Home button to a white neutral tone.
- Reworked the Dock material away from cute pastel tiles into a more macOS-like neutral frosted glass texture with translucent white glass buttons.
- Corrected the Dock button direction so only Home stays white, while the other actions keep subtle pastel tints with a translucent macOS-style glass material.
- Increased Dock button translucency contrast so the buttons remain glassy while sitting slightly more opaque than the Dock background.
- Rebalanced the home page around latest posts with popular posts as a supporting panel, converted the post detail page into a quieter reader surface, and hardened Markdown/code/table wrapping against mobile overflow.
- Added a client-side archive explorer with year, month, category, and compact/comfort density controls over the existing server-fetched post list.
- Rebuilt the chess page with safer viewport-aware board sizing, quieter puzzle panels, and enough layout clearance for the Dock.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on a local `next start` server confirmed no horizontal overflow on `/`, `/archive`, and `/play/chess` at mobile width, mobile Dock-centered navigation opens the sidebar panel without overflow, desktop CSS loads, the mobile Dock is hidden on desktop, and the desktop Dock starts folded with a pin control. The live chess board itself could not be verified because the local backend chess API was unavailable, so `/play/chess` rendered the error state.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on a local `next start` server confirmed the desktop Dock starts as a small bottom tab, uses 560ms transitions for tab/panel geometry, and expands into the larger Dock hit area when opened.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on a local `next start` server confirmed the Dock opens by default and Home/Archive/Chess/Login/Signup/Pin buttons render distinct pastel colors.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3106/` confirmed the light-mode Dock bar renders as a cleaner white glass surface and the Dock button colors use quieter pastel tones.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3107/` confirmed the Dock Home button renders white and mouse-based unpinning collapses the Dock after the pointer leaves.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3108/` confirmed the Dock uses neutral translucent glass material, with non-home buttons rendering as subtle white glass rather than pastel color tiles.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3109/` confirmed Home stays white while the other Dock buttons keep pastel-tinted translucent glass backgrounds with button-level blur.
- Validation: `npm run lint` passed and `npm run build` passed. In-app browser verification on `http://localhost:3110/` confirmed Dock buttons remain translucent but render slightly more opaque than the Dock background for clearer contrast.
- Recommended next task: run a browser pass against a reachable backend API, especially one real post detail route and the populated chess puzzle board, then tune any remaining content-specific long-title or board-height edge cases.
## 2026-05-29
- Fixed post detail 404s caused by double-encoding dynamic route slugs that already arrive percent-encoded from URLs such as `/posts/soft-delete%2C-hard-delete`.
- Updated the public post fetch helper to decode a route slug once before encoding it for the backend API path, preserving normal Korean/special-character slug requests while avoiding `%252C`-style API lookups.
- Validation: `npm run lint` passed and `npm run build` passed. A local production server with `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me` returned real post titles for `/posts/soft-delete%2C-hard-delete`, `/posts/destructuring-%26-component%ED%95%A8%EC%88%98`, and `/posts/rtr-(refresh-token-rotation)`.
- Recommended next task: deploy the slug normalization fix before re-checking affected live post URLs in Search Console or the browser.
- Added a category post-list page size control with 9/18/27 options, defaulting to 9 and persisting the user's choice in `localStorage` under `categoryPageSize`.
- Wired the selected category page size into the React Query key and `getPostsByCategory` request size, resetting to page 1 when the size changes while preserving search behavior.
- Adjusted the category page toolbar so search, page-size controls, and grid/list controls stack cleanly on narrow screens and align in one row on desktop.
- Validation: `npm run lint` passed and `npm run build` passed. Static verification confirms the category query uses `pageSize` and the page-size selector persists to `localStorage`; interactive browser verification was skipped because no local browser/Playwright automation runtime is available in this environment.
- Recommended next task: verify the category toolbar in a real browser after deploy, especially mobile widths and the 18/27 page-size transitions.
- Investigated Google Search Console crawlability concerns for the live site.
- Checked live `robots.txt`, `sitemap.xml`, homepage, sample post pages, and backend post API with a Googlebot user agent; public post URLs in the sitemap returned 200 with no `X-Robots-Tag` or meta robots block.
- Confirmed the non-www production host `blog.wypark.me` is the relevant host; its home/archive initial HTML currently contains no `/posts/` links because public lists are client-side rendered.
- Identified crawlability risk areas: home/archive rely on client-side post-list fetching, local builds generate a static-only sitemap when the backend API is unreachable, sitemap `lastmod` values can be timezone-skewed when backend timestamps omit an offset, and canonical URLs are not currently emitted.
- Validation: `npm run build` passed. As expected without a local backend, build-time sitemap generation logged `fetch failed` and produced only static sitemap entries in the local `.next` output.
- Converted home and archive pages to server-rendered public post lists through a dedicated `src/api/publicPosts.ts` fetch helper so first HTML now includes discoverable `/posts/` links.
- Added centralized site metadata utilities, canonical metadata for public pages/posts, dynamic sitemap generation, KST-aware API date parsing for `lastmod`, and robots/site URL reuse.
- Validation: `npm run lint` passed and `npm run build` passed. A local production server with `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me` returned 22 `/posts/` links on `/`, 204 on `/archive`, canonical URLs for both pages, and sitemap output with 104 URLs including 102 post URLs.
- Recommended next task: deploy the SEO crawlability fix, then resubmit `https://blog.wypark.me/sitemap.xml` and a representative post URL in Google Search Console URL Inspection.
- Redesigned the app shell into a macOS-inspired blog OS with pastel desktop background tokens, translucent menu bar, quick-launch Dock, and reusable window surfaces.
- Applied the window treatment to the home dashboard, post reader, archive, category view, auth screens, chess puzzle view, and admin management shells.
- Restored the missing local `chess.js` install in `node_modules` so build verification could run; no dependency version changes were intended.
- Refined the desktop layout after review: the sidebar can now collapse on PC, the main window width is more consistent across dashboard/list/reader/chess/admin views, the sidebar archive shortcut was removed, and the top menu now uses WYPark branding with GitHub/email links plus theme switching only.
- Moved auth/admin/write/logout actions into the Dock and changed category windows/menu labels to show the actual category name instead of Finder/posts copy.
- Tuned the follow-up layout: main content now centers inside the area remaining after the desktop sidebar, sidebar GitHub/email shortcuts were removed, top-bar GitHub/email links now sit as plain left-side menu items, and the home labels were simplified to 홈/WYPark.
- Softened the home dashboard inner popular/latest sections so they no longer render as nested macOS windows with traffic-light controls; they now read as embedded dashboard panels inside the WYPark window.
- Rebuilt the admin post writing screen as a glassy Markdown Studio with a larger writing area, publish controls, category/tag/image/draft panels, and fixed MDEditor foreground colors so typed text remains readable in both light and dark themes.
- Hardened the Markdown editor text visibility fix by binding the editor color mode to the app's resolved theme and forcing the real textarea layer to use `--color-text` while hiding the syntax overlay text layer that could inherit the wrong contrast.
- Validation: `npm run lint` passed, `npm run build` passed. The build logged a sitemap fetch warning because the backend API at the local default was not reachable, but the command completed successfully.
- Browser verification: previously started this app on `http://localhost:3100` because ports 3000 and 3001 were already occupied; confirmed desktop background gradients, menu bar, Dock, and window surfaces on `/`, `/archive`, and `/login`. For this follow-up, the dev/standalone server started successfully in foreground but exited when launched as a background non-interactive process, so browser verification was skipped after lint/build passed.
- Recommended next task: review the remaining older Korean strings in admin/editor flows and normalize any mojibake that predates this redesign.
- Unified the macOS-inspired layer system by splitting window, card, control, sidebar, menu bar, and Dock border/shadow/blur tokens, with stronger dark-mode card separation.
- Applied the layer tokens across shared surfaces, the menu bar, Dock, sidebar controls, search/toggles, home dashboard panels, reader navigation cards, and admin dashboard/editor panels.
- Validation: `npm run lint` passed, `npm run build` passed, and in-app browser verification on `http://localhost:3100/` confirmed distinct light/dark computed border, shadow, and backdrop blur values for the menu bar, main window, internal cards, and Dock.
- Recommended next task: do a visual pass on lower-traffic admin list/modal screens and convert any remaining ad hoc hover fills to the shared card/control tokens.

7
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"chess.js": "^1.4.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
@@ -3157,6 +3158,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/chess.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz",
"integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==",
"license": "BSD-2-Clause"
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
@@ -16,6 +16,7 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"chess.js": "^1.4.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",

85
src/api/chess.ts Normal file
View File

@@ -0,0 +1,85 @@
import { http } from './http';
import {
ApiResponse,
ChessGameCreateRequest,
ChessGamePageResponse,
ChessGamePgnResponse,
ChessGameResponse,
ChessGameStatsResponse,
ChessMoveRequest,
ChessPuzzle,
} from '@/types';
const requireApiData = <T>(response: ApiResponse<T>, fallbackMessage: string) => {
if (!response.data) {
throw new Error(response.message || fallbackMessage);
}
return response.data;
};
export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => {
const response = await http.get<ApiResponse<ChessPuzzle>>('/api/chess-puzzles/today', {
params: { timezone },
});
return response.data.data;
};
export const createChessGame = async (data: ChessGameCreateRequest) => {
const response = await http.post<ApiResponse<ChessGameResponse>>('/api/chess/games', data);
return requireApiData(response.data, '대국을 생성하지 못했습니다.');
};
export const getChessGames = async ({
page = 0,
size = 20,
sort = 'updatedAt,desc',
}: {
page?: number;
size?: number;
sort?: string;
} = {}) => {
const response = await http.get<ApiResponse<ChessGamePageResponse>>('/api/chess/games', {
params: { page, size, sort },
});
return requireApiData(response.data, '대국 기록을 불러오지 못했습니다.');
};
export const getChessGameStats = async () => {
const response = await http.get<ApiResponse<ChessGameStatsResponse>>('/api/chess/games/stats');
return requireApiData(response.data, '대국 통계를 불러오지 못했습니다.');
};
export const getChessGame = async (gameId: string) => {
const response = await http.get<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}`);
return requireApiData(response.data, '대국 상세를 불러오지 못했습니다.');
};
export const getChessGamePgn = async (gameId: string) => {
const response = await http.get<ApiResponse<ChessGamePgnResponse>>(`/api/chess/games/${gameId}/pgn`);
return requireApiData(response.data, 'PGN을 불러오지 못했습니다.');
};
export const postChessMove = async (gameId: string, data: ChessMoveRequest) => {
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/moves`, data);
return requireApiData(response.data, '착수를 처리하지 못했습니다.');
};
export const resignChessGame = async (gameId: string) => {
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/resign`);
return requireApiData(response.data, '기권을 처리하지 못했습니다.');
};
export const undoChessMove = async (gameId: string) => {
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/undo`);
return requireApiData(response.data, '무르기를 처리하지 못했습니다.');
};

View File

@@ -1,5 +1,41 @@
import { http } from './http'; import { http } from './http';
import { ApiResponse, Comment, CommentSaveRequest, CommentDeleteRequest } from '@/types'; import {
AdminComment,
AdminCommentListResponse,
ApiResponse,
Comment,
CommentDeleteRequest,
CommentSaveRequest,
} from '@/types';
type RawCommentAuthor = {
nickname?: string;
name?: string;
};
type RawCommentPost = {
slug?: string;
title?: string;
postSlug?: string;
postTitle?: string;
};
type RawAdminComment = AdminComment & {
nickname?: string;
authorName?: string;
writer?: string;
member?: RawCommentAuthor | null;
guest?: RawCommentAuthor | null;
post?: RawCommentPost | null;
postResponse?: RawCommentPost | null;
postName?: string;
articleSlug?: string;
articleTitle?: string;
};
type RawAdminCommentListResponse = Omit<AdminCommentListResponse, 'content'> & {
content: RawAdminComment[];
};
// 1. 댓글 목록 조회 // 1. 댓글 목록 조회
export const getComments = async (postSlug: string) => { export const getComments = async (postSlug: string) => {
@@ -24,12 +60,62 @@ export const deleteComment = async (id: number, password?: string) => {
return response.data; return response.data;
}; };
const normalizeAdminComment = (comment: RawAdminComment): AdminComment => ({
...comment,
author: comment.author
?? comment.memberNickname
?? comment.guestNickname
?? comment.nickname
?? comment.authorName
?? comment.writer
?? comment.member?.nickname
?? comment.member?.name
?? comment.guest?.nickname
?? comment.guest?.name,
postSlug: comment.postSlug
?? comment.post?.slug
?? comment.post?.postSlug
?? comment.postResponse?.slug
?? comment.postResponse?.postSlug
?? comment.articleSlug,
postTitle: comment.postTitle
?? comment.post?.title
?? comment.post?.postTitle
?? comment.postResponse?.title
?? comment.postResponse?.postTitle
?? comment.postName
?? comment.articleTitle,
});
const normalizeAdminComments = (
data: RawAdminCommentListResponse | RawAdminComment[],
): AdminCommentListResponse => {
if (Array.isArray(data)) {
return {
content: data.map(normalizeAdminComment),
totalElements: data.length,
totalPages: 1,
number: 0,
last: true,
};
}
return {
...data,
content: data.content.map(normalizeAdminComment),
totalElements: data.page?.totalElements ?? data.totalElements,
totalPages: data.page?.totalPages ?? data.totalPages,
number: data.page?.number ?? data.number,
last: data.page?.last ?? data.last,
};
};
// 4. 관리자 댓글 목록 조회 (대시보드용) // 4. 관리자 댓글 목록 조회 (대시보드용)
export const getAdminComments = async (page = 0, size = 20) => { export const getAdminComments = async (page = 0, size = 20) => {
const response = await http.get<ApiResponse<any>>('/api/admin/comments', { const response = await http.get<ApiResponse<RawAdminCommentListResponse | RawAdminComment[]>>('/api/admin/comments', {
params: { page, size }, params: { page, size },
}); });
return response.data.data; return normalizeAdminComments(response.data.data);
}; };
// 5. 관리자 댓글 강제 삭제 // 5. 관리자 댓글 강제 삭제

13
src/api/dashboard.ts Normal file
View 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;
};

View File

@@ -1,4 +1,4 @@
import axios from 'axios'; import axios, { AxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
// 🛠️ 환경 변수 처리 (배포 환경 대응) // 🛠️ 환경 변수 처리 (배포 환경 대응)
@@ -26,10 +26,33 @@ http.interceptors.request.use(
// --- 토큰 갱신 관련 변수 --- // --- 토큰 갱신 관련 변수 ---
let isRefreshing = false; 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) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
prom.reject(error); prom.reject(error);
@@ -43,7 +66,7 @@ const processQueue = (error: any, token: string | null = null) => {
// 실제 토큰 갱신을 수행하는 함수 (Lock 안에서 실행됨) // 실제 토큰 갱신을 수행하는 함수 (Lock 안에서 실행됨)
async function handleTokenRefresh() { async function handleTokenRefresh() {
try { try {
const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login, logout } = useAuthStore.getState(); const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login } = useAuthStore.getState();
// [1] 로컬 스토리지 확인 (다른 탭에서 이미 갱신했는지 체크) // [1] 로컬 스토리지 확인 (다른 탭에서 이미 갱신했는지 체크)
let actualRefreshToken = currentRefreshToken; let actualRefreshToken = currentRefreshToken;
@@ -53,12 +76,12 @@ async function handleTokenRefresh() {
const storageData = localStorage.getItem('auth-storage'); const storageData = localStorage.getItem('auth-storage');
if (storageData) { if (storageData) {
try { try {
const parsed = JSON.parse(storageData); const parsed = JSON.parse(storageData) as AuthStorageData;
const storedRefreshToken = parsed.state?.refreshToken; const storedRefreshToken = parsed.state?.refreshToken;
const storedAccessToken = parsed.state?.accessToken; const storedAccessToken = parsed.state?.accessToken;
// 저장된 토큰이 현재 메모리의 토큰과 다르다면? => 이미 다른 탭/요청이 갱신을 완료함! // 저장된 토큰이 현재 메모리의 토큰과 다르다면? => 이미 다른 탭/요청이 갱신을 완료함!
if (storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) { if (storedAccessToken && storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) {
// 현재 탭의 스토어 상태를 스토리지와 동기화 // 현재 탭의 스토어 상태를 스토리지와 동기화
login(storedAccessToken, storedRefreshToken); login(storedAccessToken, storedRefreshToken);
// 대기 중인 요청들 해소 // 대기 중인 요청들 해소
@@ -70,7 +93,7 @@ async function handleTokenRefresh() {
// 갱신 시도할 토큰 정보를 최신 스토리지 값으로 설정 // 갱신 시도할 토큰 정보를 최신 스토리지 값으로 설정
if (storedRefreshToken) actualRefreshToken = storedRefreshToken; if (storedRefreshToken) actualRefreshToken = storedRefreshToken;
if (storedAccessToken) actualAccessToken = storedAccessToken; if (storedAccessToken) actualAccessToken = storedAccessToken;
} catch (e) { } catch {
// console.error('Storage parse error', e); // console.error('Storage parse error', e);
} }
} }
@@ -113,9 +136,10 @@ async function handleTokenRefresh() {
http.interceptors.response.use( http.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config as RetriableRequestConfig | undefined;
if (!error.response) return Promise.reject(error); if (!error.response) return Promise.reject(error);
if (!originalRequest) return Promise.reject(error);
const status = error.response.status; const status = error.response.status;
@@ -143,9 +167,11 @@ http.interceptors.response.use(
try { try {
let newToken; let newToken;
if (typeof navigator !== 'undefined' && 'locks' in navigator) { const locks = typeof navigator !== 'undefined' ? (navigator as NavigatorWithLocks).locks : undefined;
if (locks) {
// Lock을 획득한 놈만 handleTokenRefresh 실행 // Lock을 획득한 놈만 handleTokenRefresh 실행
newToken = await (navigator as any).locks.request('auth-refresh-lock', async () => { newToken = await locks.request('auth-refresh-lock', async () => {
return await handleTokenRefresh(); return await handleTokenRefresh();
}); });
} else { } else {

View File

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

75
src/api/publicPosts.ts Normal file
View File

@@ -0,0 +1,75 @@
import { ApiResponse, Post, PostListResponse } from '@/types';
export const PUBLIC_POSTS_REVALIDATE_SECONDS = 300;
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
type PublicPostListParams = {
page?: number;
size?: number;
keyword?: string;
category?: string;
tag?: string;
sort?: string;
};
const buildPostListUrl = (params: PublicPostListParams = {}) => {
const searchParams = new URLSearchParams();
Object.entries({
...params,
sort: params.sort || 'createdAt,desc',
}).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
searchParams.set(key, String(value));
}
});
return `${API_URL}/api/posts?${searchParams.toString()}`;
};
const encodeSlugPathSegment = (slug: string) => {
try {
return encodeURIComponent(decodeURIComponent(slug));
} catch {
return encodeURIComponent(slug);
}
};
export const fetchPublicPosts = async (
params: PublicPostListParams = {},
): Promise<PostListResponse | null> => {
try {
const response = await fetch(buildPostListUrl(params), {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: PUBLIC_POSTS_REVALIDATE_SECONDS },
});
if (!response.ok) return null;
const json = (await response.json()) as ApiResponse<PostListResponse>;
return json.data;
} catch (error) {
console.error('Public posts fetch error:', error);
return null;
}
};
export const fetchPublicPost = async (slug: string): Promise<Post | null> => {
try {
const response = await fetch(`${API_URL}/api/posts/${encodeSlugPathSegment(slug)}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 60 },
});
if (!response.ok) return null;
const json = (await response.json()) as ApiResponse<Post>;
return json.data;
} catch (error) {
console.error('Public post fetch error:', error);
return null;
}
};

View File

@@ -0,0 +1,5 @@
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
export default function AdminCategoriesPage() {
return <AdminCategoryPanel />;
}

View File

@@ -0,0 +1,5 @@
import AdminCommentsPanel from '@/components/admin/AdminCommentsPanel';
export default function AdminCommentsPage() {
return <AdminCommentsPanel />;
}

5
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import AdminRouteShell from '@/components/admin/AdminRouteShell';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <AdminRouteShell>{children}</AdminRouteShell>;
}

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

@@ -0,0 +1,387 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
CalendarClock,
FileText,
Loader2,
PenLine,
Settings,
} from 'lucide-react';
import { getAdminDashboard } from '@/api/dashboard';
import { getCategories } from '@/api/category';
import { getAdminComments } from '@/api/comments';
import { getPosts } from '@/api/posts';
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,
DashboardTrafficPoint,
PageMeta,
Post,
} from '@/types';
const dashboardRangeDays: Record<DashboardRange, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
};
const countCategories = (categories: Category[] = []): number => {
return categories.reduce((count, category) => {
return count + 1 + countCategories(category.children || []);
}, 0);
};
const emptyPageMeta: PageMeta = {
totalPages: 0,
totalElements: 0,
number: 0,
last: true,
};
const formatDate = (value?: string | Date, withTime = false) => {
if (!value) return '-';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
}).format(date);
};
const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
if (!data) return emptyPageMeta;
return {
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
number: data.page?.number ?? data.number ?? 0,
last: data.page?.last ?? data.last,
};
};
const getPostTotal = (data?: { page?: PageMeta; totalElements?: number }) => {
return data?.page?.totalElements ?? data?.totalElements ?? 0;
};
const getAuthorName = (comment: AdminComment) => {
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
};
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,
});
const formatKstDateKey = (date: Date) => {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
};
const getFallbackTraffic = (
posts: Post[],
range: DashboardRange,
): DashboardTrafficPoint[] => {
const days = dashboardRangeDays[range];
const today = new Date();
const points = Array.from({ length: days }, (_, index) => {
const date = new Date(today);
date.setDate(today.getDate() - (days - 1 - index));
return {
date: formatKstDateKey(date),
views: 0,
};
});
const pointMap = new Map(points.map((point) => [point.date, point]));
posts.forEach((post) => {
const createdAt = new Date(post.createdAt);
if (Number.isNaN(createdAt.getTime())) return;
const dateKey = formatKstDateKey(createdAt);
const point = pointMap.get(dateKey);
if (!point) return;
point.views += post.viewCount;
});
return points;
};
function RecentPostRow({ post }: { post: Post }) {
return (
<Link
href={`/posts/${post.slug}`}
className="group flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
>
<div className="min-w-0">
<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>
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" size={15} />
</Link>
);
}
function RecentCommentRow({ comment }: { comment: AdminComment }) {
const content = (
<>
<div className="min-w-0 flex-1">
<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-[var(--color-text-subtle)]" size={15} />
</>
);
if (comment.postSlug) {
return (
<Link
href={`/posts/${comment.postSlug}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
>
{content}
</Link>
);
}
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' }),
retry: 0,
});
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'admin', 'popular'],
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
retry: 0,
});
const { data: categories, isLoading: isCategoriesLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
retry: 0,
});
const { data: commentsData, isLoading: isCommentsLoading } = useQuery({
queryKey: ['comments', 'admin', 'recent'],
queryFn: () => getAdminComments(0, 5),
retry: 0,
});
const latestPosts = latestData?.content ?? [];
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],
);
const fallbackTraffic = useMemo(
() => getFallbackTraffic([...(latestData?.content ?? []), ...(popularData?.content ?? [])], range),
[latestData?.content, popularData?.content, range],
);
const trafficPoints = dashboard?.traffic?.length ? dashboard.traffic : fallbackTraffic;
return (
<div className="space-y-8">
<header className="flex flex-col justify-between gap-5 border-b border-[var(--color-line)] pb-8 md:flex-row md:items-end">
<div>
<StatusBadge tone="info" className="mb-3">
<Settings size={14} />
</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-[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-[var(--color-line)] bg-[var(--color-control)] px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
<FileText size={17} />
</Link>
<Link
href="/admin/posts/new"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-accent)] px-5 py-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
>
<PenLine size={17} />
</Link>
</div>
</header>
<AdminDashboardOverview
overview={dashboard?.overview}
fallbackTotals={{
totalPosts,
totalComments,
totalCategories,
lastPublishedAt: latestPosts[0]?.createdAt,
}}
/>
<AdminDashboardTrafficChart
points={trafficPoints}
range={range}
onRangeChange={setRange}
isFallback={isFallback || !dashboard?.traffic?.length}
/>
<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>
<RecentActivity
posts={recentPosts}
comments={recentComments}
isLoading={isFallbackLoading}
/>
<AdminDashboardCategoryHealth
categoryStats={dashboard?.categoryStats ?? []}
isFallback={isFallback}
/>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import AdminPostEditor from '@/components/admin/AdminPostEditor';
interface EditAdminPostPageProps {
params: Promise<{
slug: string;
}>;
}
export default async function EditAdminPostPage({ params }: EditAdminPostPageProps) {
const { slug } = await params;
return <AdminPostEditor editSlug={slug} />;
}

View File

@@ -0,0 +1,5 @@
import AdminPostEditor from '@/components/admin/AdminPostEditor';
export default function NewAdminPostPage() {
return <AdminPostEditor />;
}

View File

@@ -0,0 +1,5 @@
import AdminPostsPanel from '@/components/admin/AdminPostsPanel';
export default function AdminPostsPage() {
return <AdminPostsPanel />;
}

View File

@@ -0,0 +1,5 @@
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
export default function AdminProfilePage() {
return <AdminProfilePanel />;
}

View File

@@ -1,152 +1,55 @@
'use client'; import type { Metadata } from 'next';
import { Archive } from 'lucide-react';
import { fetchPublicPosts } from '@/api/publicPosts';
import ArchiveExplorer from '@/components/post/ArchiveExplorer';
import WindowSurface from '@/components/ui/WindowSurface';
import { SITE_NAME } from '@/lib/site';
import { PostListResponse } from '@/types';
import { useQuery } from '@tanstack/react-query'; export const dynamic = 'force-dynamic';
import { getPosts } from '@/api/posts'; export const revalidate = 300;
import { Post } from '@/types';
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';
export default function ArchivePage() { export const metadata: Metadata = {
// 1. 전체 게시글 조회 (최대 1000개) title: 'Archive',
const { data, isLoading } = useQuery({ alternates: {
queryKey: ['posts', 'all'], canonical: '/archive',
queryFn: () => getPosts({ page: 0, size: 1000 }), },
staleTime: 1000 * 60 * 5, // 5분 캐시 openGraph: {
}); title: `Archive | ${SITE_NAME}`,
url: '/archive',
siteName: SITE_NAME,
type: 'website',
},
};
// 2. 게시글 그룹화 (연도 -> 월) const getTotalElements = (data?: PostListResponse | null) => {
const archiveGroups = useMemo(() => { return data?.page?.totalElements ?? data?.totalElements ?? 0;
if (!data?.content) return {}; };
const groups: { [year: string]: { [month: string]: Post[] } } = {}; export default async function ArchivePage() {
const data = await fetchPublicPosts({ page: 0, size: 1000, sort: 'createdAt,desc' });
data.content.forEach((post) => { const posts = data?.content || [];
const date = new Date(post.createdAt); const totalPosts = getTotalElements(data);
const year = date.getFullYear().toString();
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // '01', '02' 형식
if (!groups[year]) groups[year] = {};
if (!groups[year][month]) groups[year][month] = [];
groups[year][month].push(post);
});
return groups;
}, [data]);
// 3. 연도 내림차순 정렬
const sortedYears = useMemo(() => {
return Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a));
}, [archiveGroups]);
// 🛠️ 총 게시글 수 수정 (백엔드 PagedModel 대응)
// page 정보가 data 안에 직접 있거나, page 객체 안에 있을 수 있음
const meta = (data as any)?.page || data;
const totalPosts = meta?.totalElements || 0;
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[50vh]">
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}
return ( return (
<div className="max-w-4xl mx-auto py-10 px-4"> <main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
{/* 헤더 섹션 */} <WindowSurface
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8"> title="Finder"
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3"> subtitle={`Archive · ${totalPosts.toLocaleString()} posts`}
<Archive className="text-blue-600" size={32} /> bodyClassName="p-4 md:p-7"
<span>Archives</span> >
<div className="mb-7 min-w-0 border-b border-[var(--color-line)] pb-6 text-center md:text-left">
<h1 className="mb-3 flex min-w-0 items-center justify-center gap-3 text-3xl font-bold tracking-normal text-[var(--color-text)] md:justify-start">
<Archive className="shrink-0 text-[var(--color-accent)]" size={32} />
<span className="min-w-0 break-words"></span>
</h1> </h1>
<p className="text-gray-500"> <p className="break-words text-sm leading-6 text-[var(--color-text-muted)] md:text-base">
<span className="text-blue-600 font-bold">{totalPosts}</span> . <span className="font-bold text-[var(--color-accent)]">{totalPosts.toLocaleString()}</span> .
</p> </p>
</div> </div>
{/* 타임라인 컨텐츠 */} <ArchiveExplorer posts={posts} />
{sortedYears.length > 0 ? ( </WindowSurface>
<div className="space-y-12 relative"> </main>
{/* 타임라인 수직선 (좌측 장식) */}
<div className="absolute left-4 top-4 bottom-4 w-0.5 bg-gray-100 hidden md:block" />
{sortedYears.map((year) => {
const months = archiveGroups[year];
// 월 내림차순 정렬
const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a));
return (
<div key={year} className="relative">
{/* 연도 헤더 */}
<div className="flex items-center gap-4 mb-6">
<div className="hidden md:flex items-center justify-center w-9 h-9 rounded-full bg-blue-50 border-4 border-white shadow-sm z-10">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
</div>
<h2 className="text-2xl font-bold text-gray-800">{year}</h2>
</div>
{/* 월별 그룹 */}
<div className="space-y-8 md:pl-12">
{sortedMonths.map((month) => {
const posts = months[month];
// 게시글 날짜 내림차순 정렬
const sortedPosts = posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<div key={month} className="group">
<h3 className="text-lg font-bold text-gray-600 mb-4 flex items-center gap-2">
<Calendar size={18} className="text-gray-400" />
{month}
<span className="text-xs font-normal bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
{posts.length}
</span>
</h3>
<div className="grid gap-3">
{sortedPosts.map((post) => (
<Link
key={post.id}
href={`/posts/${post.slug}`}
className="block bg-white border border-gray-100 rounded-lg p-4 hover:border-blue-200 hover:shadow-md transition-all duration-200 group/item"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<h4 className="text-base font-medium text-gray-800 truncate group-hover/item:text-blue-600 transition-colors">
{post.title}
</h4>
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-400">
<span className="bg-gray-50 px-1.5 py-0.5 rounded text-gray-500">
{post.categoryName}
</span>
<span></span>
<span className="tabular-nums">
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
</span>
</div>
</div>
<ChevronRight className="text-gray-300 group-hover/item:text-blue-400 transition-colors" size={20} />
</div>
</Link>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-20 bg-gray-50 rounded-xl border border-dashed border-gray-200">
<FileText className="mx-auto text-gray-300 mb-3" size={48} />
<p className="text-gray-500"> .</p>
</div>
)}
</div>
); );
} }

View File

@@ -1,34 +1,58 @@
'use client'; 'use client';
import { use, useState, useEffect } from 'react'; import { use, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용 import { clsx } from 'clsx';
import { ChevronLeft, ChevronRight, FolderOpen, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
import { getPostsByCategory } from '@/api/posts';
import PostCard from '@/components/post/PostCard'; import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem'; import PostListItem from '@/components/post/PostListItem';
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 import PostSearch from '@/components/post/PostSearch';
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react'; import EmptyState from '@/components/ui/EmptyState';
import { clsx } from 'clsx'; import SegmentedControl from '@/components/ui/SegmentedControl';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
const PAGE_SIZE_OPTIONS = [
{ label: '9개', value: '9' },
{ label: '18개', value: '18' },
{ label: '27개', value: '27' },
] as const;
const DEFAULT_PAGE_SIZE = '9';
const CATEGORY_PAGE_SIZE_STORAGE_KEY = 'categoryPageSize';
type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number]['value'];
const isPageSizeOption = (value: string | null): value is PageSizeOption => {
return PAGE_SIZE_OPTIONS.some((option) => option.value === value);
};
const isNoticeCategoryName = (categoryName: string) => {
const normalizedName = categoryName.toLowerCase();
return categoryName === '공지' || normalizedName === 'notice' || normalizedName === 'announcement';
};
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) { export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params); const { id } = use(params);
const categoryName = decodeURIComponent(id); const categoryName = decodeURIComponent(id);
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName; const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
const isNoticeCategory = isNoticeCategoryName(apiCategoryName);
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태 const [keyword, setKeyword] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [pageSize, setPageSize] = useState<PageSizeOption>(() => {
const PAGE_SIZE = 10; if (typeof window === 'undefined') return DEFAULT_PAGE_SIZE;
useEffect(() => { const savedSize = localStorage.getItem(CATEGORY_PAGE_SIZE_STORAGE_KEY);
if (isNoticeCategory) { return isPageSizeOption(savedSize) ? savedSize : DEFAULT_PAGE_SIZE;
setViewMode('list'); });
return; const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
} if (isNoticeCategory || typeof window === 'undefined') return isNoticeCategory ? 'list' : 'grid';
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
if (savedMode) setViewMode(savedMode); const savedMode = localStorage.getItem('postViewMode');
}, [isNoticeCategory]); return savedMode === 'list' ? 'list' : 'grid';
});
const activeViewMode = isNoticeCategory ? 'list' : viewMode;
const handleViewModeChange = (mode: 'grid' | 'list') => { const handleViewModeChange = (mode: 'grid' | 'list') => {
setViewMode(mode); setViewMode(mode);
@@ -37,95 +61,113 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
} }
}; };
// 🆕 검색어가 변경되면 페이지를 0으로 초기화
const handleSearch = (newKeyword: string) => { const handleSearch = (newKeyword: string) => {
setKeyword(newKeyword); setKeyword(newKeyword);
setPage(0); setPage(0);
}; };
const handlePageSizeChange = (nextSize: PageSizeOption) => {
setPage(0);
setPageSize(nextSize);
localStorage.setItem(CATEGORY_PAGE_SIZE_STORAGE_KEY, nextSize);
};
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({ const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
queryKey: ['posts', 'category', apiCategoryName, page, keyword], // 🔑 쿼리 키에 keyword 추가 queryKey: ['posts', 'category', apiCategoryName, page, pageSize, keyword],
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword), // 🆕 검색어 전달 queryFn: () => getPostsByCategory(apiCategoryName, page, Number(pageSize), keyword),
// 🛠️ 수정됨: 검색어가 바뀌면 이전 데이터를 보여주지 않고 로딩 상태로 전환 placeholderData: (previousData) => previousData,
// (페이지 이동 시에는 부드럽게 보여주기 위해 유지)
placeholderData: (previousData, previousQuery) => {
const prevKeyword = previousQuery?.queryKey[4];
if (prevKeyword !== keyword) return undefined;
return previousData;
},
}); });
if (isLoading || (postsData === undefined && !error)) { if (isLoading || (postsData === undefined && !error)) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex h-64 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={40} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="text-center py-10 text-red-500"> <WindowSurface className="mx-auto md:w-[70vw] md:max-w-[900px]" bodyClassName="px-5 py-10 text-center text-red-500">
.<br/> .
<span className="text-sm text-gray-400"> .</span> <br />
</div> <span className="text-sm text-[var(--color-text-subtle)]"> .</span>
</WindowSurface>
); );
} }
const posts = postsData?.content || []; const posts = postsData?.content || [];
const pagingData = postsData?.page || postsData;
// 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음)
// postsData가 any로 캐스팅되어 안전하게 접근
const pagingData = (postsData as any)?.page || postsData;
const totalElements = pagingData?.totalElements ?? 0; const totalElements = pagingData?.totalElements ?? 0;
const totalPages = pagingData?.totalPages ?? 0; const totalPages = pagingData?.totalPages ?? 0;
// page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용
const isLast = pagingData?.number !== undefined const isLast = pagingData?.number !== undefined
? (pagingData.number + 1 >= pagingData.totalPages) ? pagingData.number + 1 >= pagingData.totalPages
: (postsData?.last ?? true); : (postsData?.last ?? true);
const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0)); const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
const handleNextPage = () => { const handleNextPage = () => {
if (!isLast) { if (!isLast) setPage((old) => old + 1);
setPage((old) => old + 1);
}
}; };
return ( return (
<div className="max-w-4xl mx-auto px-4 py-8"> <main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
{/* 헤더 영역 */} <WindowSurface
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4"> title="Finder"
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2 shrink-0"> subtitle={apiCategoryName}
{apiCategoryName} <span className="text-gray-400 text-lg font-normal"> </span> bodyClassName="p-5 md:p-6"
>
<div className="mb-8 flex flex-col justify-between gap-4 border-b border-[var(--color-line)] pb-5 md:flex-row md:items-center">
<h1 className="flex min-w-0 items-center gap-3 text-2xl font-bold tracking-normal text-[var(--color-text)]">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<FolderOpen size={20} />
</span>
<span className="min-w-0 truncate">{apiCategoryName}</span>
<span className="text-lg font-normal text-[var(--color-text-subtle)]"> </span>
</h1> </h1>
<div className="flex items-center gap-3 w-full md:w-auto"> <div className="flex w-full flex-col gap-3 md:w-auto md:flex-row md:items-center">
{/* 🔍 카테고리 내 검색바 */}
<PostSearch <PostSearch
onSearch={handleSearch} onSearch={handleSearch}
placeholder={`'${apiCategoryName}' 내 검색`} placeholder={`${apiCategoryName} 검색`}
className="w-full md:w-64" className="w-full md:w-64"
/> />
{/* 뷰 모드 버튼 */} <SegmentedControl
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg shrink-0"> ariaLabel="페이지당 게시글 수"
options={PAGE_SIZE_OPTIONS}
value={pageSize}
onChange={handlePageSizeChange}
className="justify-center"
/>
<div className="flex shrink-0 items-center gap-1 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-[18px]">
<button <button
type="button"
onClick={() => handleViewModeChange('grid')} onClick={() => handleViewModeChange('grid')}
className={clsx( className={clsx(
"p-2 rounded-md transition-all duration-200", 'rounded-full p-2 transition-all duration-150',
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600" activeViewMode === 'grid'
? 'bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)} )}
title="카드 보기" title="카드 보기"
aria-label="카드로 보기"
aria-pressed={activeViewMode === 'grid'}
> >
<LayoutGrid size={18} /> <LayoutGrid size={18} />
</button> </button>
<button <button
type="button"
onClick={() => handleViewModeChange('list')} onClick={() => handleViewModeChange('list')}
className={clsx( className={clsx(
"p-2 rounded-md transition-all duration-200", 'rounded-full p-2 transition-all duration-150',
viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600" activeViewMode === 'list'
? 'bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)} )}
title="리스트 보기" title="리스트 보기"
aria-label="리스트로 보기"
aria-pressed={activeViewMode === 'list'}
> >
<List size={18} /> <List size={18} />
</button> </button>
@@ -133,56 +175,56 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
</div> </div>
</div> </div>
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
{keyword && ( {keyword && (
<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"> <div className="mb-6 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
<SearchIcon size={16} className="text-blue-500" /> <SearchIcon size={16} className="text-[var(--color-accent)]" />
<span> <span>
"{keyword}" : <strong>{totalElements}</strong> <strong>{keyword}</strong> : <strong>{totalElements}</strong>
</span> </span>
</div> </div>
)} )}
{posts.length === 0 ? ( {posts.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100"> <EmptyState
<p className="text-gray-400 mb-2"> title={keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
{keyword ? `"${keyword}"에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'} className="py-20"
</p> />
</div>
) : ( ) : (
<> <>
{viewMode === 'grid' ? ( {activeViewMode === 'grid' ? (
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{posts.map((post) => ( {posts.map((post) => (
<PostCard key={post.id} post={post} /> <PostCard key={post.id} post={post} />
))} ))}
</div> </div>
) : ( ) : (
<div className="flex flex-col border-t border-gray-100"> <Surface className="flex flex-col p-2 shadow-none">
{posts.map((post) => ( {posts.map((post) => (
<PostListItem key={post.id} post={post} /> <PostListItem key={post.id} post={post} />
))} ))}
</div> </Surface>
)} )}
<div className="flex justify-center items-center gap-6 mt-12 mb-8"> <div className="mb-2 mt-10 flex items-center justify-center gap-6">
<button <button
type="button"
onClick={handlePrevPage} onClick={handlePrevPage}
disabled={page === 0} disabled={page === 0}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed" className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--card-bg)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
aria-label="이전 페이지" aria-label="이전 페이지"
> >
<ChevronLeft size={24} /> <ChevronLeft size={24} />
</button> </button>
<span className="text-sm font-medium text-gray-600"> <span className="text-sm font-medium text-[var(--color-text-muted)]">
Page <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`} <span className="font-bold text-[var(--color-text)]">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
</span> </span>
<button <button
type="button"
onClick={handleNextPage} onClick={handleNextPage}
disabled={isLast || isPlaceholderData} disabled={isLast || isPlaceholderData}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed" className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--card-bg)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
aria-label="다음 페이지" aria-label="다음 페이지"
> >
<ChevronRight size={24} /> <ChevronRight size={24} />
@@ -190,6 +232,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
</div> </div>
</> </>
)} )}
</div> </WindowSurface>
</main>
); );
} }

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import ChessHistoryClient from '@/components/chess/ChessHistoryClient';
export const metadata: Metadata = {
title: '대국 기록 | WYPark Blog',
description: 'Maia 체스 대국 기록과 결과를 확인합니다.',
};
export default function ChessHistoryPage() {
return <ChessHistoryClient />;
}

11
src/app/chess/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import ChessHomeClient from '@/components/chess/ChessHomeClient';
export const metadata: Metadata = {
title: 'Maia 체스 | WYPark Blog',
description: 'Maia3 봇과 체스를 두고 대국 기록과 PGN을 확인합니다.',
};
export default function ChessPage() {
return <ChessHomeClient />;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import ChessGamePlayClient from '@/components/chess/ChessGamePlayClient';
export const metadata: Metadata = {
title: 'Maia 대국 | WYPark Blog',
description: 'Maia 체스 대국을 이어서 진행하고 PGN을 확인합니다.',
};
type ChessGamePageProps = {
params: Promise<{
gameId: string;
}>;
};
export default async function ChessGamePage({ params }: ChessGamePageProps) {
const { gameId } = await params;
return <ChessGamePlayClient gameId={gameId} />;
}

Binary file not shown.

View File

@@ -1,37 +1,598 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
:root { :root {
--background: #ffffff; --font-apple-sans:
--foreground: #171717; var(--font-pretendard),
"Pretendard Variable",
Pretendard,
"Apple SD Gothic Neo",
-apple-system,
BlinkMacSystemFont,
"SF Pro Display",
"SF Pro Text",
"Helvetica Neue",
"Noto Sans KR",
system-ui,
sans-serif;
--font-apple-mono:
"SF Mono",
ui-monospace,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
--color-page: #edf4ff;
--desktop-bg:
linear-gradient(118deg, rgba(95, 174, 255, 0.22) 0%, rgba(95, 174, 255, 0) 35%),
linear-gradient(152deg, rgba(255, 158, 194, 0.2) 10%, rgba(255, 158, 194, 0) 48%),
linear-gradient(46deg, rgba(124, 220, 185, 0.18) 46%, rgba(124, 220, 185, 0) 82%),
linear-gradient(180deg, #f9fcff 0%, #eaf3ff 48%, #f8f2ff 100%);
--desktop-sheen:
linear-gradient(120deg, rgba(255, 255, 255, 0.48) 0 1px, transparent 1px 100%),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.12) 0 1px, transparent 1px 18px);
--desktop-icon-bg: rgba(255, 255, 255, 0.44);
--desktop-icon-bg-hover: rgba(255, 255, 255, 0.64);
--desktop-icon-border: rgba(255, 255, 255, 0.62);
--window-bg: rgba(255, 255, 255, 0.66);
--window-bg-strong: rgba(255, 255, 255, 0.86);
--window-titlebar: rgba(255, 255, 255, 0.58);
--window-border: rgba(255, 255, 255, 0.74);
--window-titlebar-border: rgba(39, 54, 82, 0.1);
--card-bg: rgba(255, 255, 255, 0.46);
--card-bg-strong: rgba(255, 255, 255, 0.68);
--card-border: rgba(39, 54, 82, 0.11);
--card-border-hover: rgba(39, 54, 82, 0.18);
--control-border: rgba(39, 54, 82, 0.12);
--menubar-bg: rgba(255, 255, 255, 0.58);
--menubar-border: rgba(255, 255, 255, 0.72);
--dock-bg: rgba(248, 250, 252, 0.54);
--dock-border: rgba(255, 255, 255, 0.68);
--sidebar-bg: rgba(255, 255, 255, 0.58);
--sidebar-border: rgba(255, 255, 255, 0.68);
--color-surface: var(--card-bg);
--color-surface-strong: var(--card-bg-strong);
--color-control: rgba(255, 255, 255, 0.56);
--color-line: rgba(39, 54, 82, 0.13);
--color-text: #1d1d1f;
--color-text-muted: #62646d;
--color-text-subtle: #858892;
--color-accent: #0066cc;
--color-accent-hover: #0071e3;
--color-accent-soft: rgba(0, 102, 204, 0.1);
--color-danger-soft: rgba(255, 59, 48, 0.1);
--shadow-window: 0 34px 96px rgba(47, 74, 112, 0.22), 0 16px 34px rgba(47, 74, 112, 0.1), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
--shadow-sidebar: 18px 20px 64px rgba(47, 74, 112, 0.16), 0 1px 0 rgba(255, 255, 255, 0.56) inset;
--shadow-menubar: 0 12px 32px rgba(47, 74, 112, 0.13), 0 1px 0 rgba(255, 255, 255, 0.62) inset;
--shadow-panel: var(--shadow-sidebar);
--shadow-card: 0 14px 34px rgba(70, 92, 130, 0.09), 0 1px 0 rgba(255, 255, 255, 0.5) inset;
--shadow-dock: 0 28px 78px rgba(15, 23, 42, 0.18), 0 12px 32px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.68) inset, 0 -1px 0 rgba(15, 23, 42, 0.06) inset;
--shadow-control: 0 12px 26px rgba(70, 92, 130, 0.1), 0 1px 0 rgba(255, 255, 255, 0.46) inset;
--focus-ring: 0 0 0 4px rgba(0, 102, 204, 0.14);
--background: var(--color-page);
--foreground: var(--color-text);
color-scheme: light;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-apple-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-apple-mono);
} }
@media (prefers-color-scheme: dark) { html[data-theme="dark"] {
:root { --color-page: #0c101a;
--background: #0a0a0a; --desktop-bg:
--foreground: #ededed; linear-gradient(118deg, rgba(44, 128, 204, 0.34) 0%, rgba(44, 128, 204, 0) 38%),
linear-gradient(150deg, rgba(125, 66, 139, 0.3) 12%, rgba(125, 66, 139, 0) 55%),
linear-gradient(46deg, rgba(30, 118, 89, 0.26) 46%, rgba(30, 118, 89, 0) 84%),
linear-gradient(180deg, #111827 0%, #080b12 100%);
--desktop-sheen:
linear-gradient(120deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 100%),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 18px);
--desktop-icon-bg: rgba(22, 25, 34, 0.5);
--desktop-icon-bg-hover: rgba(40, 44, 58, 0.72);
--desktop-icon-border: rgba(255, 255, 255, 0.16);
--window-bg: rgba(25, 29, 40, 0.72);
--window-bg-strong: rgba(39, 43, 58, 0.88);
--window-titlebar: rgba(255, 255, 255, 0.09);
--window-border: rgba(255, 255, 255, 0.18);
--window-titlebar-border: rgba(255, 255, 255, 0.13);
--card-bg: rgba(43, 46, 60, 0.5);
--card-bg-strong: rgba(52, 55, 70, 0.66);
--card-border: rgba(255, 255, 255, 0.14);
--card-border-hover: rgba(255, 255, 255, 0.22);
--control-border: rgba(255, 255, 255, 0.13);
--menubar-bg: rgba(14, 17, 26, 0.68);
--menubar-border: rgba(255, 255, 255, 0.16);
--dock-bg: rgba(18, 20, 28, 0.56);
--dock-border: rgba(255, 255, 255, 0.16);
--sidebar-bg: rgba(21, 24, 34, 0.64);
--sidebar-border: rgba(255, 255, 255, 0.14);
--color-surface: var(--card-bg);
--color-surface-strong: var(--card-bg-strong);
--color-control: rgba(255, 255, 255, 0.085);
--color-line: rgba(255, 255, 255, 0.13);
--color-text: #f5f5f7;
--color-text-muted: #b2b5bf;
--color-text-subtle: #8f939f;
--color-accent: #2997ff;
--color-accent-hover: #46a6ff;
--color-accent-soft: rgba(41, 151, 255, 0.16);
--color-danger-soft: rgba(255, 69, 58, 0.16);
--shadow-window: 0 32px 100px rgba(0, 0, 0, 0.54), 0 14px 36px rgba(0, 0, 0, 0.28), 0 1px 0 rgba(255, 255, 255, 0.1) inset;
--shadow-sidebar: 18px 0 46px rgba(0, 0, 0, 0.38), -1px 0 0 rgba(255, 255, 255, 0.08) inset;
--shadow-menubar: 0 18px 46px rgba(0, 0, 0, 0.32), 0 1px 0 rgba(255, 255, 255, 0.09) inset;
--shadow-panel: var(--shadow-sidebar);
--shadow-card: 0 16px 34px rgba(0, 0, 0, 0.32), 0 1px 0 rgba(255, 255, 255, 0.08) inset;
--shadow-dock: 0 30px 86px rgba(0, 0, 0, 0.48), 0 12px 34px rgba(0, 0, 0, 0.34), 0 1px 0 rgba(255, 255, 255, 0.14) inset, 0 -1px 0 rgba(0, 0, 0, 0.36) inset;
--shadow-control: 0 14px 30px rgba(0, 0, 0, 0.28), 0 1px 0 rgba(255, 255, 255, 0.08) inset;
--focus-ring: 0 0 0 4px rgba(41, 151, 255, 0.18);
--background: var(--color-page);
--foreground: var(--color-text);
color-scheme: dark;
} }
html {
overflow-x: hidden;
} }
body { body {
background: var(--background); min-height: 100vh;
overflow-x: clip;
background: var(--desktop-bg);
background-attachment: fixed;
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-apple-sans);
font-feature-settings: "kern", "ss05";
font-size: 16px;
font-weight: 450;
letter-spacing: 0;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.desktop-shell {
position: relative;
isolation: isolate;
min-height: 100vh;
background: var(--desktop-bg);
}
.desktop-shell::before,
.desktop-shell::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
}
.desktop-shell::before {
z-index: 0;
background:
var(--desktop-sheen),
radial-gradient(ellipse at 20% 15%, rgba(255, 255, 255, 0.42), transparent 42%),
var(--desktop-bg);
}
.desktop-shell::after {
z-index: 0;
background-image:
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.06) 0 1px, transparent 1px 3px),
linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.18));
opacity: 0.32;
mix-blend-mode: soft-light;
}
html[data-theme="dark"] .desktop-shell::after {
opacity: 0.22;
}
.window-surface {
position: relative;
}
.window-surface::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 22rem),
linear-gradient(90deg, rgba(255, 255, 255, 0.18), transparent 28%);
opacity: 0.52;
}
html[data-theme="dark"] .window-surface::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 22rem),
linear-gradient(90deg, rgba(255, 255, 255, 0.05), transparent 28%);
opacity: 0.72;
}
.window-surface > * {
position: relative;
}
pre,
code,
table {
max-width: 100%;
}
::selection {
background: var(--color-accent-soft);
color: var(--color-text);
}
code,
kbd,
pre,
samp,
.tabular-nums {
font-family: var(--font-apple-mono);
}
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.bg-white {
background-color: var(--window-bg-strong);
}
.bg-gray-50,
.bg-gray-100 {
background-color: rgba(255, 255, 255, 0.38);
}
.bg-blue-50,
.bg-blue-100 {
background-color: var(--color-accent-soft);
}
.bg-red-50,
.bg-red-100 {
background-color: var(--color-danger-soft);
}
.bg-green-100 {
background-color: rgba(52, 199, 89, 0.14);
}
.bg-blue-600 {
background-color: var(--color-accent);
}
.hover\:bg-blue-700:hover {
background-color: var(--color-accent-hover);
}
.border-gray-100,
.border-gray-200,
.border-gray-300 {
border-color: var(--color-line);
}
.border-blue-100 {
border-color: rgba(0, 102, 204, 0.22);
}
.border-red-100 {
border-color: rgba(255, 59, 48, 0.22);
}
.text-gray-950,
.text-gray-900,
.text-gray-800,
.text-gray-700 {
color: var(--color-text);
}
.text-gray-600,
.text-gray-500 {
color: var(--color-text-muted);
}
.text-gray-400,
.text-gray-300 {
color: var(--color-text-subtle);
}
.text-blue-700,
.text-blue-600 {
color: var(--color-accent);
}
.text-red-950,
.text-red-700,
.text-red-600 {
color: #d9362e;
}
.text-green-600 {
color: #22863a;
}
.divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
border-color: var(--color-line);
}
.hover\:bg-white:hover,
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-gray-200:hover,
.focus\:bg-white:focus {
background-color: var(--card-bg-strong);
}
.hover\:bg-blue-50:hover {
background-color: rgba(0, 102, 204, 0.12);
}
.hover\:bg-red-50:hover {
background-color: rgba(255, 59, 48, 0.12);
}
.hover\:text-gray-600:hover,
.hover\:text-gray-700:hover,
.hover\:text-gray-800:hover {
color: var(--color-text);
}
.placeholder\:text-gray-300::placeholder,
.placeholder\:text-gray-400::placeholder {
color: var(--color-text-subtle);
}
.disabled\:bg-gray-50:disabled,
.disabled\:bg-gray-300:disabled,
.disabled\:bg-gray-400:disabled {
background-color: rgba(255, 255, 255, 0.32);
color: var(--color-text-subtle);
}
.shadow-xl,
.shadow-md,
.shadow-sm {
box-shadow: var(--shadow-card);
}
.rounded-xl {
border-radius: 0.5rem;
}
input:not([type="checkbox"]):not([type="radio"]),
textarea,
select {
background-color: var(--color-control);
border-color: var(--control-border);
color: var(--color-text);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.42) inset;
backdrop-filter: blur(18px);
}
input:not([type="checkbox"]):not([type="radio"]):focus,
textarea:focus,
select:focus {
background-color: var(--card-bg-strong);
border-color: var(--color-accent);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-subtle);
}
option {
background-color: var(--color-surface-strong);
color: var(--color-text);
}
html[data-theme="dark"] .bg-gray-50,
html[data-theme="dark"] .bg-gray-100 {
background-color: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .bg-blue-50,
html[data-theme="dark"] .bg-blue-100 {
background-color: rgba(10, 132, 255, 0.14);
}
html[data-theme="dark"] .bg-red-50,
html[data-theme="dark"] .bg-red-100 {
background-color: rgba(255, 69, 58, 0.13);
}
html[data-theme="dark"] .border-blue-100 {
border-color: rgba(10, 132, 255, 0.28);
}
html[data-theme="dark"] .border-red-100 {
border-color: rgba(255, 105, 97, 0.28);
}
html[data-theme="dark"] .text-blue-700,
html[data-theme="dark"] .text-blue-600 {
color: #8ecbff;
}
html[data-theme="dark"] .text-red-950,
html[data-theme="dark"] .text-red-700,
html[data-theme="dark"] .text-red-600 {
color: #ffb4ad;
}
html[data-theme="dark"] .text-green-600 {
color: #8ff0a4;
}
html[data-theme="dark"] .hover\:bg-white:hover,
html[data-theme="dark"] .hover\:bg-gray-50:hover,
html[data-theme="dark"] .hover\:bg-gray-100:hover,
html[data-theme="dark"] .hover\:bg-gray-200:hover,
html[data-theme="dark"] .focus\:bg-white:focus {
background-color: var(--card-bg-strong);
}
html[data-theme="dark"] .hover\:bg-blue-50:hover {
background-color: rgba(10, 132, 255, 0.16);
}
html[data-theme="dark"] .hover\:bg-red-50:hover {
background-color: rgba(255, 69, 58, 0.16);
}
html[data-theme="dark"] .disabled\:bg-gray-50:disabled,
html[data-theme="dark"] .disabled\:bg-gray-300:disabled,
html[data-theme="dark"] .disabled\:bg-gray-400:disabled {
background-color: rgba(255, 255, 255, 0.1);
color: var(--color-text-subtle);
}
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]),
html[data-theme="dark"] textarea,
html[data-theme="dark"] select {
background-color: var(--color-control);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset;
}
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):focus,
html[data-theme="dark"] textarea:focus,
html[data-theme="dark"] select:focus {
background-color: var(--card-bg-strong);
} }
.w-md-editor { .w-md-editor {
border-radius: 0.75rem !important; /* 둥근 모서리 */ border-radius: 0.5rem !important;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); border-color: var(--card-border) !important;
border-color: #e5e7eb !important; background-color: var(--card-bg-strong) !important;
box-shadow: var(--shadow-card);
backdrop-filter: blur(20px);
} }
.w-md-editor-toolbar { .w-md-editor-toolbar {
border-radius: 0.75rem 0.75rem 0 0 !important; border-radius: 0.5rem 0.5rem 0 0 !important;
background-color: #f9fafb !important; border-color: var(--window-titlebar-border) !important;
background-color: var(--window-titlebar) !important;
}
html[data-theme="dark"] .w-md-editor {
background-color: var(--card-bg-strong) !important;
border-color: var(--card-border) !important;
}
html[data-theme="dark"] .w-md-editor-toolbar {
background-color: var(--window-titlebar) !important;
border-color: var(--window-titlebar-border) !important;
}
.wy-editor-shell {
--color-canvas-default: transparent;
--color-canvas-subtle: transparent;
--color-border-default: var(--card-border);
--color-border-muted: var(--card-border);
--color-fg-default: var(--color-text);
--color-fg-muted: var(--color-text-muted);
--color-accent-fg: var(--color-accent);
color: var(--color-text);
}
.wy-editor-shell .w-md-editor {
min-height: 520px;
overflow: hidden;
border-radius: 0.5rem !important;
border: 1px solid var(--card-border) !important;
background-color: var(--card-bg-strong) !important;
color: var(--color-text) !important;
box-shadow: var(--shadow-card) !important;
backdrop-filter: blur(20px);
}
.wy-editor-shell .w-md-editor-toolbar {
border-color: var(--window-titlebar-border) !important;
background-color: var(--window-titlebar) !important;
}
.wy-editor-shell .w-md-editor-toolbar button,
.wy-editor-shell .w-md-editor-toolbar li,
.wy-editor-shell .w-md-editor-toolbar svg {
color: var(--color-text-muted) !important;
}
.wy-editor-shell .w-md-editor-toolbar button:hover {
background-color: var(--color-control) !important;
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-content,
.wy-editor-shell .w-md-editor-area,
.wy-editor-shell .w-md-editor-text,
.wy-editor-shell .w-md-editor-text-pre,
.wy-editor-shell .w-md-editor-text-input,
.wy-editor-shell .w-md-editor-preview,
.wy-editor-shell .wmde-markdown {
background: transparent !important;
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-text {
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-text-pre,
.wy-editor-shell .w-md-editor-text-pre *,
.wy-editor-shell .w-md-editor-text-pre > code,
.wy-editor-shell .w-md-editor-text-pre code,
.wy-editor-shell .w-md-editor-text-pre .token,
.wy-editor-shell .w-md-editor-text-pre .punctuation {
color: transparent !important;
-webkit-text-fill-color: transparent !important;
}
.wy-editor-shell .w-md-editor-text-input {
opacity: 1 !important;
background: transparent !important;
color: var(--color-text) !important;
-webkit-text-fill-color: var(--color-text) !important;
caret-color: var(--color-accent);
text-shadow: none !important;
}
.wy-editor-shell .w-md-editor-text-input::placeholder {
color: var(--color-text-subtle) !important;
-webkit-text-fill-color: var(--color-text-subtle) !important;
}
.wy-editor-shell .wmde-markdown,
.wy-editor-shell .wmde-markdown p,
.wy-editor-shell .wmde-markdown li,
.wy-editor-shell .wmde-markdown h1,
.wy-editor-shell .wmde-markdown h2,
.wy-editor-shell .wmde-markdown h3,
.wy-editor-shell .wmde-markdown h4,
.wy-editor-shell .wmde-markdown h5,
.wy-editor-shell .wmde-markdown h6 {
color: var(--color-text) !important;
}
.wy-editor-shell .wmde-markdown a {
color: var(--color-accent) !important;
}
.wy-editor-shell .wmde-markdown blockquote {
color: var(--color-text-muted) !important;
border-left-color: var(--color-line) !important;
}
.wy-editor-shell .wmde-markdown pre,
.wy-editor-shell .wmde-markdown code {
background-color: rgba(30, 30, 30, 0.9) !important;
color: #f4f4f5 !important;
} }

View File

@@ -1,13 +1,37 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import localFont from 'next/font/local';
import Script from 'next/script';
import './globals.css'; import './globals.css';
import Providers from './providers'; import Providers from './providers';
import Sidebar from '@/components/layout/Sidebar'; import DesktopShell from '@/components/layout/DesktopShell';
import TopHeader from '@/components/layout/TopHeader'; import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
import Script from 'next/script'; import { DEFAULT_DESCRIPTION, SITE_NAME, SITE_URL } from '@/lib/site';
const pretendard = localFont({
src: './fonts/PretendardVariable.woff2',
variable: '--font-pretendard',
weight: '45 920',
display: 'swap',
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'WYPark Blog', metadataBase: new URL(SITE_URL),
description: '개발 블로그', title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`,
},
description: DEFAULT_DESCRIPTION,
alternates: {
canonical: '/',
},
openGraph: {
title: SITE_NAME,
description: DEFAULT_DESCRIPTION,
url: '/',
siteName: SITE_NAME,
type: 'website',
locale: 'ko_KR',
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -16,12 +40,15 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="ko"> <html lang="ko" data-theme="light" data-theme-mode="system" suppressHydrationWarning>
<head> <head>
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" /> <meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
</head> </head>
<body className="bg-[#f8f9fa] text-gray-800"> <body className={`${pretendard.variable} ${pretendard.className} text-[var(--color-text)] antialiased`}>
{/* 🌟 Google Analytics 스크립트 */} <Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<Script <Script
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK" src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
strategy="afterInteractive" strategy="afterInteractive"
@@ -37,23 +64,10 @@ export default function RootLayout({
</Script> </Script>
<Providers> <Providers>
<div className="min-h-screen flex"> <DesktopShell>{children}</DesktopShell>
{/* 사이드바 */}
<Sidebar />
{/* 메인 영역 */}
<main className="flex-1 transition-all duration-300 md:ml-72 w-full relative">
<TopHeader />
{/* 🛠️ 수정됨: max-w-4xl 제한을 제거하고 w-full로 변경 */}
{/* 이제 각 페이지(page.tsx)에서 원하는 너비를 설정할 수 있습니다. */}
<div className="w-full mx-auto p-4 md:p-8 pt-20">
{children}
</div>
</main>
</div>
</Providers> </Providers>
</body> </body>
</html> </html>
); );
} }

View File

@@ -1,96 +1,137 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthStore } from '@/store/authStore'; import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2, LockKeyhole, UserRound } from 'lucide-react';
import { login } from '@/api/auth'; import { login } from '@/api/auth';
import { Loader2 } from 'lucide-react'; import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore';
export default function LoginPage() { const getSafeRedirectPath = (value: string | null) => {
if (!value) return '/';
if (!value.startsWith('/') || value.startsWith('//')) return '/';
if (value.startsWith('/login')) return '/';
if (value.includes('://')) return '/';
return value;
};
const getErrorMessage = (error: unknown) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return response.data.message;
}
if (error instanceof Error) return error.message;
return '로그인 중 오류가 발생했습니다.';
};
function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { login: setLoginState } = useAuthStore(); const { login: setLoginState } = useAuthStore();
const redirectPath = getSafeRedirectPath(searchParams.get('redirect'));
const [formData, setFormData] = useState({ email: '', password: '' }); const [formData, setFormData] = useState({ email: '', password: '' });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
e.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res = await login(formData); const response = await login(formData);
if (res.code === 'SUCCESS' && res.data) { if (response.code === 'SUCCESS' && response.data) {
// ✨ 수정됨: AccessToken과 RefreshToken 모두 저장 setLoginState(response.data.accessToken, response.data.refreshToken);
setLoginState(res.data.accessToken, res.data.refreshToken); router.push(redirectPath);
// 로그인 성공 후 메인으로 이동
router.push('/');
} else { } else {
setError(res.message || '로그인에 실패했습니다.'); setError(response.message || '로그인에 실패했습니다.');
} }
} catch (err: any) { } catch (loginError) {
setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.'); setError(getErrorMessage(loginError));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
// 🎨 배경색 수정: bg-gray-50 -> bg-white <div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-white"> <WindowSurface
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8 border border-gray-100"> title="Login Window"
<div className="text-center mb-8"> subtitle="WYPark OS"
<h1 className="text-3xl font-bold text-gray-900"></h1> className="w-full max-w-md"
<p className="text-gray-500 mt-2"> .</p> bodyClassName="p-8"
>
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-2xl border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-card)]">
<UserRound size={34} strokeWidth={1.9} />
</div>
<h1 className="text-3xl font-bold text-[var(--color-text)]"></h1>
<p className="mt-2 text-[var(--color-text-muted)]"> .</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(event) => setFormData({ ...formData, email: event.target.value })}
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
placeholder="example@email.com" placeholder="example@email.com"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(event) => setFormData({ ...formData, password: event.target.value })}
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
placeholder="••••••••" placeholder="••••••••"
required required
/> />
</div> </div>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} {error && <p className="text-center text-sm text-red-500">{error}</p>}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-blue-400 flex justify-center items-center gap-2" className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
> >
{loading && <Loader2 className="animate-spin" size={20} />} {loading ? <Loader2 className="animate-spin" size={20} /> : <LockKeyhole size={18} />}
</button> </button>
</form> </form>
<div className="mt-6 text-center text-sm text-gray-500"> <div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
?{' '} ?{' '}
<Link href="/signup" className="text-blue-600 font-medium hover:underline"> <Link href="/signup" className="font-medium text-[var(--color-accent)] hover:underline">
</Link> </Link>
</div> </div>
</div> </WindowSurface>
</div> </div>
); );
} }
export default function LoginPage() {
return (
<Suspense
fallback={
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div>
}
>
<LoginForm />
</Suspense>
);
}

View File

@@ -1,184 +1,351 @@
'use client'; import type { Metadata } from 'next';
import type { ReactNode } from 'react';
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 Link from 'next/link'; import Link from 'next/link';
import {
Archive,
ChevronRight,
Clock,
FileText,
HardDrive,
Search,
TrendingUp,
} from 'lucide-react';
import { fetchPublicPosts } from '@/api/publicPosts';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
import { DEFAULT_DESCRIPTION, SITE_NAME } from '@/lib/site';
import { Post, PostListResponse } from '@/types';
function HomeContent() { export const dynamic = 'force-dynamic';
const router = useRouter(); export const revalidate = 300;
const searchParams = useSearchParams();
// URL 쿼리 스트링에서 검색어 가져오기 export const metadata: Metadata = {
const keyword = searchParams.get('keyword') || ''; title: {
absolute: SITE_NAME,
},
description: DEFAULT_DESCRIPTION,
alternates: {
canonical: '/',
},
openGraph: {
title: SITE_NAME,
description: DEFAULT_DESCRIPTION,
url: '/',
siteName: SITE_NAME,
type: 'website',
},
};
// 1. 공지사항 조회 type HomePageProps = {
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({ searchParams?: Promise<Record<string, string | string[] | undefined>>;
queryKey: ['posts', 'notices'], };
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
});
// 2. 최신 게시글 조회 const isNoticePost = (post: Post) => {
const { data: latestData, isLoading: isLatestLoading } = useQuery({ const categoryName = post.categoryName || '';
queryKey: ['posts', 'latest'], const normalizedName = categoryName.toLowerCase();
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
});
// 3. 인기 게시글 조회 return categoryName === '공지' || normalizedName === 'notice' || normalizedName === 'announcement';
const { data: popularData, isLoading: isPopularLoading } = useQuery({ };
queryKey: ['posts', 'popular'],
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
});
// 4. 검색 결과 조회 const formatDate = (value?: string) => {
const { data: searchData, isLoading: isSearchLoading } = useQuery({ if (!value) return '';
queryKey: ['posts', 'search', keyword],
queryFn: () => getPosts({ keyword, size: 20 }), const date = new Date(value);
enabled: !!keyword, 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 getTotalElements = (data?: PostListResponse | null) => {
return data?.page?.totalElements ?? data?.totalElements ?? 0;
};
const getSearchKeyword = async (searchParams?: HomePageProps['searchParams']) => {
const resolvedSearchParams = searchParams ? await searchParams : {};
const keyword = resolvedSearchParams.keyword;
return Array.isArray(keyword) ? keyword[0] || '' : keyword || '';
};
function SearchResults({
keyword,
data,
}: {
keyword: string;
data?: PostListResponse | null;
}) {
const searchResults = data?.content || [];
const searchTotalElements = getTotalElements(data);
if (isNoticesLoading || isLatestLoading || isPopularLoading) {
return ( return (
<div className="flex justify-center items-center h-screen"> <WindowSurface
<Loader2 className="animate-spin text-blue-500" size={40} /> title="Spotlight"
subtitle={`"${keyword}"`}
bodyClassName="p-0"
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<div className="border-b border-[var(--color-line)] bg-[var(--window-titlebar)] px-4 py-4 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<Search size={20} />
</div> </div>
<div className="min-w-0">
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">
</h1>
<p className="break-words text-sm text-[var(--color-text-muted)]">
<span className="font-semibold text-[var(--color-text)]">{keyword}</span> {searchTotalElements.toLocaleString()}
</p>
</div>
</div>
</div>
<div className="p-4 md:p-6">
{searchResults.length > 0 ? (
<div className="divide-y divide-[var(--color-line)] rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-2">
{searchResults.map((post) => (
<CompactPostRow key={post.id} post={post} />
))}
</div>
) : (
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
)}
</div>
</WindowSurface>
); );
} }
const filterPosts = (posts: Post[] | undefined, limit: number) => { function NoticeStrip({ notices }: { notices: Post[] }) {
if (!posts) return []; if (notices.length === 0) return null;
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 ( return (
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12"> <section className="space-y-2" aria-label="공지">
{notices.slice(0, 3).map((notice) => (
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */} <Link key={notice.id} href={`/posts/${notice.slug}`} className="group block min-w-0">
{keyword ? ( <Surface
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300"> interactive
<div className="flex items-center gap-2 mb-6 border-b border-gray-100 pb-4"> className="flex min-w-0 items-center justify-between gap-3 border-red-500/10 bg-red-500/[0.045] px-4 py-3 shadow-none"
<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>
</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} />
))}
</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>
)}
{/* 최신 포스트 섹션 */}
<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} /> <div className="flex min-w-0 items-center gap-3">
</Link> <StatusBadge tone="danger" className="shrink-0"></StatusBadge>
</div> */} <span className="min-w-0 truncate text-sm font-semibold text-[var(--color-text)]">
{notice.title}
</span>
</div> </div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
<time className="hidden sm:inline">{formatDate(notice.createdAt)}</time>
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
</div>
</Surface>
</Link>
))}
</section>
);
}
function CompactPostRow({
post,
rank,
showViews = false,
featured = false,
}: {
post: Post;
rank?: number;
showViews?: boolean;
featured?: boolean;
}) {
const summary = getSummary(post.content, featured ? 150 : 92);
return (
<Link
href={`/posts/${post.slug}`}
className={clsxSafe(
'group flex min-w-0 items-start justify-between gap-3 rounded-lg px-1 py-4 transition duration-150 hover:bg-[var(--card-bg)] hover:px-3',
featured && 'md:py-5',
)} )}
>
{rank !== undefined && (
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-xs font-bold tabular-nums text-[var(--color-accent)]">
{rank}
</span>
)}
{rank === undefined && (
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<FileText size={17} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={isNoticePost(post) ? 'danger' : 'neutral'} className="shrink-0">
{post.categoryName || '미분류'}
</StatusBadge>
<time className="shrink-0 text-xs text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time>
{showViews && (
<span className="shrink-0 text-xs tabular-nums text-[var(--color-text-subtle)]">
{post.viewCount.toLocaleString()}
</span>
)}
</div>
<h3 className="line-clamp-2 break-words text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{post.title}
</h3>
{summary && (
<p className="mt-1 line-clamp-2 break-words text-sm leading-6 text-[var(--color-text-muted)]">
{summary}
</p>
)}
</div>
<ChevronRight size={17} 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 clsxSafe(...classes: Array<string | false | undefined>) {
return classes.filter(Boolean).join(' ');
}
function PostListPanel({
title,
icon,
posts,
isPopular = false,
featured = false,
}: {
title: string;
icon: ReactNode;
posts: Post[];
isPopular?: boolean;
featured?: boolean;
}) {
return (
<Surface as="section" strong className="min-w-0 overflow-hidden shadow-none">
<div className="flex min-h-12 min-w-0 items-center justify-between gap-3 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4 py-3 md:px-5">
<h2 className="flex min-w-0 items-center gap-2 text-sm font-bold text-[var(--color-text)]">
{icon}
<span className="truncate">{title}</span>
</h2>
<Link href="/archive" className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-[var(--color-text-muted)] transition hover:text-[var(--color-accent)]">
<ChevronRight size={14} />
</Link>
</div>
<div className="p-4 md:p-6">
{posts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{posts.map((post, index) => (
<CompactPostRow
key={post.id}
post={post}
rank={isPopular ? index + 1 : undefined}
showViews={isPopular}
featured={featured}
/>
))}
</div>
) : (
<EmptyState title={isPopular ? '인기 글을 집계 중입니다.' : '아직 공개된 글이 없습니다.'} className="min-h-72" />
)}
</div>
</Surface>
);
}
export default async function Home({ searchParams }: HomePageProps) {
const keyword = await getSearchKeyword(searchParams);
if (keyword) {
const searchData = await fetchPublicPosts({ keyword, size: 20 });
return (
<main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
<SearchResults keyword={keyword} data={searchData} />
</main> </main>
); );
} }
export default function Home() { const [noticesData, latestData, popularData] = await Promise.all([
fetchPublicPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
fetchPublicPosts({ size: 10, sort: 'createdAt,desc' }),
fetchPublicPosts({ size: 8, sort: 'viewCount,desc' }),
]);
const notices = noticesData?.content || [];
const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 7);
const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5);
return ( return (
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" size={40} /></div>}> <main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
<HomeContent /> <h1 className="sr-only">{SITE_NAME}</h1>
</Suspense> <WindowSurface
title="WYPark"
subtitle="블로그 라이브러리"
controls={(
<Link
href="/archive"
className="inline-flex h-8 min-w-0 items-center gap-2 rounded-full bg-[var(--color-text)] px-3 text-xs font-semibold text-[var(--color-page)] shadow-[var(--shadow-control)] transition hover:opacity-90 dark:bg-white dark:text-black"
>
<span className="hidden sm:inline"> </span>
<Archive size={14} />
</Link>
)}
bodyClassName="space-y-6 p-4 md:p-6"
>
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-4 py-3 md:flex-row md:items-center">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<HardDrive size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-[var(--color-text)]"> </p>
<p className="truncate text-xs text-[var(--color-text-subtle)]">
{latestList.length.toLocaleString()} · {popularList.length.toLocaleString()}
</p>
</div>
</div>
<span className="text-xs font-semibold tabular-nums text-[var(--color-text-subtle)]">
{new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium' }).format(new Date())}
</span>
</div>
<NoticeStrip notices={notices} />
<section className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)]">
<PostListPanel
title="최신 글"
icon={<Clock size={18} className="shrink-0 text-[var(--color-accent)]" />}
posts={latestList}
featured
/>
<PostListPanel
title="인기 글"
icon={<TrendingUp size={18} className="shrink-0 text-[var(--color-accent)]" />}
posts={popularList}
isPopular
/>
</section>
</WindowSurface>
</main>
); );
} }

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import ChessPuzzleClient from '@/components/chess/ChessPuzzleClient';
export const metadata: Metadata = {
title: '오늘의 체스 퍼즐 | WYPark Blog',
description: '오늘의 메이트 체스 퍼즐',
};
export default function ChessPuzzlePage() {
return <ChessPuzzleClient />;
}

View File

@@ -0,0 +1,9 @@
import { Loader2 } from 'lucide-react';
export default function Loading() {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { Loader2 } from 'lucide-react';
export default function Loading() {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}

View File

@@ -1,87 +1,90 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import PostDetailClient from '@/components/post/PostDetailClient';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Post } from '@/types'; import { fetchPublicPost } from '@/api/publicPosts';
import PostDetailClient from '@/components/post/PostDetailClient';
import { DEFAULT_DESCRIPTION, getCanonicalUrl, parseApiDate, SITE_NAME, SITE_URL } from '@/lib/site';
type Props = { type Props = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}; };
// 🛠️ 서버 사이드 데이터 패칭 함수 (Metadata와 Page 양쪽에서 재사용) const getPostDescription = (content?: string) => {
async function getPostFromServer(slug: string): Promise<Post | null> { const plainText = content
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; ?.replace(/```[\s\S]*?```/g, ' ')
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/[#*`_~>]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!plainText) return DEFAULT_DESCRIPTION;
return plainText.length > 150 ? `${plainText.slice(0, 150)}...` : plainText;
};
const getFirstMarkdownImage = (content?: string) => {
const imageMatch = content?.match(/!\[.*?\]\((.*?)\)/);
const imageUrl = imageMatch?.[1]?.split(/\s+/)[0] || '/og-image.png';
try { try {
const res = await fetch(`${BASE_URL}/api/posts/${slug}`, { return new URL(imageUrl, SITE_URL).toString();
// 캐시 설정: 60초마다 갱신 (블로그 특성상 적절) } catch {
// 즉시 반영이 필요하다면 'no-store'로 설정하거나 revalidate: 0 사용 return new URL('/og-image.png', SITE_URL).toString();
next: { revalidate: 60 },
});
if (!res.ok) return null;
const json = await res.json();
return json.data;
} catch (error) {
console.error('Server fetch error:', error);
return null;
}
} }
};
// 🌟 메타데이터 생성 (SEO)
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const post = await getPostFromServer(slug); const post = await fetchPublicPost(slug);
const canonicalUrl = getCanonicalUrl(`/posts/${encodeURIComponent(post?.slug || slug)}`);
if (!post) { if (!post) {
return { return {
title: '게시글을 찾을 수 없습니다', title: '게시글을 찾을 수 없습니다',
alternates: {
canonical: canonicalUrl,
},
}; };
} }
// 본문 요약 및 이미지 추출 로직 const description = getPostDescription(post.content);
const description = post.content const imageUrl = getFirstMarkdownImage(post.content);
?.replace(/[#*`_~]/g, '') const publishedTime = parseApiDate(post.createdAt).toISOString();
.replace(/\n/g, ' ') const modifiedTime = parseApiDate(post.updatedAt || post.createdAt).toISOString();
.substring(0, 150) + '...';
const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/);
const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png';
return { return {
title: post.title, title: post.title,
description: description, description,
alternates: {
canonical: canonicalUrl,
},
openGraph: { openGraph: {
title: post.title, title: post.title,
description: description, description,
url: `https://blog.wypark.me/posts/${slug}`, url: canonicalUrl,
siteName: 'WYPark Blog', siteName: SITE_NAME,
images: [{ url: imageUrl, width: 1200, height: 630 }], images: [{ url: imageUrl, width: 1200, height: 630 }],
type: 'article', type: 'article',
publishedTime: post.createdAt, // created_at 대신 createdAt 사용 주의 (타입 정의 따름) publishedTime,
modifiedTime,
authors: ['WYPark'], authors: ['WYPark'],
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title: post.title, title: post.title,
description: description, description,
images: [imageUrl], images: [imageUrl],
}, },
}; };
} }
// 🌟 실제 페이지 렌더링 (SSR 적용)
export default async function PostDetailPage({ params }: Props) { export default async function PostDetailPage({ params }: Props) {
const { slug } = await params; const { slug } = await params;
const post = await fetchPublicPost(slug);
// 1. 서버에서 데이터를 미리 가져옵니다.
const post = await getPostFromServer(slug);
// 2. 데이터가 없으면 404 페이지로 보냅니다. (봇에게도 404 신호를 줌)
if (!post) { if (!post) {
notFound(); notFound();
} }
// 3. 가져온 데이터를 클라이언트 컴포넌트에 'initialPost'로 넘겨줍니다.
return <PostDetailClient slug={slug} initialPost={post} />; return <PostDetailClient slug={slug} initialPost={post} />;
} }

View File

@@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import axios from 'axios'; import axios from 'axios';
import { ThemeProvider } from '@/components/theme/ThemeProvider';
// 🛠️ JWT 토큰 만료 여부 체크 함수 (라이브러리 없이 구현) // 🛠️ JWT 토큰 만료 여부 체크 함수 (라이브러리 없이 구현)
function isTokenExpired(token: string) { function isTokenExpired(token: string) {
@@ -23,7 +24,7 @@ function isTokenExpired(token: string) {
// 현재 시간(초)이 만료 시간(exp)보다 크거나 같으면 만료됨 // 현재 시간(초)이 만료 시간(exp)보다 크거나 같으면 만료됨
// 안전 마진 60초 추가 (만료 1분 전이면 미리 갱신) // 안전 마진 60초 추가 (만료 1분 전이면 미리 갱신)
return Date.now() / 1000 >= exp - 60; return Date.now() / 1000 >= exp - 60;
} catch (e) { } catch {
return true; // 파싱 실패 시 만료된 것으로 간주 return true; // 파싱 실패 시 만료된 것으로 간주
} }
} }
@@ -61,7 +62,7 @@ function AuthInitializer() {
} else { } else {
throw new Error('Token refresh response invalid'); throw new Error('Token refresh response invalid');
} }
} catch (error) { } catch {
// console.error('❌ Failed to refresh token on init:', error); // console.error('❌ Failed to refresh token on init:', error);
// 갱신 실패 시 깔끔하게 로그아웃 처리하여 꼬임 방지 // 갱신 실패 시 깔끔하게 로그아웃 처리하여 꼬임 방지
logout(); logout();
@@ -93,11 +94,15 @@ export default function Providers({ children }: { children: React.ReactNode }) {
); );
return ( return (
<ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */} <AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
{children} {children}
<Toaster position="top-right" /> <Toaster position="top-right" />
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
); );
} }

View File

@@ -1,16 +1,14 @@
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { SITE_URL } from '@/lib/site';
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://blog.wypark.me';
return { return {
rules: { rules: {
userAgent: '*', userAgent: '*',
allow: '/', allow: '/',
// 검색 로봇이 굳이 긁어갈 필요 없는 페이지들은 차단 (글쓰기, 로그인 등)
disallow: ['/write', '/login', '/signup', '/admin'], disallow: ['/write', '/login', '/signup', '/admin'],
}, },
// 여기서 동적으로 생성된 sitemap.xml을 가리킵니다. sitemap: `${SITE_URL}/sitemap.xml`,
sitemap: `${baseUrl}/sitemap.xml`,
}; };
} }

View File

@@ -2,132 +2,153 @@
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import { signup, verifyEmail } from '@/api/auth';
import { SignupRequest } from '@/types';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { MailCheck, UserPlus } from 'lucide-react';
import { signup, verifyEmail } from '@/api/auth';
import WindowSurface from '@/components/ui/WindowSurface';
import { SignupRequest } from '@/types';
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() { export default function SignupPage() {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM'); // 단계 관리 const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState(''); // 인증할 이메일 저장 const [registeredEmail, setRegisteredEmail] = useState('');
// React Hook Form 설정 const { register, handleSubmit, formState: { errors } } = useForm<SignupRequest>();
const { register, handleSubmit, formState: { errors }, watch } = useForm<SignupRequest>();
const [verifyCode, setVerifyCode] = useState(''); const [verifyCode, setVerifyCode] = useState('');
// 1단계: 회원가입 정보 제출
const onSignupSubmit = async (data: SignupRequest) => { const onSignupSubmit = async (data: SignupRequest) => {
setLoading(true); setLoading(true);
try { try {
const res = await signup(data); const res = await signup(data);
if (res.code === 'SUCCESS') { if (res.code === 'SUCCESS') {
alert(`📧 ${data.email}로 인증 코드를 보냈습니다!`); alert(`${data.email}로 인증 코드를 보냈습니다.`);
setRegisteredEmail(data.email); // 이메일 기억하기 setRegisteredEmail(data.email);
setStep('VERIFY'); // 2단계로 이동 setStep('VERIFY');
} else { } else {
alert('회원가입 실패: ' + res.message); alert(`회원가입 실패: ${res.message}`);
} }
} catch (error: any) { } catch (error) {
alert('오류 발생: ' + (error.response?.data?.message || error.message)); alert(`오류 발생: ${getErrorMessage(error)}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 2단계: 인증 코드 제출 const onVerifySubmit = async (event: React.FormEvent) => {
const onVerifySubmit = async (e: React.FormEvent) => { event.preventDefault();
e.preventDefault(); if (!verifyCode) {
if (!verifyCode) return alert('인증 코드를 입력해주세요.'); alert('인증 코드를 입력해주세요.');
return;
}
setLoading(true); setLoading(true);
try { try {
const res = await verifyEmail({ email: registeredEmail, code: verifyCode }); const res = await verifyEmail({ email: registeredEmail, code: verifyCode });
if (res.code === 'SUCCESS') { if (res.code === 'SUCCESS') {
alert('인증되었습니다! 로그인 페이지로 이동합니다.'); alert('인증되었습니다. 로그인 페이지로 이동합니다.');
router.push('/login'); router.push('/login');
} else { } else {
alert('인증 실패: ' + res.message); alert(`인증 실패: ${res.message}`);
} }
} catch (error: any) { } catch (error) {
alert('오류 발생: ' + (error.response?.data?.message || error.message)); alert(`오류 발생: ${getErrorMessage(error)}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
// 🎨 배경색 수정: bg-gray-50 -> bg-white <div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
<div className="min-h-screen flex items-center justify-center bg-white px-4 py-12"> <WindowSurface
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100"> title="Account Setup"
subtitle={step === 'FORM' ? 'Create account' : registeredEmail}
{/* 헤더 */} className="w-full max-w-md"
<div className="text-center mb-8"> bodyClassName="p-8"
<h1 className="text-2xl font-bold text-gray-900"></h1> >
<p className="text-sm text-gray-500 mt-2"> <div className="mb-8 text-center">
{step === 'FORM' ? '' : '이메일로 전송된 6자리 코드를 입력하세요.'} <div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-2xl border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-card)]">
{step === 'FORM' ? <UserPlus size={34} strokeWidth={1.9} /> : <MailCheck size={34} strokeWidth={1.9} />}
</div>
<h1 className="text-2xl font-bold text-[var(--color-text)]"></h1>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
{step === 'FORM' ? '블로그 OS 계정을 만듭니다.' : '이메일로 전송된 6자리 코드를 입력하세요.'}
</p> </p>
</div> </div>
{/* STEP 1: 가입 정보 입력 폼 */}
{step === 'FORM' && ( {step === 'FORM' && (
<form onSubmit={handleSubmit(onSignupSubmit)} className="space-y-5"> <form onSubmit={handleSubmit(onSignupSubmit)} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
{...register('email', { {...register('email', {
required: '이메일은 필수입니다.', required: '이메일은 필수입니다.',
pattern: { value: /\S+@\S+\.\S+/, message: '이메일 형식이 올바르지 않습니다.' } pattern: { value: /\S+@\S+\.\S+/, message: '이메일 형식이 올바르지 않습니다.' },
})} })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="user@example.com" placeholder="user@example.com"
/> />
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>} {errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
{...register('nickname', { required: '닉네임을 입력해주세요.' })} {...register('nickname', { required: '닉네임을 입력해주세요.' })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="개발자" placeholder="개발자"
/> />
{errors.nickname && <p className="text-red-500 text-xs mt-1">{errors.nickname.message}</p>} {errors.nickname && <p className="mt-1 text-xs text-red-500">{errors.nickname.message}</p>}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
type="password" type="password"
{...register('password', { required: '비밀번호를 입력해주세요.', minLength: { value: 6, message: '6자 이상 입력해주세요.' } })} {...register('password', {
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none" required: '비밀번호를 입력해주세요.',
minLength: { value: 6, message: '6자 이상 입력해주세요.' },
})}
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="••••••••" placeholder="••••••••"
/> />
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>} {errors.password && <p className="mt-1 text-xs text-red-500">{errors.password.message}</p>}
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400" className="w-full rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
> >
{loading ? '처리 중...' : '인증 메일 받기'} {loading ? '처리 중...' : '인증 메일 받기'}
</button> </button>
</form> </form>
)} )}
{/* STEP 2: 인증 코드 입력 폼 */}
{step === 'VERIFY' && ( {step === 'VERIFY' && (
<form onSubmit={onVerifySubmit} className="space-y-5"> <form onSubmit={onVerifySubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> (6)</label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"> (6)</label>
<input <input
type="text" type="text"
value={verifyCode} value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)} onChange={(event) => setVerifyCode(event.target.value)}
maxLength={6} maxLength={6}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none text-center text-2xl tracking-widest" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-center text-2xl tracking-normal text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="000000" placeholder="000000"
/> />
</div> </div>
@@ -135,7 +156,7 @@ export default function SignupPage() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400" className="w-full rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
> >
{loading ? '처리 중...' : '인증 완료'} {loading ? '처리 중...' : '인증 완료'}
</button> </button>
@@ -143,21 +164,20 @@ export default function SignupPage() {
<button <button
type="button" type="button"
onClick={() => setStep('FORM')} onClick={() => setStep('FORM')}
className="w-full text-sm text-gray-500 hover:underline" className="w-full text-sm text-[var(--color-text-muted)] hover:underline"
> >
</button> </button>
</form> </form>
)} )}
{/* 하단 링크 */} <div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
<div className="mt-6 text-center text-sm text-gray-500">
?{' '} ?{' '}
<Link href="/login" className="text-blue-600 font-semibold hover:underline"> <Link href="/login" className="font-semibold text-[var(--color-accent)] hover:underline">
</Link> </Link>
</div> </div>
</div> </WindowSurface>
</div> </div>
); );
} }

View File

@@ -1,54 +1,40 @@
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import {
fetchPublicPosts,
} from '@/api/publicPosts';
import { parseApiDate, SITE_URL } from '@/lib/site';
export const dynamic = 'force-dynamic';
export const revalidate = 300;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://blog.wypark.me'; const generatedAt = new Date();
// API 주소 환경변수 사용 (없으면 로컬)
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
// 1. 고정된 정적 페이지들
const routes: MetadataRoute.Sitemap = [ const routes: MetadataRoute.Sitemap = [
{ {
url: baseUrl, url: SITE_URL,
lastModified: new Date(), lastModified: generatedAt,
changeFrequency: 'daily', changeFrequency: 'daily',
priority: 1, priority: 1,
}, },
{ {
url: `${baseUrl}/archive`, url: `${SITE_URL}/archive`,
lastModified: new Date(), lastModified: generatedAt,
changeFrequency: 'daily', changeFrequency: 'daily',
priority: 0.8, priority: 0.8,
}, },
]; ];
try { const postsData = await fetchPublicPosts({ size: 1000, sort: 'createdAt,desc' });
// 2. API에서 게시글 목록 가져오기 const posts = postsData?.content || [];
const response = await fetch(`${apiUrl}/api/posts?size=1000&sort=createdAt,desc`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 3600 }
});
if (!response.ok) { const postRoutes: MetadataRoute.Sitemap = posts
throw new Error('Failed to fetch posts'); .filter((post) => post.slug)
} .map((post) => ({
url: `${SITE_URL}/posts/${encodeURIComponent(post.slug)}`,
const json = await response.json(); lastModified: parseApiDate(post.updatedAt || post.createdAt, generatedAt),
const posts = json.data?.content || []; changeFrequency: 'weekly',
// 3. 게시글 데이터를 사이트맵 형식으로 변환
const postRoutes = posts.map((post: any) => ({
// 🛠️ 핵심 수정: slug를 encodeURIComponent로 감싸서 특수문자(&, 한글 등)를 안전하게 처리합니다.
url: `${baseUrl}/posts/${encodeURIComponent(post.slug)}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8, priority: 0.8,
})); }));
return [...routes, ...postRoutes]; return [...routes, ...postRoutes];
} catch (error) {
console.error('Sitemap generation error:', error);
return routes;
}
} }

View File

@@ -1,524 +1,29 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { Suspense, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react';
import { createPost, updatePost, getPost } from '@/api/posts';
import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image';
import { useAuthStore } from '@/store/authStore';
import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud, FileText, Trash2, X, Clock } from 'lucide-react';
import toast from 'react-hot-toast';
import dynamic from 'next/dynamic';
import axios from 'axios';
const MDEditor = dynamic( function LegacyWriteRedirect() {
() => import('@uiw/react-md-editor').then((mod) => mod.default),
{ ssr: false }
);
// 🛠️ 임시저장 데이터 타입 정의
interface DraftPost {
id: number;
title: string;
content: string;
savedAt: string;
}
// 🛠️ 1. 토큰 만료 체크 유틸리티
function isTokenExpired(token: string) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
const { exp } = JSON.parse(jsonPayload);
return Date.now() / 1000 >= exp - 30;
} catch (e) {
return true;
}
}
function WritePageContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore();
useEffect(() => {
const editSlug = searchParams.get('slug'); const editSlug = searchParams.get('slug');
const isEditMode = !!editSlug; router.replace(editSlug ? `/admin/posts/${editSlug}/edit` : '/admin/posts/new');
}, [router, searchParams]);
const [title, setTitle] = useState('');
const [content, setContent] = useState('**Hello world!**');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// 💾 임시저장 관련 상태
const [drafts, setDrafts] = useState<DraftPost[]>([]);
const [showDraftList, setShowDraftList] = useState(false);
useEffect(() => {
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
toast.error('관리자 권한이 필요합니다.');
router.push('/');
}
}, [role, _hasHydrated, router]);
// 컴포넌트 마운트 시 로컬스토리지에서 임시저장 목록 불러오기
useEffect(() => {
const savedDrafts = localStorage.getItem('temp_drafts');
if (savedDrafts) {
try {
setDrafts(JSON.parse(savedDrafts));
} catch (e) {
console.error('Failed to parse drafts', e);
}
}
}, []);
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
});
const { data: existingPost, isLoading: isLoadingPost } = useQuery({
queryKey: ['post', editSlug],
queryFn: () => getPost(editSlug!),
enabled: isEditMode,
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always'
});
useEffect(() => {
if (existingPost) {
setTitle(existingPost.title || '');
setContent(existingPost.content || '');
setTags(existingPost.tags ? existingPost.tags.join(', ') : '');
if (categories && existingPost.categoryName) {
const found = findCategoryByName(categories, existingPost.categoryName);
if (found) setCategoryId(found.id);
}
}
}, [existingPost, categories]);
const findCategoryByName = (cats: any[], name: string): any => {
for (const cat of cats) {
if (cat.name === name) return cat;
if (cat.children) {
const found = findCategoryByName(cat.children, name);
if (found) return found;
}
}
return null;
};
// 💾 임시저장 기능
const handleTempSave = () => {
if (!title.trim() && !content.trim()) {
toast.error('제목이나 내용을 입력해주세요.');
return;
}
if (drafts.length >= 10) {
toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.');
setShowDraftList(true); // 목록 열어주기
return;
}
const newDraft: DraftPost = {
id: Date.now(),
title: title || '(제목 없음)',
content: content,
savedAt: new Date().toLocaleString(),
};
const newDrafts = [newDraft, ...drafts];
setDrafts(newDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
toast.success('임시저장 되었습니다!');
};
// 💾 임시저장 불러오기
const handleLoadDraft = (draft: DraftPost) => {
if (confirm('현재 작성 중인 내용이 사라집니다.\n선택한 임시저장 글을 불러오시겠습니까?')) {
setTitle(draft.title === '(제목 없음)' ? '' : draft.title);
setContent(draft.content);
setShowDraftList(false);
toast.success('불러오기 완료');
}
};
// 💾 임시저장 삭제
const handleDeleteDraft = (id: number) => {
const newDrafts = drafts.filter(d => d.id !== id);
setDrafts(newDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
toast.success('삭제되었습니다.');
};
const mutation = useMutation({
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
onSuccess: async (response: any) => {
const savedPost = response?.data;
const newSlug = savedPost?.slug || editSlug;
await queryClient.resetQueries({ queryKey: ['posts'] });
await queryClient.invalidateQueries({ queryKey: ['categories'] });
if (editSlug) {
queryClient.removeQueries({ queryKey: ['post', editSlug] });
}
if (newSlug && newSlug !== editSlug) {
queryClient.removeQueries({ queryKey: ['post', newSlug] });
}
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
router.push(isEditMode ? `/posts/${newSlug}` : '/');
},
onError: (err: any) => {
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
},
onSettled: () => {
setIsSubmitting(false);
}
});
const ensureAuthToken = async (): Promise<boolean> => {
if (!accessToken || !refreshToken) {
toast.error('로그인이 필요합니다.');
return false;
}
if (!isTokenExpired(accessToken)) {
return true;
}
try {
console.log('🔄 Access token expired during write. Refreshing...');
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
const { data } = await axios.post(
`${BASE_URL}/api/auth/reissue`,
{ accessToken, refreshToken },
{ headers: { 'Content-Type': 'application/json' }, withCredentials: true }
);
if (data.code === 'SUCCESS' && data.data) {
login(data.data.accessToken, data.data.refreshToken);
console.log('✅ Token refreshed successfully before save.');
return true;
}
return false;
} catch (error) {
console.error('❌ Failed to refresh token before save:', error);
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 });
return false;
}
};
const handleSubmit = async () => {
if (!title.trim() || !content.trim()) {
toast.error('제목과 내용을 입력해주세요.');
return;
}
if (categoryId === '') {
toast.error('카테고리를 선택해주세요.');
return;
}
setIsSubmitting(true);
const isTokenValid = await ensureAuthToken();
if (!isTokenValid) {
setIsSubmitting(false);
return;
}
mutation.mutate({
title,
content,
categoryId: Number(categoryId),
tags: tags.split(',').map(t => t.trim()).filter(t => t),
});
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const uploadToast = toast.loading('이미지 업로드 중...');
try {
await ensureAuthToken();
const res = await uploadImage(file);
if (res.code === 'SUCCESS' && res.data) {
const imageUrl = res.data;
const markdownImage = `![image](${imageUrl})`;
setContent((prev) => prev + '\n' + markdownImage);
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
}
} catch (error) {
toast.error('이미지 업로드 실패', { id: uploadToast });
} finally {
setIsUploading(false);
e.target.value = '';
}
};
const onPaste = async (event: any) => {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.indexOf('image') !== -1) {
event.preventDefault();
const file = item.getAsFile();
if (!file) return;
setIsUploading(true);
const uploadToast = toast.loading('이미지 업로드 중...');
try {
await ensureAuthToken();
const res = await uploadImage(file);
if (res.code === 'SUCCESS' && res.data) {
const imageUrl = res.data;
const markdownImage = `![image](${imageUrl})`;
setContent((prev) => prev + '\n' + markdownImage);
toast.success('이미지 붙여넣기 완료!', { id: uploadToast });
}
} catch (error) {
toast.error('이미지 업로드 실패', { id: uploadToast });
} finally {
setIsUploading(false);
}
}
}
};
if (isEditMode && isLoadingPost) {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}
const renderCategoryOptions = (cats: any[], depth = 0) => {
return cats.map((cat) => (
<div key={cat.id}>
<label className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer rounded transition-colors">
<input
type="radio"
name="category"
value={cat.id}
checked={categoryId === cat.id}
onChange={(e) => setCategoryId(Number(e.target.value))}
className="text-blue-600 focus:ring-blue-500"
/>
<span style={{ marginLeft: depth * 10 + 'px' }} className={depth === 0 ? 'font-medium' : 'text-gray-600'}>
{depth > 0 && '- '} {cat.name}
</span>
</label>
{cat.children && renderCategoryOptions(cat.children, depth + 1)}
</div>
));
};
return ( return (
<div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}> <div className="flex min-h-[60vh] items-center justify-center">
{/* 상단 헤더 영역 */} <Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors">
<ArrowLeft size={20} />
</button>
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
</div>
<div className="flex items-center gap-3">
{/* 💾 임시저장 버튼 그룹 */}
<div className="relative">
<div className="flex items-center bg-white border border-gray-300 rounded-lg shadow-sm">
<button
onClick={handleTempSave}
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2 border-r border-gray-300 rounded-l-lg transition-colors"
title="현재 내용 임시저장"
>
<FileText size={16} className="text-gray-500" />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={() => setShowDraftList(!showDraftList)}
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-r-lg transition-colors flex items-center gap-1"
title="임시저장 목록 보기"
>
<span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded text-xs font-bold">
{drafts.length}
</span>
</button>
</div>
{/* 💾 임시저장 목록 팝업 */}
{showDraftList && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowDraftList(false)}
/>
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-20 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
<h3 className="font-bold text-gray-700 text-sm"> ({drafts.length}/10)</h3>
<button onClick={() => setShowDraftList(false)}><X size={16} className="text-gray-400 hover:text-gray-600" /></button>
</div>
<div className="max-h-80 overflow-y-auto">
{drafts.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm">
.
</div>
) : (
<ul className="divide-y divide-gray-100">
{drafts.map((draft) => (
<li key={draft.id} className="p-3 hover:bg-blue-50 transition-colors group">
<div className="flex justify-between items-start gap-2">
<button
onClick={() => handleLoadDraft(draft)}
className="flex-1 text-left"
>
<p className="font-medium text-gray-800 text-sm line-clamp-1 mb-1 group-hover:text-blue-600">
{draft.title}
</p>
<div className="flex items-center gap-1 text-xs text-gray-400">
<Clock size={12} />
{draft.savedAt}
</div>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteDraft(draft.id); }}
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
</div>
<button
onClick={handleSubmit}
disabled={isSubmitting || isUploading}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-400 shadow-md hover:shadow-lg transform active:scale-95 duration-200"
>
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
{isEditMode ? '수정하기' : '작성하기'}
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3 space-y-4">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력하세요"
className="w-full text-3xl font-bold placeholder:text-gray-300 border-none outline-none py-2 bg-transparent"
/>
<div data-color-mode="light">
<MDEditor
value={content}
onChange={(val) => setContent(val || '')}
height={600}
preview="edit"
className="border border-gray-200 rounded-lg shadow-sm !font-sans"
/>
</div>
</div>
<div className="space-y-6">
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-6">
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
<Folder size={18} />
</h3>
<div className="max-h-60 overflow-y-auto space-y-1 text-sm border-t border-gray-100 pt-2 scrollbar-thin scrollbar-thumb-gray-200">
{categories ? renderCategoryOptions(categories) : <p className="text-gray-400 text-sm"> ...</p>}
</div>
</div>
{/* 🛠️ [Legacy] 태그 입력 UI 비활성화
사용자 요청으로 UI에서 숨김 처리 (데이터 구조는 유지)
*/}
{/*
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
🏷️ 태그
</h3>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="태그 입력 (콤마 , 로 구분)"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none"
/>
<p className="text-xs text-gray-400 mt-2">예: React, Next.js, 튜토리얼</p>
</div>
*/}
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
<ImageIcon size={18} />
</h3>
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
<br/><strong> & (Ctrl+V)</strong> <br/> .
</p>
<label
className={`flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50 hover:border-blue-400 transition-all duration-200 ${isUploading ? 'opacity-50 cursor-wait' : ''}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<UploadCloud className="w-8 h-8 text-gray-400 mb-2 group-hover:text-blue-500" />
<p className="text-xs text-gray-500 font-medium">
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
</p>
</div>
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
/>
</label>
</div>
</div>
</div>
</div> </div>
); );
} }
export default function WritePage() { export default function WritePage() {
return ( return (
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" /></div>}> <Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>}>
<WritePageContent /> <LegacyWriteRedirect />
</Suspense> </Suspense>
); );
} }

View File

@@ -0,0 +1,432 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { Folder, FolderOpen, FolderPlus, FolderTree, Loader2, Save, Trash2 } from 'lucide-react';
import { createCategory, deleteCategory, getCategories, updateCategory } from '@/api/category';
import WindowSurface from '@/components/ui/WindowSurface';
import { Category, CategoryUpdateRequest } from '@/types';
import { clsx } from 'clsx';
const ROOT_PARENT = 'root';
type FlatCategory = Category & {
depth: number;
parentId: number | null;
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
}
if (error instanceof Error) return `${fallback}: ${error.message}`;
return fallback;
};
const sortCategories = (categories: Category[] = []) => {
return [...categories].sort((a, b) => a.id - b.id);
};
const flattenCategories = (
categories: Category[] = [],
depth = 0,
parentId: number | null = null,
): FlatCategory[] => {
return sortCategories(categories).flatMap((category) => {
const resolvedParentId = category.parentId ?? parentId;
const current: FlatCategory = {
...category,
parentId: resolvedParentId,
depth,
};
return [
current,
...flattenCategories(category.children || [], depth + 1, category.id),
];
});
};
const collectDescendantIds = (category: Category): Set<number> => {
const ids = new Set<number>([category.id]);
for (const child of category.children || []) {
for (const id of collectDescendantIds(child)) {
ids.add(id);
}
}
return ids;
};
const parentValueToId = (value: string): number | null => {
return value === ROOT_PARENT ? null : Number(value);
};
const parentIdToValue = (parentId?: number | null) => {
return parentId == null ? ROOT_PARENT : String(parentId);
};
function ParentSelect({
value,
onChange,
categories,
disabledIds = new Set<number>(),
disabled,
}: {
value: string;
onChange: (value: string) => void;
categories: FlatCategory[];
disabledIds?: Set<number>;
disabled?: boolean;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
>
<option value={ROOT_PARENT}> </option>
{categories.map((category) => (
<option
key={category.id}
value={category.id}
disabled={disabledIds.has(category.id)}
>
{`${' '.repeat(category.depth)}${category.depth > 0 ? '- ' : ''}${category.name}`}
</option>
))}
</select>
);
}
function CategoryTree({
categories,
selectedId,
onSelect,
depth = 0,
}: {
categories: Category[];
selectedId: number | null;
onSelect: (category: Category) => void;
depth?: number;
}) {
return (
<div className="space-y-1">
{sortCategories(categories).map((category) => {
const isSelected = selectedId === category.id;
const hasChildren = category.children?.length > 0;
return (
<div key={category.id}>
<button
type="button"
onClick={() => onSelect(category)}
className={clsx(
'flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition',
isSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50',
)}
style={{ paddingLeft: `${12 + depth * 16}px` }}
>
<span className="flex min-w-0 items-center gap-2">
{hasChildren ? <FolderOpen size={16} /> : <Folder size={16} />}
<span className="truncate font-medium">{category.name}</span>
</span>
<span className="shrink-0 text-xs text-gray-400">#{category.id}</span>
</button>
{hasChildren && (
<CategoryTree
categories={category.children}
selectedId={selectedId}
onSelect={onSelect}
depth={depth + 1}
/>
)}
</div>
);
})}
</div>
);
}
function SelectedCategoryEditor({
category,
flatCategories,
onUpdate,
onDelete,
isSaving,
isDeleting,
}: {
category: FlatCategory;
flatCategories: FlatCategory[];
onUpdate: (id: number, data: CategoryUpdateRequest) => void;
onDelete: (id: number) => void;
isSaving: boolean;
isDeleting: boolean;
}) {
const [name, setName] = useState(category.name);
const [parentValue, setParentValue] = useState(parentIdToValue(category.parentId));
const [deleteArmed, setDeleteArmed] = useState(false);
const disabledParentIds = useMemo(() => collectDescendantIds(category), [category]);
const isBusy = isSaving || isDeleting;
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
toast.error('카테고리 이름을 입력해주세요.');
return;
}
onUpdate(category.id, {
name: trimmedName,
parentId: parentValueToId(parentValue),
});
};
return (
<form onSubmit={handleSubmit} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-bold text-gray-900"> </h3>
<p className="mt-1 text-xs text-gray-500"> .</p>
</div>
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-500">
#{category.id}
</span>
</div>
<div className="space-y-3">
<label className="block">
<span className="text-xs font-bold text-gray-500"> </span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
disabled={isBusy}
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
required
/>
</label>
<label className="block">
<span className="text-xs font-bold text-gray-500"> </span>
<div className="mt-1">
<ParentSelect
value={parentValue}
onChange={setParentValue}
categories={flatCategories}
disabledIds={disabledParentIds}
disabled={isBusy}
/>
</div>
</label>
</div>
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
{!deleteArmed ? (
<button
type="button"
onClick={() => setDeleteArmed(true)}
disabled={isBusy}
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-100 bg-white px-4 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 size={15} />
</button>
) : (
<>
<button
type="button"
onClick={() => onDelete(category.id)}
disabled={isBusy}
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
>
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
</button>
<button
type="button"
onClick={() => setDeleteArmed(false)}
disabled={isBusy}
className="rounded-full px-3 py-2 text-sm font-semibold text-gray-500 transition hover:bg-white"
>
</button>
</>
)}
</div>
<button
type="submit"
disabled={isBusy}
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-2 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-300"
>
{isSaving ? <Loader2 className="animate-spin" size={15} /> : <Save size={15} />}
</button>
</div>
</form>
);
}
export default function AdminCategoryPanel() {
const queryClient = useQueryClient();
const [createName, setCreateName] = useState('');
const [createParentValue, setCreateParentValue] = useState(ROOT_PARENT);
const [selectedId, setSelectedId] = useState<number | null>(null);
const { data: categories = [], isLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
retry: 0,
});
const flatCategories = useMemo(() => flattenCategories(categories), [categories]);
const selectedCategory = flatCategories.find((category) => category.id === selectedId);
const invalidateCategoryData = () => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
};
const createMutation = useMutation({
mutationFn: createCategory,
onSuccess: () => {
invalidateCategoryData();
setCreateName('');
setCreateParentValue(ROOT_PARENT);
toast.success('카테고리가 생성되었습니다.');
},
onError: (error) => toast.error(getErrorMessage(error, '카테고리 생성 실패')),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: CategoryUpdateRequest }) => updateCategory(id, data),
onSuccess: () => {
invalidateCategoryData();
toast.success('카테고리가 수정되었습니다.');
},
onError: (error) => toast.error(getErrorMessage(error, '카테고리 수정 실패')),
});
const deleteMutation = useMutation({
mutationFn: deleteCategory,
onSuccess: () => {
invalidateCategoryData();
setSelectedId(null);
toast.success('카테고리가 삭제되었습니다.');
},
onError: (error) => toast.error(getErrorMessage(error, '카테고리 삭제 실패')),
});
const handleCreate = (event: React.FormEvent) => {
event.preventDefault();
const trimmedName = createName.trim();
if (!trimmedName) {
toast.error('카테고리 이름을 입력해주세요.');
return;
}
createMutation.mutate({
name: trimmedName,
parentId: parentValueToId(createParentValue),
});
};
return (
<WindowSurface title="Categories" subtitle="Finder tree" bodyClassName="p-5">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
<FolderTree size={19} />
</h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(260px,0.8fr)_minmax(0,1.2fr)]">
<div className="rounded-lg border border-gray-200 p-3">
<div className="mb-3 flex items-center justify-between px-1">
<h3 className="text-sm font-bold text-gray-900"> </h3>
<span className="text-xs text-gray-400">{flatCategories.length.toLocaleString()}</span>
</div>
{isLoading ? (
<div className="flex min-h-64 items-center justify-center rounded-lg bg-gray-50">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : categories.length > 0 ? (
<CategoryTree categories={categories} selectedId={selectedId} onSelect={(category) => setSelectedId(category.id)} />
) : (
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
<div className="space-y-4">
<form onSubmit={handleCreate} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center gap-2">
<FolderPlus size={18} className="text-blue-600" />
<h3 className="text-sm font-bold text-gray-900"> </h3>
</div>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
<input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
placeholder="카테고리 이름"
disabled={createMutation.isPending}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<ParentSelect
value={createParentValue}
onChange={setCreateParentValue}
categories={flatCategories}
disabled={createMutation.isPending}
/>
</div>
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={createMutation.isPending}
className="inline-flex items-center justify-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
>
{createMutation.isPending ? <Loader2 className="animate-spin" size={15} /> : <FolderPlus size={15} />}
</button>
</div>
</form>
{selectedCategory ? (
<SelectedCategoryEditor
key={`${selectedCategory.id}-${selectedCategory.name}-${selectedCategory.parentId}`}
category={selectedCategory}
flatCategories={flatCategories}
onUpdate={(id, data) => updateMutation.mutate({ id, data })}
onDelete={(id) => deleteMutation.mutate(id)}
isSaving={updateMutation.isPending}
isDeleting={deleteMutation.isPending}
/>
) : (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-12 text-center">
<p className="text-sm font-semibold text-gray-700"> .</p>
<p className="mt-2 text-sm text-gray-400"> .</p>
</div>
)}
</div>
</div>
</WindowSurface>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import {
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
MessageSquareText,
Trash2,
X,
} from 'lucide-react';
import { deleteAdminComment, getAdminComments } from '@/api/comments';
import WindowSurface from '@/components/ui/WindowSurface';
import { AdminComment, AdminCommentListResponse, PageMeta } from '@/types';
const PAGE_SIZE = 10;
const emptyPageMeta: PageMeta = {
totalElements: 0,
totalPages: 0,
number: 0,
last: true,
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
}
if (error instanceof Error) return `${fallback}: ${error.message}`;
return fallback;
};
const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
if (!data) return emptyPageMeta;
return {
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
number: data.page?.number ?? data.number ?? 0,
last: data.page?.last ?? data.last,
};
};
const formatDate = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const getAuthorName = (comment: AdminComment) => {
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
};
function DeleteCommentDialog({
comment,
isDeleting,
onCancel,
onConfirm,
}: {
comment: AdminComment;
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
<div>
<h3 className="text-lg font-bold text-gray-950"> </h3>
<p className="mt-1 text-sm leading-6 text-gray-500">
. ?
</p>
</div>
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
aria-label="댓글 삭제 닫기"
>
<X size={18} />
</button>
</div>
<div className="p-5">
<div className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
<p className="line-clamp-3 text-sm font-semibold text-red-950">{comment.content}</p>
<p className="mt-2 text-xs text-red-700">
{getAuthorName(comment)} · {formatDate(comment.createdAt)}
</p>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
>
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
</button>
</div>
</div>
</div>
</div>
);
}
export default function AdminCommentsPanel() {
const queryClient = useQueryClient();
const [page, setPage] = useState(0);
const [deleteTarget, setDeleteTarget] = useState<AdminComment | null>(null);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['comments', 'admin', { page }],
queryFn: () => getAdminComments(page, PAGE_SIZE),
retry: 0,
});
const comments = data?.content ?? [];
const meta = getCommentListMeta(data);
const displayTotalPages = Math.max(meta.totalPages, 1);
const isLastPage = meta.last ?? (page + 1 >= displayTotalPages);
const deleteMutation = useMutation({
mutationFn: deleteAdminComment,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments'] });
if (comments.length === 1 && page > 0) {
setPage((currentPage) => Math.max(0, currentPage - 1));
}
setDeleteTarget(null);
toast.success('댓글이 삭제되었습니다.');
},
onError: (error) => toast.error(getErrorMessage(error, '댓글 삭제 실패')),
});
const handleConfirmDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id);
};
return (
<WindowSurface title="Comments" subtitle="Moderation" bodyClassName="p-5">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
<MessageSquareText size={19} />
</h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
<span className="rounded-full bg-gray-50 px-3 py-1 text-sm font-semibold text-gray-500">
{meta.totalElements.toLocaleString()}
</span>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="hidden grid-cols-[120px_minmax(0,1fr)_180px_96px] gap-3 border-b border-gray-100 bg-gray-50 px-4 py-3 text-xs font-bold text-gray-500 md:grid">
<span></span>
<span></span>
<span></span>
<span className="text-right"></span>
</div>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : comments.length > 0 ? (
<div className={isFetching ? 'divide-y divide-gray-100 opacity-70' : 'divide-y divide-gray-100'}>
{comments.map((comment) => (
<div
key={comment.id}
className="grid gap-3 px-4 py-4 transition hover:bg-gray-50 md:grid-cols-[120px_minmax(0,1fr)_180px_96px] md:items-center"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-800">{getAuthorName(comment)}</p>
<p className="mt-1 text-xs text-gray-400 md:hidden">{formatDate(comment.createdAt)}</p>
</div>
<div className="min-w-0">
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
{comment.postTitle && (
<p className="mt-1 truncate text-xs text-gray-400">
: {comment.postTitle}
</p>
)}
</div>
<span className="hidden text-sm text-gray-500 md:block">{formatDate(comment.createdAt)}</span>
<div className="flex items-center justify-end gap-1">
{comment.postSlug && (
<Link
href={`/posts/${comment.postSlug}`}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
aria-label="댓글이 달린 글 보기"
>
<ExternalLink size={16} />
</Link>
)}
<button
type="button"
onClick={() => setDeleteTarget(comment)}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-red-50 hover:text-red-600"
aria-label="댓글 삭제"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-16 text-center">
<MessageSquareText className="mx-auto mb-3 text-gray-300" size={42} />
<p className="text-sm font-semibold text-gray-700"> .</p>
<p className="mt-2 text-sm text-gray-400"> .</p>
</div>
)}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-gray-500">
{isFetching && !isLoading ? '댓글을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
</div>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setPage((currentPage) => Math.max(0, currentPage - 1))}
disabled={page === 0 || isFetching}
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft size={15} />
</button>
<button
type="button"
onClick={() => setPage((currentPage) => currentPage + 1)}
disabled={isLastPage || isFetching}
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronRight size={15} />
</button>
</div>
</div>
{deleteTarget && (
<DeleteCommentDialog
comment={deleteTarget}
isDeleting={deleteMutation.isPending}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleConfirmDelete}
/>
)}
</WindowSurface>
);
}

View File

@@ -0,0 +1,698 @@
'use client';
import axios from 'axios';
import dynamic from 'next/dynamic';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import {
ArrowLeft,
CheckCircle2,
Clock,
FileText,
Folder,
Image as ImageIcon,
Loader2,
Save,
Tags,
Trash2,
UploadCloud,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image';
import { createPost, getPost, updatePost } from '@/api/posts';
import { useTheme } from '@/components/theme/ThemeProvider';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore';
import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types';
const MDEditor = dynamic(
() => import('@uiw/react-md-editor').then((mod) => mod.default),
{ ssr: false },
);
interface DraftPost {
id: number;
title: string;
content: string;
savedAt: string;
}
interface AdminPostEditorProps {
editSlug?: string;
}
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
}
if (error instanceof Error) return `${fallback}: ${error.message}`;
return fallback;
};
const findCategoryByName = (categories: Category[], name: string): Category | null => {
for (const category of categories) {
if (category.name === name) return category;
const found = findCategoryByName(category.children || [], name);
if (found) return found;
}
return null;
};
const findCategoryById = (categories: Category[], id: number): Category | null => {
for (const category of categories) {
if (category.id === id) return category;
const found = findCategoryById(category.children || [], id);
if (found) return found;
}
return null;
};
const isTokenExpired = (token: string) => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((character) => `%${(`00${character.charCodeAt(0).toString(16)}`).slice(-2)}`)
.join(''),
);
const { exp } = JSON.parse(jsonPayload) as { exp?: number };
return !exp || Date.now() / 1000 >= exp - 30;
} catch {
return true;
}
};
function CategoryOptions({
categories,
selectedId,
onSelect,
depth = 0,
}: {
categories: Category[];
selectedId: number | '';
onSelect: (id: number) => void;
depth?: number;
}) {
return (
<div className={depth === 0 ? 'space-y-1' : 'mt-1 space-y-1'}>
{categories.map((category) => (
<div key={category.id}>
<label
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]"
style={{ marginLeft: `${depth * 10}px` }}
>
<input
type="radio"
name="category"
value={category.id}
checked={selectedId === category.id}
onChange={(event) => onSelect(Number(event.target.value))}
className="h-4 w-4 accent-[var(--color-accent)]"
/>
<span className={depth === 0 ? 'font-semibold' : ''}>{category.name}</span>
</label>
{category.children?.length > 0 && (
<CategoryOptions
categories={category.children}
selectedId={selectedId}
onSelect={onSelect}
depth={depth + 1}
/>
)}
</div>
))}
</div>
);
}
function DraftLoadDialog({
draft,
onCancel,
onConfirm,
}: {
draft: DraftPost;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/35 px-4 py-6 backdrop-blur-sm sm:items-center">
<WindowSurface
title="임시저장 불러오기"
className="w-full max-w-md"
bodyClassName="p-5"
>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
.
</p>
<Surface strong className="mt-4 p-4 shadow-none">
<p className="line-clamp-2 text-sm font-semibold text-[var(--color-text)]">{draft.title}</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">{draft.savedAt}</p>
</Surface>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onCancel}
className="inline-flex items-center justify-center rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
</button>
<button
type="button"
onClick={onConfirm}
className="inline-flex items-center justify-center rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
>
</button>
</div>
</WindowSurface>
</div>
);
}
export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const { resolvedTheme } = useTheme();
const { isLoggedIn, role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
const isEditMode = Boolean(editSlug);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [drafts, setDrafts] = useState<DraftPost[]>([]);
const [showDraftList, setShowDraftList] = useState(false);
const [draftToLoad, setDraftToLoad] = useState<DraftPost | null>(null);
useEffect(() => {
if (!_hasHydrated) return;
if (!isLoggedIn) {
toast.error('로그인이 필요합니다.');
const currentPath = typeof window === 'undefined' ? pathname : `${window.location.pathname}${window.location.search}`;
router.replace(`/login?redirect=${encodeURIComponent(currentPath || '/admin/posts/new')}`);
return;
}
if (!isAdmin) {
toast.error('관리자 권한이 필요합니다.');
router.replace('/');
}
}, [_hasHydrated, isAdmin, isLoggedIn, pathname, router]);
useEffect(() => {
const savedDrafts = localStorage.getItem('temp_drafts');
if (!savedDrafts) return;
try {
setDrafts(JSON.parse(savedDrafts) as DraftPost[]);
} catch {
localStorage.removeItem('temp_drafts');
}
}, []);
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
enabled: isAdmin,
});
const { data: existingPost, isLoading: isLoadingPost } = useQuery({
queryKey: ['post', editSlug],
queryFn: () => getPost(editSlug!),
enabled: isAdmin && isEditMode,
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
});
useEffect(() => {
if (!existingPost) return;
setTitle(existingPost.title || '');
setContent(existingPost.content || '');
setTags(existingPost.tags ? existingPost.tags.join(', ') : '');
if (categories.length > 0 && existingPost.categoryName) {
const found = findCategoryByName(categories, existingPost.categoryName);
if (found) setCategoryId(found.id);
}
}, [existingPost, categories]);
const selectedCategoryName = useMemo(() => {
if (categoryId === '') return '선택 안 됨';
return findCategoryById(categories, categoryId)?.name || '선택 안 됨';
}, [categories, categoryId]);
const contentLength = content.trim().length;
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
const readingMinutes = Math.max(1, Math.ceil(wordCount / 350));
const canSubmit = Boolean(title.trim() && content.trim() && categoryId !== '');
const saveDrafts = (nextDrafts: DraftPost[]) => {
setDrafts(nextDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts));
};
const handleTempSave = () => {
if (!title.trim() && !content.trim()) {
toast.error('제목이나 내용을 입력해 주세요.');
return;
}
if (drafts.length >= 10) {
toast.error('임시저장은 최대 10개까지 가능합니다. 기존 저장분을 삭제해 주세요.');
setShowDraftList(true);
return;
}
saveDrafts([
{
id: Date.now(),
title: title || '(제목 없음)',
content,
savedAt: new Date().toLocaleString(),
},
...drafts,
]);
toast.success('임시저장했습니다.');
};
const handleLoadDraft = (draft: DraftPost) => {
setDraftToLoad(draft);
};
const confirmLoadDraft = () => {
if (!draftToLoad) return;
setTitle(draftToLoad.title === '(제목 없음)' ? '' : draftToLoad.title);
setContent(draftToLoad.content);
setShowDraftList(false);
setDraftToLoad(null);
toast.success('임시저장을 불러왔습니다.');
};
const handleDeleteDraft = (id: number) => {
saveDrafts(drafts.filter((draft) => draft.id !== id));
toast.success('삭제했습니다.');
};
const mutation = useMutation({
mutationFn: (data: PostSaveRequest) => {
if (isEditMode) {
return updatePost(existingPost!.id, data);
}
return createPost(data);
},
onSuccess: async (response: ApiResponse<Post>) => {
const savedPost = response.data;
const newSlug = savedPost?.slug || editSlug;
await queryClient.resetQueries({ queryKey: ['posts'] });
await queryClient.invalidateQueries({ queryKey: ['categories'] });
if (editSlug) {
queryClient.removeQueries({ queryKey: ['post', editSlug] });
}
if (newSlug && newSlug !== editSlug) {
queryClient.removeQueries({ queryKey: ['post', newSlug] });
}
toast.success(isEditMode ? '게시글을 수정했습니다.' : '게시글을 발행했습니다.');
router.push(newSlug ? `/posts/${newSlug}` : '/admin');
},
onError: (error) => {
toast.error(getErrorMessage(error, '저장 실패'));
},
onSettled: () => {
setIsSubmitting(false);
},
});
const ensureAuthToken = async (): Promise<boolean> => {
if (!accessToken || !refreshToken) {
toast.error('로그인이 필요합니다.');
return false;
}
if (!isTokenExpired(accessToken)) return true;
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
const { data } = await axios.post<ApiResponse<AuthResponse>>(
`${baseUrl}/api/auth/reissue`,
{ accessToken, refreshToken },
{ headers: { 'Content-Type': 'application/json' }, withCredentials: true },
);
if (data.code === 'SUCCESS' && data.data) {
login(data.data.accessToken, data.data.refreshToken);
return true;
}
return false;
} catch {
toast.error('세션이 만료되었습니다. 작성 중인 글을 복사해 두고 다시 로그인해 주세요.', { duration: 5000 });
return false;
}
};
const appendImageMarkdown = (imageUrl: string) => {
setContent((previous) => `${previous}${previous ? '\n\n' : ''}![image](${imageUrl})`);
};
const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => {
const isTokenValid = await ensureAuthToken();
if (!isTokenValid) return;
const response = await uploadImage(file);
if (response.code === 'SUCCESS' && response.data) {
appendImageMarkdown(response.data);
toast.success(successMessage, { id: toastId });
} else {
toast.error(response.message || '이미지 업로드 실패', { id: toastId });
}
};
const handleSubmit = async () => {
if (!title.trim() || !content.trim()) {
toast.error('제목과 내용을 입력해 주세요.');
return;
}
if (categoryId === '') {
toast.error('카테고리를 선택해 주세요.');
return;
}
setIsSubmitting(true);
const isTokenValid = await ensureAuthToken();
if (!isTokenValid) {
setIsSubmitting(false);
return;
}
mutation.mutate({
title,
content,
categoryId: Number(categoryId),
tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
});
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
const uploadToast = toast.loading('이미지 업로드 중...');
try {
await uploadEditorImage(file, '이미지를 추가했습니다.', uploadToast);
} catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally {
setIsUploading(false);
event.target.value = '';
}
};
const handlePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (!item.type.startsWith('image')) continue;
event.preventDefault();
const file = item.getAsFile();
if (!file) return;
setIsUploading(true);
const uploadToast = toast.loading('이미지 업로드 중...');
try {
await uploadEditorImage(file, '붙여넣은 이미지를 추가했습니다.', uploadToast);
} catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally {
setIsUploading(false);
}
}
};
if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}
return (
<main className="w-full min-w-0" onPaste={handlePaste}>
<WindowSurface
title={isEditMode ? '글 수정' : '새 글 작성'}
subtitle="Markdown Studio"
controls={(
<button
type="button"
onClick={() => router.push('/admin/posts')}
className="inline-flex h-8 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-xs font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<ArrowLeft size={14} />
</button>
)}
bodyClassName="p-4 md:p-6"
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<section className="min-w-0 space-y-4">
<Surface strong className="p-4 shadow-none md:p-5">
<div className="mb-4 flex flex-col gap-3 border-b border-[var(--color-line)] pb-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">Document</p>
<h1 className="mt-1 text-xl font-bold text-[var(--color-text)]">
{isEditMode ? '게시글 수정' : '게시글 작성'}
</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleTempSave}
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<FileText size={16} />
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting || isUploading || !canSubmit}
className="inline-flex h-9 items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 text-sm font-bold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-55"
>
{isSubmitting ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{isEditMode ? '수정하기' : '발행하기'}
</button>
</div>
</div>
<label className="mb-2 block text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
</label>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="제목을 입력하세요"
className="mb-5 w-full rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 py-3 text-2xl font-bold tracking-normal text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] md:text-3xl"
/>
<div className="wy-editor-shell" data-color-mode={resolvedTheme}>
<MDEditor
value={content}
onChange={(value) => setContent(value || '')}
height={680}
preview="edit"
data-color-mode={resolvedTheme}
textareaProps={{
style: {
color: 'var(--color-text)',
WebkitTextFillColor: 'var(--color-text)',
caretColor: 'var(--color-accent)',
background: 'transparent',
},
}}
className="wy-markdown-editor"
/>
</div>
</Surface>
</section>
<aside className="space-y-4 xl:sticky xl:top-24 xl:self-start">
<Surface strong className="p-4 shadow-none">
<h2 className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<CheckCircle2 size={17} className="text-[var(--color-accent)]" />
</h2>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 shadow-[var(--shadow-card)]">
<p className="text-lg font-bold tabular-nums text-[var(--color-text)]">{contentLength.toLocaleString()}</p>
<p className="text-[10px] font-semibold text-[var(--color-text-subtle)]"></p>
</div>
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 shadow-[var(--shadow-card)]">
<p className="text-lg font-bold tabular-nums text-[var(--color-text)]">{wordCount.toLocaleString()}</p>
<p className="text-[10px] font-semibold text-[var(--color-text-subtle)]"></p>
</div>
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 shadow-[var(--shadow-card)]">
<p className="text-lg font-bold tabular-nums text-[var(--color-text)]">{readingMinutes}</p>
<p className="text-[10px] font-semibold text-[var(--color-text-subtle)]"></p>
</div>
</div>
<p className="mt-3 text-xs leading-5 text-[var(--color-text-muted)]">
: <span className="font-semibold text-[var(--color-text)]">{selectedCategoryName}</span>
</p>
</Surface>
<Surface strong className="p-4 shadow-none">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Folder size={17} />
</h2>
<div className="max-h-64 overflow-y-auto rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-2 shadow-[var(--shadow-card)]">
{categories.length > 0 ? (
<CategoryOptions categories={categories} selectedId={categoryId} onSelect={setCategoryId} />
) : (
<div className="flex min-h-24 items-center justify-center text-sm text-[var(--color-text-subtle)]">
...
</div>
)}
</div>
</Surface>
<Surface strong className="p-4 shadow-none">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Tags size={17} />
</h2>
<input
type="text"
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="react, nextjs, essay"
className="w-full rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
/>
<p className="mt-2 text-xs text-[var(--color-text-subtle)]"> .</p>
</Surface>
<Surface strong className="p-4 shadow-none">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<ImageIcon size={17} />
</h2>
<label
className={`flex min-h-28 w-full cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-[var(--card-border)] bg-[var(--card-bg)] p-4 text-center shadow-[var(--shadow-card)] transition hover:border-[var(--color-accent)] hover:bg-[var(--card-bg-strong)] ${isUploading ? 'cursor-wait opacity-60' : ''}`}
>
<UploadCloud className="mb-2 h-7 w-7 text-[var(--color-text-subtle)]" />
<span className="text-sm font-semibold text-[var(--color-text)]">
{isUploading ? '업로드 중...' : '이미지 선택'}
</span>
<span className="mt-1 text-xs leading-5 text-[var(--color-text-subtle)]">
</span>
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
/>
</label>
</Surface>
<Surface strong className="overflow-hidden shadow-none">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-line)] px-4 py-3">
<h2 className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Clock size={17} />
</h2>
<button
type="button"
onClick={() => setShowDraftList((previous) => !previous)}
className="rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-2.5 py-1 text-xs font-bold text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
{drafts.length}/10
</button>
</div>
{showDraftList ? (
<div className="max-h-72 overflow-y-auto p-2">
{drafts.length === 0 ? (
<div className="flex min-h-24 items-center justify-center text-sm text-[var(--color-text-subtle)]">
.
</div>
) : (
<ul className="space-y-1">
{drafts.map((draft) => (
<li key={draft.id} className="group rounded-lg px-3 py-2 transition hover:bg-[var(--card-bg)]">
<div className="flex items-start justify-between gap-2">
<button type="button" onClick={() => handleLoadDraft(draft)} className="min-w-0 flex-1 text-left">
<p className="line-clamp-1 text-sm font-semibold text-[var(--color-text)]">{draft.title}</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">{draft.savedAt}</p>
</button>
<button
type="button"
onClick={() => handleDeleteDraft(draft.id)}
className="rounded p-1.5 text-[var(--color-text-subtle)] transition hover:bg-[var(--color-danger-soft)] hover:text-red-500"
title="삭제"
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
) : (
<div className="p-4 text-xs leading-5 text-[var(--color-text-muted)]">
.
</div>
)}
</Surface>
</aside>
</div>
</WindowSurface>
{draftToLoad && (
<DraftLoadDialog
draft={draftToLoad}
onCancel={() => setDraftToLoad(null)}
onConfirm={confirmLoadDraft}
/>
)}
</main>
);
}

View File

@@ -0,0 +1,671 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { clsx } from 'clsx';
import {
ChevronLeft,
ChevronRight,
Edit2,
Eye,
FileText,
Loader2,
Search,
Trash2,
X,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { deletePost, getPosts } from '@/api/posts';
import WindowSurface from '@/components/ui/WindowSurface';
import { Category, PageMeta, Post, PostListResponse } from '@/types';
const PAGE_SIZE = 10;
const sortOptions = [
{ value: 'createdAt,desc', label: '최신순' },
{ value: 'createdAt,asc', label: '오래된순' },
{ value: 'viewCount,desc', label: '조회수순' },
];
const emptyPageMeta: PageMeta = {
totalPages: 0,
totalElements: 0,
number: 0,
last: true,
};
const formatDate = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
};
type CategoryOption = {
id: number;
name: string;
label: string;
};
const flattenCategoryOptions = (categories: Category[] = [], depth = 0): CategoryOption[] => {
return categories.flatMap((category) => [
{
id: category.id,
name: category.name,
label: `${' '.repeat(depth)}${depth > 0 ? '- ' : ''}${category.name}`,
},
...flattenCategoryOptions(category.children || [], depth + 1),
]);
};
const getPostListMeta = (data?: PostListResponse): PageMeta => {
if (!data) return emptyPageMeta;
return {
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
number: data.page?.number ?? data.number ?? 0,
last: data.page?.last ?? data.last,
};
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
}
if (error instanceof Error) return `${fallback}: ${error.message}`;
return fallback;
};
function DeletePostDialog({
post,
isDeleting,
onCancel,
onConfirm,
}: {
post: Post;
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
<div>
<h3 className="text-lg font-bold text-gray-950"> </h3>
<p className="mt-1 text-sm leading-6 text-gray-500">
. ?
</p>
</div>
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
aria-label="삭제 확인 닫기"
>
<X size={18} />
</button>
</div>
<div className="p-5">
<div className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
<p className="line-clamp-2 text-sm font-semibold text-red-950">{post.title}</p>
<p className="mt-1 text-xs text-red-700">
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
</p>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
>
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
</button>
</div>
</div>
</div>
</div>
);
}
function BulkDeletePostsDialog({
posts,
isDeleting,
onCancel,
onConfirm,
}: {
posts: Post[];
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
<div className="w-full max-w-lg rounded-xl border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
<div>
<h3 className="text-lg font-bold text-gray-950"> </h3>
<p className="mt-1 text-sm leading-6 text-gray-500">
{posts.length.toLocaleString()} . .
</p>
</div>
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
aria-label="대량 삭제 확인 닫기"
>
<X size={18} />
</button>
</div>
<div className="p-5">
<div className="max-h-52 overflow-y-auto rounded-lg border border-red-100 bg-red-50">
{posts.map((post) => (
<div key={post.id} className="border-b border-red-100 px-4 py-3 last:border-b-0">
<p className="line-clamp-1 text-sm font-semibold text-red-950">{post.title}</p>
<p className="mt-1 text-xs text-red-700">
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
</p>
</div>
))}
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
>
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
</button>
</div>
</div>
</div>
</div>
);
}
export default function AdminPostsPanel() {
const queryClient = useQueryClient();
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState('1');
const [keywordInput, setKeywordInput] = useState('');
const [keyword, setKeyword] = useState('');
const [sort, setSort] = useState(sortOptions[0].value);
const [categoryName, setCategoryName] = useState('');
const [selectedPostIds, setSelectedPostIds] = useState<Set<number>>(() => new Set());
const [deleteTarget, setDeleteTarget] = useState<Post | null>(null);
const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['posts', 'admin', 'management', { page, keyword, sort, categoryName }],
queryFn: () => getPosts({
page,
size: PAGE_SIZE,
keyword: keyword || undefined,
category: categoryName || undefined,
sort,
}),
retry: 0,
});
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
retry: 0,
});
const posts = useMemo(() => data?.content ?? [], [data?.content]);
const meta = getPostListMeta(data);
const displayTotalPages = Math.max(meta.totalPages, 1);
const isLastPage = meta.last ?? (page + 1 >= displayTotalPages);
const categoryOptions = useMemo(() => flattenCategoryOptions(categories), [categories]);
const selectedPosts = useMemo(
() => posts.filter((post) => selectedPostIds.has(post.id)),
[posts, selectedPostIds],
);
const allPageSelected = posts.length > 0 && posts.every((post) => selectedPostIds.has(post.id));
const resetSelection = () => {
setSelectedPostIds(new Set());
};
const goToPage = (nextPage: number) => {
const boundedPage = Math.min(Math.max(nextPage, 0), displayTotalPages - 1);
setPage(boundedPage);
setPageInput(String(boundedPage + 1));
resetSelection();
};
const deleteMutation = useMutation({
mutationFn: deletePost,
onSuccess: async () => {
const deletedPost = deleteTarget;
await queryClient.invalidateQueries({ queryKey: ['posts'] });
if (deletedPost?.slug) {
queryClient.removeQueries({ queryKey: ['post', deletedPost.slug] });
}
if (posts.length === 1 && page > 0) {
goToPage(page - 1);
}
resetSelection();
setDeleteTarget(null);
toast.success('게시글이 삭제되었습니다.');
},
onError: (error) => toast.error(getErrorMessage(error, '게시글 삭제 실패')),
});
const bulkDeleteMutation = useMutation({
mutationFn: async (targets: Post[]) => {
await Promise.all(targets.map((post) => deletePost(post.id)));
return targets;
},
onSuccess: async (deletedPosts) => {
await queryClient.invalidateQueries({ queryKey: ['posts'] });
deletedPosts.forEach((post) => {
if (post.slug) queryClient.removeQueries({ queryKey: ['post', post.slug] });
});
if (deletedPosts.length >= posts.length && page > 0) {
goToPage(page - 1);
} else {
resetSelection();
}
setIsBulkDeleteOpen(false);
toast.success(`게시글 ${deletedPosts.length.toLocaleString()}개가 삭제되었습니다.`);
},
onError: (error) => toast.error(getErrorMessage(error, '게시글 대량 삭제 실패')),
});
const handleSearch = (event: React.FormEvent) => {
event.preventDefault();
setPage(0);
setPageInput('1');
resetSelection();
setKeyword(keywordInput.trim());
};
const handleClearSearch = () => {
setKeywordInput('');
setKeyword('');
setPage(0);
setPageInput('1');
resetSelection();
};
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSort(event.target.value);
setPage(0);
setPageInput('1');
resetSelection();
};
const handleCategoryChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setCategoryName(event.target.value);
setPage(0);
setPageInput('1');
resetSelection();
};
const handlePageJump = (event: React.FormEvent) => {
event.preventDefault();
const parsedPage = Number(pageInput);
if (!Number.isFinite(parsedPage) || parsedPage < 1) {
toast.error('이동할 페이지 번호를 입력해주세요.');
setPageInput(String(page + 1));
return;
}
goToPage(parsedPage - 1);
};
const handleConfirmDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id);
};
const handleConfirmBulkDelete = () => {
if (selectedPosts.length === 0) return;
bulkDeleteMutation.mutate(selectedPosts);
};
const togglePostSelection = (id: number) => {
setSelectedPostIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(id)) {
nextIds.delete(id);
} else {
nextIds.add(id);
}
return nextIds;
});
};
const togglePageSelection = () => {
setSelectedPostIds((currentIds) => {
const nextIds = new Set(currentIds);
if (allPageSelected) {
posts.forEach((post) => nextIds.delete(post.id));
} else {
posts.forEach((post) => nextIds.add(post.id));
}
return nextIds;
});
};
return (
<WindowSurface title="Posts" subtitle="Management" bodyClassName="p-5">
<div className="mb-5 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
<FileText size={19} />
</h2>
<p className="mt-1 text-sm text-gray-500">, , .</p>
</div>
<Link
href="/admin/posts/new"
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-gray-800"
>
<Edit2 size={15} />
</Link>
</div>
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<form onSubmit={handleSearch} className="flex min-w-0 flex-1 items-center gap-2">
<label className="relative block min-w-0 flex-1">
<span className="sr-only"> </span>
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
<input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
placeholder="제목 또는 본문 검색"
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2 pl-9 pr-3 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-100"
/>
</label>
<button
type="submit"
className="inline-flex shrink-0 items-center justify-center rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
>
</button>
{keyword && (
<button
type="button"
onClick={handleClearSearch}
className="inline-flex shrink-0 items-center justify-center rounded-full px-3 py-2 text-sm font-semibold text-gray-500 transition hover:bg-gray-100 hover:text-gray-700"
>
</button>
)}
</form>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
<div className="text-sm text-gray-500">
{keyword ? (
<>
<span className="font-semibold text-gray-900">{keyword}</span> {' '}
</>
) : null}
<span className="font-semibold text-gray-900">{meta.totalElements.toLocaleString()}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedPostIds.size > 0 && (
<button
type="button"
onClick={() => setIsBulkDeleteOpen(true)}
disabled={isFetching}
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-100 bg-red-50 px-3 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 size={15} />
{selectedPostIds.size.toLocaleString()}
</button>
)}
<label className="flex items-center gap-2">
<span className="sr-only"> </span>
<select
value={categoryName}
onChange={handleCategoryChange}
className="rounded-full border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
>
<option value=""> </option>
{categoryOptions.map((category) => (
<option key={category.id} value={category.name}>
{category.label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="sr-only"> </span>
<select
value={sort}
onChange={handleSortChange}
className="rounded-full border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="hidden grid-cols-[44px_minmax(0,1fr)_130px_110px_84px_132px] gap-3 border-b border-gray-100 bg-gray-50 px-4 py-3 text-xs font-bold text-gray-500 md:grid">
<span className="flex items-center justify-center">
<input
type="checkbox"
checked={allPageSelected}
onChange={togglePageSelection}
disabled={posts.length === 0 || isFetching}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="현재 페이지 게시글 전체 선택"
/>
</span>
<span></span>
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
</div>
{isLoading ? (
<div className="flex min-h-64 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : posts.length > 0 ? (
<div className={clsx('divide-y divide-gray-100', isFetching && 'opacity-70')}>
{posts.map((post) => (
<div
key={post.id}
className="grid gap-3 px-4 py-4 transition hover:bg-gray-50 md:grid-cols-[44px_minmax(0,1fr)_130px_110px_84px_132px] md:items-center"
>
<label className="flex items-start md:items-center md:justify-center">
<span className="sr-only">{post.title} </span>
<input
type="checkbox"
checked={selectedPostIds.has(post.id)}
onChange={() => togglePostSelection(post.id)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 md:mt-0"
/>
</label>
<div className="min-w-0">
<Link
href={`/posts/${post.slug}`}
className="line-clamp-2 text-sm font-semibold text-gray-950 transition hover:text-blue-600"
>
{post.title}
</Link>
<p className="mt-1 text-xs text-gray-400 md:hidden">
{post.categoryName || '미분류'} · {formatDate(post.createdAt)} · {post.viewCount.toLocaleString()}
</p>
</div>
<span className="hidden truncate text-sm text-gray-500 md:block">{post.categoryName || '미분류'}</span>
<span className="hidden text-sm text-gray-500 md:block">{formatDate(post.createdAt)}</span>
<span className="hidden text-right text-sm tabular-nums text-gray-500 md:block">
{post.viewCount.toLocaleString()}
</span>
<div className="flex items-center justify-end gap-1">
<Link
href={`/posts/${post.slug}`}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
aria-label={`${post.title} 보기`}
>
<Eye size={16} />
</Link>
<Link
href={`/admin/posts/${post.slug}/edit`}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-blue-50 hover:text-blue-600"
aria-label={`${post.title} 수정`}
>
<Edit2 size={16} />
</Link>
<button
type="button"
onClick={() => setDeleteTarget(post)}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-red-50 hover:text-red-600"
aria-label={`${post.title} 삭제`}
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-16 text-center">
<FileText className="mx-auto mb-3 text-gray-300" size={42} />
<p className="text-sm font-semibold text-gray-700">
{keyword ? '검색 조건에 맞는 게시글이 없습니다.' : '아직 작성된 글이 없습니다.'}
</p>
<p className="mt-2 text-sm text-gray-400"> .</p>
</div>
)}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-gray-500">
{isFetching && !isLoading ? '목록을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
</div>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => goToPage(page - 1)}
disabled={page === 0 || isFetching}
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft size={15} />
</button>
<form onSubmit={handlePageJump} className="flex items-center gap-2">
<input
type="number"
min={1}
max={displayTotalPages}
value={pageInput}
onChange={(event) => setPageInput(event.target.value)}
disabled={isFetching}
className="h-9 w-20 rounded-full border border-gray-200 px-3 text-center text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50"
aria-label="이동할 페이지 번호"
/>
<button
type="submit"
disabled={isFetching}
className="inline-flex h-9 items-center justify-center rounded-full border border-gray-200 px-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</form>
<button
type="button"
onClick={() => goToPage(page + 1)}
disabled={isLastPage || isFetching}
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronRight size={15} />
</button>
</div>
</div>
{deleteTarget && (
<DeletePostDialog
post={deleteTarget}
isDeleting={deleteMutation.isPending}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleConfirmDelete}
/>
)}
{isBulkDeleteOpen && selectedPosts.length > 0 && (
<BulkDeletePostsDialog
posts={selectedPosts}
isDeleting={bulkDeleteMutation.isPending}
onCancel={() => setIsBulkDeleteOpen(false)}
onConfirm={handleConfirmBulkDelete}
/>
)}
</WindowSurface>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useRef, useState } from 'react';
import Image from 'next/image';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { Camera, Loader2, Save, UserRound } from 'lucide-react';
import { getProfile, updateProfile } from '@/api/profile';
import { uploadImage } from '@/api/image';
import WindowSurface from '@/components/ui/WindowSurface';
import { Profile, ProfileUpdateRequest } from '@/types';
const defaultProfile: Profile = {
name: 'Dev Park',
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
githubUrl: 'https://github.com',
email: 'user@example.com',
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return `${fallback}: ${response.data.message}`;
}
if (error instanceof Error) return `${fallback}: ${error.message}`;
return fallback;
};
function toProfileForm(profile: Profile): ProfileUpdateRequest {
return {
name: profile.name,
bio: profile.bio,
imageUrl: profile.imageUrl || '',
githubUrl: profile.githubUrl || '',
email: profile.email || '',
};
}
function ProfileForm({ profile }: { profile: Profile }) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [form, setForm] = useState<ProfileUpdateRequest>(() => toProfileForm(profile));
const [isUploading, setIsUploading] = useState(false);
const updateMutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
toast.success('프로필이 저장되었습니다.');
},
onError: (error) => toast.error(getErrorMessage(error, '프로필 저장 실패')),
});
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
const uploadToast = toast.loading('이미지 업로드 중...');
try {
const response = await uploadImage(file);
if (response.code === 'SUCCESS' && response.data) {
setForm((previous) => ({ ...previous, imageUrl: response.data }));
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
} else {
toast.error(response.message || '이미지 업로드 실패', { id: uploadToast });
}
} catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
updateMutation.mutate({
...form,
name: form.name.trim(),
bio: form.bio.trim(),
githubUrl: form.githubUrl?.trim(),
email: form.email?.trim(),
imageUrl: form.imageUrl?.trim(),
});
};
const isSaving = updateMutation.isPending || isUploading;
return (
<form onSubmit={handleSubmit} className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="flex flex-col items-center rounded-lg border border-gray-200 bg-gray-50 p-5">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="group relative h-28 w-28 overflow-hidden rounded-full border border-gray-200 bg-white shadow-sm"
aria-label="프로필 이미지 변경"
>
<Image
src={form.imageUrl || defaultProfile.imageUrl!}
alt="프로필 미리보기"
fill
sizes="112px"
className="object-cover"
unoptimized
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100">
{isUploading ? <Loader2 className="animate-spin" size={24} /> : <Camera size={24} />}
</span>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isSaving}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isSaving}
className="mt-4 rounded-full border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-blue-200 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
</div>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="text-xs font-bold text-gray-500"></span>
<input
value={form.name}
onChange={(event) => setForm((previous) => ({ ...previous, name: event.target.value }))}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
required
/>
</label>
<label className="block">
<span className="text-xs font-bold text-gray-500">Email</span>
<input
type="email"
value={form.email || ''}
onChange={(event) => setForm((previous) => ({ ...previous, email: event.target.value }))}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
/>
</label>
</div>
<label className="block">
<span className="text-xs font-bold text-gray-500">Github URL</span>
<input
type="url"
value={form.githubUrl || ''}
onChange={(event) => setForm((previous) => ({ ...previous, githubUrl: event.target.value }))}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
/>
</label>
<label className="block">
<span className="text-xs font-bold text-gray-500"></span>
<textarea
value={form.bio}
onChange={(event) => setForm((previous) => ({ ...previous, bio: event.target.value }))}
className="mt-1 h-28 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm leading-6 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
required
/>
</label>
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-300"
>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
</button>
</div>
</div>
</form>
);
}
export default function AdminProfilePanel() {
const { data: profile, isLoading } = useQuery({
queryKey: ['profile'],
queryFn: getProfile,
retry: 0,
});
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
const formKey = [
displayProfile.name,
displayProfile.bio,
displayProfile.imageUrl,
displayProfile.githubUrl,
displayProfile.email,
].join('|');
return (
<WindowSurface title="Profile" subtitle="Public identity" bodyClassName="p-5">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
<UserRound size={19} />
</h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
</div>
{isLoading ? (
<div className="flex min-h-56 items-center justify-center rounded-lg bg-gray-50">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : (
<ProfileForm key={formKey} profile={displayProfile} />
)}
</WindowSurface>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import toast from 'react-hot-toast';
import { BarChart3, FileText, FolderTree, Loader2, MessageSquareText, UserRound } from 'lucide-react';
import { clsx } from 'clsx';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore';
const navItems = [
{ href: '/admin', label: '대시보드', icon: BarChart3, exact: true },
{ href: '/admin/posts', label: '게시글', icon: FileText },
{ href: '/admin/comments', label: '댓글', icon: MessageSquareText },
{ href: '/admin/categories', label: '카테고리', icon: FolderTree },
{ href: '/admin/profile', label: '프로필', icon: UserRound },
];
const getCurrentPath = (pathname: string) => {
if (typeof window === 'undefined') return pathname || '/admin';
return `${window.location.pathname}${window.location.search}`;
};
const getLoginRedirectPath = (pathname: string) => {
const currentPath = getCurrentPath(pathname);
return `/login?redirect=${encodeURIComponent(currentPath || '/admin')}`;
};
export default function AdminRouteShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
useEffect(() => {
if (!_hasHydrated) return;
if (!isLoggedIn) {
router.replace(getLoginRedirectPath(pathname));
return;
}
if (!isAdmin) {
toast.error('관리자 권한이 필요합니다.');
router.replace('/');
}
}, [_hasHydrated, isAdmin, isLoggedIn, pathname, router]);
if (!_hasHydrated || !isAdmin) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div>
);
}
return (
<div className="mx-auto w-full max-w-[1400px] space-y-6 px-0 py-4 md:py-6">
<WindowSurface title="System Settings" subtitle="Admin Console" bodyClassName="p-2">
<nav
className="flex gap-2 overflow-x-auto rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-1 shadow-[var(--shadow-card)] backdrop-blur-[18px]"
aria-label="관리자 메뉴"
>
{navItems.map((item) => {
const Icon = item.icon;
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={clsx(
'inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-4 text-sm font-semibold transition',
isActive
? 'bg-[var(--card-bg-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]',
)}
>
<Icon size={16} />
{item.label}
</Link>
);
})}
</nav>
</WindowSurface>
{children}
</div>
);
}

View File

@@ -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-[var(--card-bg)]"
>
<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>
);
}

View File

@@ -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(--card-border)] bg-[var(--card-bg)] shadow-[var(--shadow-card)]">
<div className="min-w-[680px]">
<div className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 bg-[var(--window-titlebar)] px-4 py-3 text-xs font-bold text-[var(--color-text-subtle)]">
<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(--card-border)] px-4 py-3 text-sm transition hover:bg-[var(--card-bg-strong)]"
>
<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>
);
}

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

View File

@@ -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-[var(--card-bg)]"
>
<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>
);
}

View File

@@ -0,0 +1,101 @@
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);
const hasTraffic = points.some((point) => point.views > 0);
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)]">
{isFallback
? '대시보드 집계 전에는 게시글 누적 조회수로 임시 흐름을 표시합니다.'
: '최근 기간별 조회수 흐름을 확인합니다.'}
</p>
</div>
<SegmentedControl
ariaLabel="트래픽 기간"
options={rangeOptions}
value={range}
onChange={onRangeChange}
/>
</div>
{points.length > 0 ? (
<>
<div className="h-64 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
<div className="flex h-full min-w-full items-stretch gap-1.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="flex min-h-0 w-full flex-1 items-end">
<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()}`}
/>
</div>
<span className="hidden h-4 text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
{formatDay(point.date)}
</span>
</div>
);
})}
</div>
</div>
{isFallback && (
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
{hasTraffic
? '임시 그래프는 글 발행일과 누적 조회수를 기준으로 계산됩니다.'
: '집계 API가 연결되면 실제 일별 조회수로 대체됩니다.'}
</p>
)}
</>
) : (
<EmptyState
title="통계 API 연결 전"
description={isFallback ? '백엔드 집계가 준비되면 기간별 그래프가 표시됩니다.' : '표시할 트래픽 데이터가 없습니다.'}
/>
)}
</Surface>
);
}

View File

@@ -0,0 +1,347 @@
'use client';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { Chess as ChessGame, type Color, type PieceSymbol, type Square } from 'chess.js';
import { clsx } from 'clsx';
import { ChessColor } from '@/types';
export type MoveSquares = {
from: Square;
to: Square;
};
type BoardPiece = {
square: Square;
color: Color;
type: PieceSymbol;
};
type VisualPiece = BoardPiece & {
id: string;
};
interface ChessBoardProps {
fen: string;
orientation?: ChessColor;
selectedSquare?: Square | null;
legalTargets?: Set<Square>;
lastMoveSquares?: MoveSquares | null;
disabled?: boolean;
isDraggableSquare?: (square: Square) => boolean;
onSquareClick?: (square: Square) => void;
onSquareDrop?: (from: Square, to: Square) => void;
}
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const;
const WHITE_RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const;
const BLACK_RANKS = [1, 2, 3, 4, 5, 6, 7, 8] as const;
export const BOARD_SQUARES = WHITE_RANKS.flatMap((rank) =>
FILES.map((file) => `${file}${rank}` as Square),
);
const PIECE_SYMBOLS: Record<Color, Record<PieceSymbol, string>> = {
w: {
k: '♔',
q: '♕',
r: '♖',
b: '♗',
n: '♘',
p: '♙',
},
b: {
k: '♚',
q: '♛',
r: '♜',
b: '♝',
n: '♞',
p: '♟',
},
};
const PIECE_NAMES: Record<PieceSymbol, string> = {
k: '킹',
q: '퀸',
r: '룩',
b: '비숍',
n: '나이트',
p: '폰',
};
const colorLabel = (color: Color) => (color === 'w' ? '백' : '흑');
const createGame = (fen: string) => {
try {
return new ChessGame(fen);
} catch {
return new ChessGame();
}
};
const getBoardSquares = (orientation: ChessColor) => {
const ranks = orientation === 'black' ? BLACK_RANKS : WHITE_RANKS;
const files = orientation === 'black' ? [...FILES].reverse() : FILES;
return ranks.flatMap((rank) => files.map((file) => `${file}${rank}` as Square));
};
const getPieces = (game: ChessGame): BoardPiece[] =>
BOARD_SQUARES.flatMap((square) => {
const piece = game.get(square);
if (!piece) return [];
return [{ square, color: piece.color, type: piece.type }];
});
const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => {
if (!piece) return `${square} 빈 칸`;
return `${square} ${colorLabel(piece.color)} ${PIECE_NAMES[piece.type]}`;
};
const isLightSquare = (square: Square) => {
const fileIndex = FILES.indexOf(square[0] as (typeof FILES)[number]);
const rank = Number(square[1]);
return (fileIndex + rank) % 2 === 0;
};
const getSquarePosition = (square: Square, orientation: ChessColor): CSSProperties => {
const fileIndex = FILES.indexOf(square[0] as (typeof FILES)[number]);
const rank = Number(square[1]);
const x = orientation === 'black' ? 7 - fileIndex : fileIndex;
const y = orientation === 'black' ? rank - 1 : 8 - rank;
return {
transform: `translate(${x * 100}%, ${y * 100}%)`,
};
};
const samePiece = (a: BoardPiece, b: BoardPiece) => a.color === b.color && a.type === b.type;
const canRepresentMove = (fromPiece: VisualPiece, toPiece: BoardPiece) =>
fromPiece.color === toPiece.color && (fromPiece.type === toPiece.type || fromPiece.type === 'p');
const createPieceId = (piece: BoardPiece, index: number) =>
`${piece.color}-${piece.type}-${piece.square}-${index}`;
const createVisualPieces = (pieces: BoardPiece[]) => pieces.map((piece, index) => ({ ...piece, id: createPieceId(piece, index) }));
const reconcileVisualPieces = (
previousPieces: VisualPiece[],
nextPieces: BoardPiece[],
lastMoveSquares?: MoveSquares | null,
) => {
if (!lastMoveSquares) return createVisualPieces(nextPieces);
const nextBySquare = new Map(nextPieces.map((piece) => [piece.square, piece]));
const movedPieceTarget = nextBySquare.get(lastMoveSquares.to);
if (!movedPieceTarget) return createVisualPieces(nextPieces);
const usedVisualIds = new Set<string>();
const usedSquares = new Set<Square>();
const reconciled: VisualPiece[] = [];
const movingPiece =
previousPieces.find(
(piece) => piece.square === lastMoveSquares.from && canRepresentMove(piece, movedPieceTarget),
) ?? previousPieces.find((piece) => piece.square === lastMoveSquares.from);
if (movingPiece) {
reconciled.push({ ...movingPiece, ...movedPieceTarget });
usedVisualIds.add(movingPiece.id);
usedSquares.add(movedPieceTarget.square);
}
previousPieces.forEach((visualPiece) => {
if (usedVisualIds.has(visualPiece.id)) return;
const nextPiece = nextBySquare.get(visualPiece.square);
if (!nextPiece || usedSquares.has(nextPiece.square) || !samePiece(visualPiece, nextPiece)) return;
reconciled.push({ ...visualPiece, ...nextPiece });
usedVisualIds.add(visualPiece.id);
usedSquares.add(nextPiece.square);
});
nextPieces.forEach((nextPiece, index) => {
if (usedSquares.has(nextPiece.square)) return;
reconciled.push({ ...nextPiece, id: createPieceId(nextPiece, previousPieces.length + index) });
});
return reconciled;
};
export const getMoveSquares = (move?: string | null): MoveSquares | null => {
if (!move || move.length < 4) return null;
return {
from: move.slice(0, 2) as Square,
to: move.slice(2, 4) as Square,
};
};
export const toChessJsColor = (color: ChessColor): Color => (color === 'white' ? 'w' : 'b');
export const getTurnLabel = (color: ChessColor) => (color === 'white' ? '백' : '흑');
export default function ChessBoard({
fen,
orientation = 'white',
selectedSquare,
legalTargets,
lastMoveSquares,
disabled = false,
isDraggableSquare,
onSquareClick,
onSquareDrop,
}: ChessBoardProps) {
const game = useMemo(() => createGame(fen), [fen]);
const boardSquares = useMemo(() => getBoardSquares(orientation), [orientation]);
const firstFile = orientation === 'black' ? 'h' : 'a';
const lastRank = orientation === 'black' ? '8' : '1';
const [visualPieces, setVisualPieces] = useState(() => createVisualPieces(getPieces(game)));
const previousFenRef = useRef(fen);
useEffect(() => {
const nextPieces = getPieces(game);
setVisualPieces((previousPieces) => {
if (previousFenRef.current === fen) return previousPieces;
previousFenRef.current = fen;
return reconcileVisualPieces(previousPieces, nextPieces, lastMoveSquares);
});
}, [fen, game, lastMoveSquares]);
return (
<div className="relative aspect-square w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-[18px]">
<div className="relative h-full w-full overflow-hidden rounded-md border border-white/60 bg-[var(--color-surface-strong)] shadow-[0_1px_0_rgb(255_255_255_/_0.46)_inset] dark:border-white/10">
<div className="grid h-full w-full grid-cols-[repeat(8,minmax(0,1fr))] grid-rows-[repeat(8,minmax(0,1fr))]">
{boardSquares.map((square) => {
const piece = game.get(square);
const isLight = isLightSquare(square);
const isSelected = selectedSquare === square;
const isTarget = legalTargets?.has(square) ?? false;
const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square;
const canDrag = !disabled && Boolean(piece) && (isDraggableSquare?.(square) ?? true);
const file = square[0];
const rank = square[1];
return (
<button
key={square}
type="button"
onClick={() => onSquareClick?.(square)}
draggable={canDrag}
onDragStart={(event) => {
if (!canDrag) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', square);
}}
onDragOver={(event) => {
if (!disabled && onSquareDrop) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}}
onDrop={(event) => {
if (disabled || !onSquareDrop) return;
event.preventDefault();
const from = event.dataTransfer.getData('text/plain') as Square;
if (!from || from === square) return;
onSquareDrop(from, square);
}}
className={clsx(
'relative min-h-0 min-w-0 overflow-hidden transition-[filter,box-shadow] duration-150',
isLight ? 'bg-[#edf2f5] dark:bg-[#c8d0d6]' : 'bg-[#7fa18b] dark:bg-[#415d52]',
canDrag && 'cursor-grab active:cursor-grabbing',
!disabled && 'hover:brightness-[1.04] focus-visible:z-20',
disabled && 'cursor-not-allowed',
)}
aria-label={getSquareLabel(square, piece)}
aria-pressed={isSelected}
>
{isLastMoveSquare && (
<span className="absolute inset-1 rounded bg-[var(--color-accent-soft)] shadow-[0_0_0_1px_rgb(255_255_255_/_0.34)_inset]" />
)}
{isSelected && (
<span className="absolute inset-1 rounded border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] shadow-[0_0_0_2px_rgb(255_255_255_/_0.28)_inset]" />
)}
{file === firstFile && (
<span
className={clsx(
'absolute left-1 top-1 text-[9px] font-bold leading-none sm:left-1.5 sm:top-1.5 sm:text-[10px]',
isLight ? 'text-[#7a9a86]' : 'text-white/80',
)}
>
{rank}
</span>
)}
{rank === lastRank && (
<span
className={clsx(
'absolute bottom-1 right-1 text-[9px] font-bold leading-none sm:bottom-1.5 sm:right-1.5 sm:text-[10px]',
isLight ? 'text-[#7a9a86]' : 'text-white/80',
)}
>
{file}
</span>
)}
{isTarget && (
<span
className={clsx(
'absolute z-10 rounded-full transition',
piece
? 'inset-[13%] border-[2px] border-[var(--color-accent)]/70 shadow-[0_0_0_1px_rgb(255_255_255_/_0.28)_inset]'
: 'left-1/2 top-1/2 h-[22%] w-[22%] -translate-x-1/2 -translate-y-1/2 bg-[var(--color-accent)]/42 shadow-[0_1px_3px_rgb(0_0_0_/_0.18)]',
)}
/>
)}
</button>
);
})}
</div>
<div className="pointer-events-none absolute inset-0">
{visualPieces.map((piece) => {
const canDrag = !disabled && (isDraggableSquare?.(piece.square) ?? true);
const isSelected = selectedSquare === piece.square;
const isLastMovePiece = lastMoveSquares?.to === piece.square;
return (
<span
key={piece.id}
className="absolute left-0 top-0 flex h-[12.5%] w-[12.5%] items-center justify-center transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={getSquarePosition(piece.square, orientation)}
aria-hidden="true"
>
<span
className={clsx(
'flex h-full w-full select-none items-center justify-center font-serif text-[2.5rem] font-bold leading-none transition-[transform,filter] duration-200 sm:text-[3rem] md:text-[3.6rem]',
canDrag && 'cursor-grab active:cursor-grabbing',
isSelected && 'scale-110 -translate-y-0.5 brightness-110',
isLastMovePiece && 'scale-[1.04]',
piece.color === 'w'
? 'text-[#fffdf7] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.48),0_0_1px_rgb(0_0_0_/_0.72),0_5px_9px_rgb(15_23_42_/_0.2)]'
: 'text-[#151922] [text-shadow:0_1px_0_rgb(255_255_255_/_0.72),0_0_1px_rgb(255_255_255_/_0.82),0_5px_9px_rgb(15_23_42_/_0.2)]',
)}
>
{PIECE_SYMBOLS[piece.color][piece.type]}
</span>
</span>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,535 @@
'use client';
import { useMemo, useState, type ReactNode } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Chess as ChessGame, type Move, type Square } from 'chess.js';
import { clsx } from 'clsx';
import {
AlertCircle,
ChevronLeft,
Clipboard,
Flag,
History,
Home,
Loader2,
PlusCircle,
RotateCcw,
Swords,
Undo2,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { getChessGame, postChessMove, resignChessGame, undoChessMove } from '@/api/chess';
import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor, type MoveSquares } from '@/components/chess/ChessBoard';
import { getChessErrorMessage, getChessOutcomeLabel, isAuthError, outcomeBadgeTones } from '@/components/chess/chessUi';
import StatusBadge from '@/components/ui/StatusBadge';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore';
import { ChessColor, ChessGameResponse } from '@/types';
interface ChessGamePlayClientProps {
gameId: string;
}
const INITIAL_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
const createGame = (fen: string) => {
try {
return new ChessGame(fen);
} catch {
return null;
}
};
const getPreferredMove = (moves: Move[], to: Square) => {
const candidates = moves.filter((move) => move.to === to);
return candidates.find((move) => move.promotion === 'q') ?? candidates[0] ?? null;
};
const toUciMove = (move: Move) => `${move.from}${move.to}${move.promotion ?? ''}`;
const hasPlayerMoved = (moves: string[], playerColor: ChessColor) => {
return moves.some((_, index) => {
const side = index % 2 === 0 ? 'white' : 'black';
return side === playerColor;
});
};
function ChessPageFrame({ children }: { children: ReactNode }) {
return (
<main className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 px-0 py-3 md:py-6">
{children}
</main>
);
}
function LoadingState({ message = '대국을 불러오는 중입니다.' }: { message?: string }) {
return (
<ChessPageFrame>
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={30} />
<p className="text-sm font-semibold text-[var(--color-text)]">{message}</p>
</WindowSurface>
</ChessPageFrame>
);
}
function LoginRequired() {
return (
<ChessPageFrame>
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-amber-500" size={30} />
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]"> .</h1>
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
Maia .
</p>
<Link
href="/login?redirect=/chess"
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
>
</Link>
</WindowSurface>
</ChessPageFrame>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<ChessPageFrame>
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-red-500" size={30} />
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]"> .</h1>
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">{message}</p>
<button
type="button"
onClick={onRetry}
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<RotateCcw size={16} />
</button>
</WindowSurface>
</ChessPageFrame>
);
}
function GameInfoPanel({
game,
isPlayerTurn,
pending,
canUndo,
canResign,
onCopyPgn,
onUndo,
onResign,
}: {
game: ChessGameResponse;
isPlayerTurn: boolean;
pending: boolean;
canUndo: boolean;
canResign: boolean;
onCopyPgn: () => void;
onUndo: () => void;
onResign: () => void;
}) {
const gameEnded = game.status !== 'IN_PROGRESS';
return (
<WindowSurface title="Game Info" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
<div className="mb-4 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
<dt className="text-xs text-[var(--color-text-subtle)]"></dt>
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{game.rating}</dd>
</div>
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
<dt className="text-xs text-[var(--color-text-subtle)]"></dt>
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">Maia {game.model}</dd>
</div>
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
<dt className="text-xs text-[var(--color-text-subtle)]"> </dt>
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{getTurnLabel(game.playerColor)}</dd>
</div>
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
<dt className="text-xs text-[var(--color-text-subtle)]"> </dt>
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{getTurnLabel(game.turn)}</dd>
</div>
</dl>
<div
className={clsx(
'mt-4 rounded-lg border px-3 py-3 text-sm font-semibold leading-6',
gameEnded && 'border-[var(--color-line)] bg-black/[0.025] text-[var(--color-text-muted)] dark:bg-white/[0.06]',
!gameEnded && isPlayerTurn && !pending && 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
!gameEnded && (!isPlayerTurn || pending) && 'border-[var(--color-line)] bg-black/[0.025] text-[var(--color-text-muted)] dark:bg-white/[0.06]',
)}
>
{gameEnded && '종료된 대국입니다.'}
{!gameEnded && pending && '요청을 처리하는 중입니다.'}
{!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'}
{!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3 xl:grid-cols-1">
{game.status === 'IN_PROGRESS' && (
<>
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Undo2 size={16} />
</button>
<button
type="button"
onClick={onResign}
disabled={!canResign}
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-3 text-sm font-semibold text-red-700 shadow-[var(--shadow-control)] transition hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300"
>
<Flag size={16} />
</button>
</>
)}
<Link
href="/chess"
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
>
<PlusCircle size={16} />
</Link>
</div>
<div className="mt-4 rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-3 py-3 dark:bg-white/[0.06]">
<p className="text-xs font-semibold text-[var(--color-text-subtle)]"> Maia </p>
<p className="mt-1 break-words font-mono text-sm font-semibold text-[var(--color-text)]">
{game.maiaMove || '아직 없음'}
</p>
</div>
<div className="mt-5">
<div className="mb-2 flex min-w-0 items-center justify-between gap-3">
<p className="text-sm font-bold text-[var(--color-text)]">PGN</p>
<button
type="button"
onClick={onCopyPgn}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-xs font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<Clipboard size={14} />
</button>
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-3 text-xs leading-5 text-[var(--color-text-muted)]">
{game.pgn || 'PGN이 아직 없습니다.'}
</pre>
</div>
</WindowSurface>
);
}
export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps) {
const queryClient = useQueryClient();
const { isLoggedIn, _hasHydrated } = useAuthStore();
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
const [optimisticFen, setOptimisticFen] = useState<string | null>(null);
const [optimisticMoveSquares, setOptimisticMoveSquares] = useState<MoveSquares | null>(null);
const gameQueryKey = useMemo(() => ['chess-game', gameId] as const, [gameId]);
const gameQuery = useQuery({
queryKey: gameQueryKey,
queryFn: () => getChessGame(gameId),
enabled: _hasHydrated && isLoggedIn,
retry: 0,
});
const syncUpdatedGame = async (updatedGame: ChessGameResponse) => {
queryClient.setQueryData(gameQueryKey, updatedGame);
setOptimisticFen(null);
setOptimisticMoveSquares(null);
setSelectedSquare(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['chess-games'] }),
queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }),
]);
};
const moveMutation = useMutation({
mutationFn: (move: string) => postChessMove(gameId, { move }),
onSuccess: async (updatedGame) => {
await syncUpdatedGame(updatedGame);
},
onError: (error) => {
setOptimisticFen(null);
setOptimisticMoveSquares(null);
setSelectedSquare(null);
toast.error(getChessErrorMessage(error, '봇 응답을 가져오지 못했습니다. 잠시 후 다시 시도해주세요.'));
void gameQuery.refetch();
},
});
const undoMutation = useMutation({
mutationFn: () => undoChessMove(gameId),
onSuccess: async (updatedGame) => {
await syncUpdatedGame(updatedGame);
toast.success('무르기를 적용했습니다.');
},
onError: (error) => {
setOptimisticFen(null);
setOptimisticMoveSquares(null);
setSelectedSquare(null);
toast.error(getChessErrorMessage(error, '무르기를 처리하지 못했습니다.'));
void gameQuery.refetch();
},
});
const resignMutation = useMutation({
mutationFn: () => resignChessGame(gameId),
onSuccess: async (updatedGame) => {
await syncUpdatedGame(updatedGame);
toast.success('기권 처리되었습니다.');
},
onError: (error) => {
setOptimisticFen(null);
setOptimisticMoveSquares(null);
setSelectedSquare(null);
toast.error(getChessErrorMessage(error, '기권을 처리하지 못했습니다.'));
void gameQuery.refetch();
},
});
const game = gameQuery.data;
const isPending = moveMutation.isPending || undoMutation.isPending || resignMutation.isPending;
const currentFen = optimisticFen ?? game?.fen ?? INITIAL_FEN;
const localGame = useMemo(() => createGame(currentFen), [currentFen]);
const playerColor = game ? toChessJsColor(game.playerColor) : null;
const canInteract =
Boolean(game && localGame && playerColor) &&
game?.status === 'IN_PROGRESS' &&
game.turn === game.playerColor &&
!isPending;
const legalMoves = useMemo<Move[]>(() => {
if (!localGame || !selectedSquare || !canInteract) return [];
return localGame.moves({ square: selectedSquare, verbose: true });
}, [canInteract, localGame, selectedSquare]);
const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]);
const serverLastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]);
const lastMoveSquares = optimisticMoveSquares ?? serverLastMoveSquares;
const isOwnTurnPiece = (square: Square) => {
if (!localGame || !playerColor || !canInteract) return false;
const piece = localGame.get(square);
return piece?.color === playerColor && piece.color === localGame.turn();
};
const attemptMove = (from: Square, to: Square) => {
if (!game || !localGame || !canInteract || !isOwnTurnPiece(from)) return;
const movesFromSquare = localGame.moves({ square: from, verbose: true });
const candidate = getPreferredMove(movesFromSquare, to);
if (!candidate) {
setSelectedSquare(null);
toast.error('그 칸으로는 이동할 수 없습니다.');
return;
}
try {
const nextGame = new ChessGame(currentFen);
nextGame.move({
from,
to,
promotion: candidate.promotion,
});
setOptimisticFen(nextGame.fen());
setOptimisticMoveSquares({ from, to });
setSelectedSquare(null);
moveMutation.mutate(toUciMove(candidate));
} catch {
setOptimisticMoveSquares(null);
toast.error('합법적인 수가 아닙니다.');
}
};
const handleSquareClick = (square: Square) => {
if (!localGame || !canInteract) return;
const piece = localGame.get(square);
const isSelectablePiece = piece?.color === playerColor && piece.color === localGame.turn();
if (!selectedSquare) {
if (isSelectablePiece) setSelectedSquare(square);
return;
}
if (selectedSquare === square) {
setSelectedSquare(null);
return;
}
if (legalTargets.has(square)) {
attemptMove(selectedSquare, square);
return;
}
if (isSelectablePiece) {
setSelectedSquare(square);
return;
}
setSelectedSquare(null);
};
const copyPgn = () => {
if (!game?.pgn) {
toast.error('복사할 PGN이 없습니다.');
return;
}
void navigator.clipboard.writeText(game.pgn)
.then(() => toast.success('PGN을 복사했습니다.'))
.catch(() => toast.error('PGN 복사에 실패했습니다.'));
};
const canResign = Boolean(game && game.status === 'IN_PROGRESS' && !isPending);
const canUndo = Boolean(game && game.status === 'IN_PROGRESS' && !isPending && hasPlayerMoved(game.moves, game.playerColor));
const pendingLabel = resignMutation.isPending
? '기권 처리 중'
: undoMutation.isPending
? '무르기 처리 중'
: moveMutation.isPending
? 'Maia 생각 중'
: '';
const handleUndo = () => {
if (!canUndo) return;
undoMutation.mutate();
};
const handleResign = () => {
if (!canResign) return;
if (!window.confirm('정말 기권하시겠습니까? 이 대국은 패배로 종료됩니다.')) return;
resignMutation.mutate();
};
if (!_hasHydrated) return <LoadingState message="로그인 상태를 확인하는 중입니다." />;
if (!isLoggedIn) return <LoginRequired />;
if (gameQuery.isLoading) return <LoadingState />;
if (gameQuery.isError) {
if (isAuthError(gameQuery.error)) return <LoginRequired />;
return (
<ErrorState
message={getChessErrorMessage(gameQuery.error, '대국 상세를 불러오지 못했습니다.')}
onRetry={() => void gameQuery.refetch()}
/>
);
}
if (!game) {
return (
<ErrorState
message="대국 정보를 찾을 수 없습니다."
onRetry={() => void gameQuery.refetch()}
/>
);
}
const isPlayerTurn = game.status === 'IN_PROGRESS' && game.turn === game.playerColor;
return (
<ChessPageFrame>
<section className="flex min-w-0 flex-col gap-3 border-b border-[var(--color-line)] pb-5">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
<Swords size={24} className="shrink-0" />
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
Maia
</h1>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Link
href="/chess"
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<Home size={16} />
</Link>
<Link
href="/chess/history"
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<History size={16} />
</Link>
</div>
</div>
<Link
href="/chess"
className="inline-flex w-fit items-center gap-1.5 text-sm font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-accent)]"
>
<ChevronLeft size={15} />
</Link>
</section>
<section className="grid min-w-0 items-start justify-center gap-5 xl:grid-cols-[minmax(0,30rem)_22rem]">
<WindowSurface
title="Board"
subtitle={isPending ? 'Updating' : isPlayerTurn ? 'Your move' : 'Synced'}
showTrafficLights={false}
bodyClassName="p-2 md:p-3"
>
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(19rem, min(52vw, calc(100svh - 22.5rem)), 30rem))' }}>
<ChessBoard
fen={currentFen}
orientation={game.playerColor}
selectedSquare={selectedSquare}
legalTargets={legalTargets}
lastMoveSquares={lastMoveSquares}
disabled={!canInteract}
isDraggableSquare={isOwnTurnPiece}
onSquareClick={handleSquareClick}
onSquareDrop={attemptMove}
/>
{isPending && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-30 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-full border border-white/25 bg-black/62 px-4 py-2 text-sm font-semibold text-white shadow-[0_12px_28px_rgb(0_0_0_/_0.28)] backdrop-blur-md">
<Loader2 className="animate-spin" size={16} />
{pendingLabel || '처리 중'}
</div>
</div>
)}
</div>
</WindowSurface>
<GameInfoPanel
game={game}
isPlayerTurn={isPlayerTurn}
pending={isPending}
canUndo={canUndo}
canResign={canResign}
onCopyPgn={copyPgn}
onUndo={handleUndo}
onResign={handleResign}
/>
</section>
</ChessPageFrame>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { useMemo, useState, type ReactNode } from 'react';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, Bot, ChevronRight, Home, Loader2, RotateCcw, Swords } from 'lucide-react';
import { getChessGames } from '@/api/chess';
import { getTurnLabel } from '@/components/chess/ChessBoard';
import {
formatChessDateTime,
getChessErrorMessage,
getChessOutcomeLabel,
isAuthError,
outcomeBadgeTones,
type OutcomeFilter,
} from '@/components/chess/chessUi';
import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl';
import StatusBadge from '@/components/ui/StatusBadge';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore';
import { ChessGameSummaryResponse } from '@/types';
const FILTER_OPTIONS: readonly SegmentedControlOption<OutcomeFilter>[] = [
{ label: '전체', value: 'ALL' },
{ label: '진행중', value: 'IN_PROGRESS' },
{ label: '승', value: 'WIN' },
{ label: '패', value: 'LOSS' },
{ label: '무', value: 'DRAW' },
];
function ChessPageFrame({ children }: { children: ReactNode }) {
return (
<main className="mx-auto flex w-full min-w-0 max-w-[1160px] flex-col gap-5 px-0 py-3 md:py-6">
{children}
</main>
);
}
function LoadingState({ message = '대국 기록을 불러오는 중입니다.' }: { message?: string }) {
return (
<ChessPageFrame>
<WindowSurface title="Game History" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={30} />
<p className="text-sm font-semibold text-[var(--color-text)]">{message}</p>
</WindowSurface>
</ChessPageFrame>
);
}
function LoginRequired() {
return (
<ChessPageFrame>
<WindowSurface title="Game History" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-amber-500" size={30} />
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]"> .</h1>
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
.
</p>
<Link
href="/login?redirect=/chess/history"
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
>
</Link>
</WindowSurface>
</ChessPageFrame>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<ChessPageFrame>
<WindowSurface title="Game History" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-red-500" size={30} />
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]"> .</h1>
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">{message}</p>
<button
type="button"
onClick={onRetry}
className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<RotateCcw size={16} />
</button>
</WindowSurface>
</ChessPageFrame>
);
}
function HistoryRow({ game }: { game: ChessGameSummaryResponse }) {
return (
<Link
href={`/chess/play/${game.gameId}`}
className="group grid min-w-0 gap-3 rounded-lg px-3 py-4 transition hover:bg-[var(--card-bg)] md:grid-cols-[minmax(0,1fr)_8rem_8rem_7rem_1.25rem] md:items-center"
>
<div className="min-w-0">
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
</div>
<p className="truncate text-sm font-bold text-[var(--color-text)]">
Maia {game.model} · {game.rating}
</p>
<p className="mt-0.5 truncate text-xs text-[var(--color-text-subtle)]">
{formatChessDateTime(game.updatedAt)}
</p>
</div>
<div className="min-w-0 text-sm md:text-right">
<p className="text-xs text-[var(--color-text-subtle)] md:hidden"> </p>
<p className="font-semibold text-[var(--color-text-muted)]">{getTurnLabel(game.playerColor)}</p>
</div>
<div className="min-w-0 text-sm md:text-right">
<p className="text-xs text-[var(--color-text-subtle)] md:hidden"></p>
<p className="font-semibold tabular-nums text-[var(--color-text-muted)]">{game.movesCount.toLocaleString()}</p>
</div>
<div className="min-w-0 text-sm md:text-right">
<p className="text-xs text-[var(--color-text-subtle)] md:hidden"></p>
<p className="truncate font-semibold text-[var(--color-text-muted)]">{formatChessDateTime(game.createdAt)}</p>
</div>
<ChevronRight size={17} className="hidden text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)] md:block" />
</Link>
);
}
export default function ChessHistoryClient() {
const { isLoggedIn, _hasHydrated } = useAuthStore();
const [filter, setFilter] = useState<OutcomeFilter>('ALL');
const gamesQuery = useQuery({
queryKey: ['chess-games', { page: 0, size: 100, sort: 'updatedAt,desc' }],
queryFn: () => getChessGames({ page: 0, size: 100, sort: 'updatedAt,desc' }),
enabled: _hasHydrated && isLoggedIn,
retry: 0,
});
const games = useMemo(() => gamesQuery.data?.content ?? [], [gamesQuery.data?.content]);
const filteredGames = useMemo(() => {
if (filter === 'ALL') return games;
return games.filter((game) => game.outcome === filter);
}, [filter, games]);
if (!_hasHydrated) return <LoadingState message="로그인 상태를 확인하는 중입니다." />;
if (!isLoggedIn) return <LoginRequired />;
if (gamesQuery.isLoading) return <LoadingState />;
if (gamesQuery.isError) {
if (isAuthError(gamesQuery.error)) return <LoginRequired />;
return (
<ErrorState
message={getChessErrorMessage(gamesQuery.error, '대국 기록을 불러오지 못했습니다.')}
onRetry={() => void gamesQuery.refetch()}
/>
);
}
return (
<ChessPageFrame>
<section className="flex min-w-0 flex-col gap-3 border-b border-[var(--color-line)] pb-5">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
<Swords size={24} className="shrink-0" />
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
</h1>
</div>
<Link
href="/chess"
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<Home size={16} />
</Link>
</div>
<p className="max-w-2xl break-words text-sm leading-6 text-[var(--color-text-muted)]">
Maia . PGN을 .
</p>
</section>
<WindowSurface
title="History"
subtitle={`${filteredGames.length.toLocaleString()} / ${games.length.toLocaleString()} games`}
showTrafficLights={false}
controls={(
<SegmentedControl
ariaLabel="대국 결과 필터"
options={FILTER_OPTIONS}
value={filter}
onChange={setFilter}
className="max-w-[calc(100vw-3rem)] overflow-x-auto"
/>
)}
bodyClassName="p-3 md:p-4"
>
{filteredGames.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{filteredGames.map((game) => (
<HistoryRow key={game.gameId} game={game} />
))}
</div>
) : (
<div className="flex min-h-72 flex-col items-center justify-center text-center">
<Bot className="mb-3 text-[var(--color-accent)]" size={30} />
<p className="text-sm font-semibold text-[var(--color-text)]"> .</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]"> .</p>
</div>
)}
</WindowSurface>
</ChessPageFrame>
);
}

View File

@@ -0,0 +1,362 @@
'use client';
import { type FormEvent, type ReactNode, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bot,
ChevronRight,
Clock3,
History,
Loader2,
LockKeyhole,
Puzzle,
Swords,
Trophy,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { createChessGame, getChessGames, getChessGameStats } from '@/api/chess';
import { getTurnLabel } from '@/components/chess/ChessBoard';
import { formatChessDateTime, getChessErrorMessage, getChessOutcomeLabel, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi';
import MetricCard from '@/components/ui/MetricCard';
import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl';
import StatusBadge from '@/components/ui/StatusBadge';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore';
import { ChessColor, ChessGameCreateRequest, MaiaModel } from '@/types';
type ChessFormState = Required<Pick<ChessGameCreateRequest, 'rating' | 'playerColor' | 'model' | 'temperature' | 'topP'>>;
const COLOR_OPTIONS: readonly SegmentedControlOption<ChessColor>[] = [
{ label: '백', value: 'white' },
{ label: '흑', value: 'black' },
];
const MODEL_OPTIONS: readonly SegmentedControlOption<MaiaModel>[] = [
{ label: '3m', value: '3m' },
{ label: '5m', value: '5m' },
{ label: '23m', value: '23m' },
{ label: '79m', value: '79m' },
];
const DEFAULT_FORM: ChessFormState = {
rating: 1500,
playerColor: 'white',
model: '5m',
temperature: 0.8,
topP: 0.95,
};
const clampRating = (value: number) => Math.min(2600, Math.max(600, value));
function ChessPageFrame({ children }: { children: ReactNode }) {
return (
<main className="mx-auto flex w-full min-w-0 max-w-[1160px] flex-col gap-5 px-0 py-3 md:py-6">
{children}
</main>
);
}
function LoadingGate() {
return (
<ChessPageFrame>
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={30} />
<p className="text-sm font-semibold text-[var(--color-text)]"> .</p>
</WindowSurface>
</ChessPageFrame>
);
}
function LoginRequired() {
return (
<ChessPageFrame>
<WindowSurface title="Maia Chess" showTrafficLights={false} bodyClassName="p-6 md:p-8">
<div className="flex min-h-80 flex-col items-center justify-center text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<LockKeyhole size={30} />
</div>
<h1 className="break-words text-2xl font-bold tracking-normal text-[var(--color-text)]"> .</h1>
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
Maia , , PGN은 .
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Link
href="/login?redirect=/chess"
className="inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
>
<LockKeyhole size={16} />
</Link>
<Link
href="/play/chess"
className="inline-flex h-10 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<Puzzle size={16} />
</Link>
</div>
</div>
</WindowSurface>
</ChessPageFrame>
);
}
function GameSummaryRow({
game,
}: {
game: {
gameId: string;
rating: number;
playerColor: ChessColor;
model: string;
outcome: keyof typeof outcomeLabels;
status: string;
movesCount: number;
updatedAt: string;
};
}) {
return (
<Link
href={`/chess/play/${game.gameId}`}
className="group flex min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-3 transition hover:bg-[var(--card-bg)]"
>
<div className="min-w-0">
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
<span className="text-xs font-semibold text-[var(--color-text-subtle)]">
Maia {game.model} · {game.rating}
</span>
</div>
<p className="truncate text-sm font-semibold text-[var(--color-text)]">
{getTurnLabel(game.playerColor)} · {game.movesCount.toLocaleString()}
</p>
<p className="mt-0.5 truncate text-xs text-[var(--color-text-subtle)]">{formatChessDateTime(game.updatedAt)}</p>
</div>
<ChevronRight size={17} className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
</Link>
);
}
export default function ChessHomeClient() {
const router = useRouter();
const queryClient = useQueryClient();
const { isLoggedIn, _hasHydrated } = useAuthStore();
const [form, setForm] = useState<ChessFormState>(DEFAULT_FORM);
const statsQuery = useQuery({
queryKey: ['chess-game-stats'],
queryFn: getChessGameStats,
enabled: _hasHydrated && isLoggedIn,
retry: 0,
});
const gamesQuery = useQuery({
queryKey: ['chess-games', { page: 0, size: 6, sort: 'updatedAt,desc' }],
queryFn: () => getChessGames({ page: 0, size: 6, sort: 'updatedAt,desc' }),
enabled: _hasHydrated && isLoggedIn,
retry: 0,
});
const createMutation = useMutation({
mutationFn: (payload: ChessGameCreateRequest) => createChessGame(payload),
onSuccess: async (game) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['chess-games'] }),
queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }),
]);
router.push(`/chess/play/${game.gameId}`);
},
onError: (error) => {
toast.error(getChessErrorMessage(error, '대국을 생성하지 못했습니다.'));
},
});
const recentGames = gamesQuery.data?.content ?? [];
const stats = statsQuery.data;
const isLoadingData = statsQuery.isLoading || gamesQuery.isLoading;
const hasDataError = statsQuery.isError || gamesQuery.isError;
const dataErrorMessage = useMemo(() => {
if (statsQuery.error) return getChessErrorMessage(statsQuery.error, '통계를 불러오지 못했습니다.');
if (gamesQuery.error) return getChessErrorMessage(gamesQuery.error, '대국 기록을 불러오지 못했습니다.');
return '';
}, [gamesQuery.error, statsQuery.error]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
createMutation.mutate(form);
};
if (!_hasHydrated) return <LoadingGate />;
if (!isLoggedIn) return <LoginRequired />;
return (
<ChessPageFrame>
<section className="flex min-w-0 flex-col gap-2 border-b border-[var(--color-line)] pb-5">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
<Swords size={24} className="shrink-0" />
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
Maia
</h1>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Link
href="/chess/history"
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<History size={16} />
</Link>
<Link
href="/play/chess"
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]"
>
<Puzzle size={16} />
</Link>
</div>
</div>
<p className="max-w-2xl break-words text-sm leading-6 text-[var(--color-text-muted)]">
Maia3 . FEN으로 .
</p>
</section>
<section className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-5">
<MetricCard label="전체" value={stats?.total ?? (isLoadingData ? null : 0)} icon={<Trophy size={18} />} />
<MetricCard label="진행중" value={stats?.inProgress ?? (isLoadingData ? null : 0)} icon={<Clock3 size={18} />} />
<MetricCard label="승" value={stats?.wins ?? (isLoadingData ? null : 0)} />
<MetricCard label="패" value={stats?.losses ?? (isLoadingData ? null : 0)} />
<MetricCard label="무" value={stats?.draws ?? (isLoadingData ? null : 0)} />
</section>
{hasDataError && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm font-semibold text-red-700 dark:text-red-300">
{dataErrorMessage}
</div>
)}
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]">
<WindowSurface title="New Game" showTrafficLights={false} bodyClassName="p-4 md:p-5">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<label htmlFor="chess-rating" className="text-sm font-semibold text-[var(--color-text)]">
</label>
<span className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-bold tabular-nums text-[var(--color-accent)]">
{form.rating}
</span>
</div>
<input
id="chess-rating"
type="range"
min={600}
max={2600}
step={50}
value={form.rating}
onChange={(event) => setForm((previous) => ({ ...previous, rating: clampRating(Number(event.target.value)) }))}
className="w-full accent-[var(--color-accent)]"
/>
<input
type="number"
min={600}
max={2600}
step={50}
value={form.rating}
onChange={(event) => setForm((previous) => ({ ...previous, rating: clampRating(Number(event.target.value) || DEFAULT_FORM.rating) }))}
className="mt-3 h-10 w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
/>
</div>
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<div className="min-w-0">
<p className="mb-2 text-sm font-semibold text-[var(--color-text)]"> </p>
<SegmentedControl
ariaLabel="내 색상"
options={COLOR_OPTIONS}
value={form.playerColor}
onChange={(playerColor) => setForm((previous) => ({ ...previous, playerColor }))}
className="w-full justify-center"
/>
</div>
<div className="min-w-0">
<p className="mb-2 text-sm font-semibold text-[var(--color-text)]"></p>
<SegmentedControl
ariaLabel="Maia 모델"
options={MODEL_OPTIONS}
value={form.model}
onChange={(model) => setForm((previous) => ({ ...previous, model }))}
className="w-full justify-center"
/>
</div>
</div>
<details className="rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-4 py-3 dark:bg-white/[0.06]">
<summary className="cursor-pointer text-sm font-semibold text-[var(--color-text-muted)]">
</summary>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label className="min-w-0 text-sm font-semibold text-[var(--color-text)]">
Temperature
<input
type="number"
min={0}
max={2}
step={0.05}
value={form.temperature}
onChange={(event) => setForm((previous) => ({ ...previous, temperature: Number(event.target.value) }))}
className="mt-2 h-10 w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
/>
</label>
<label className="min-w-0 text-sm font-semibold text-[var(--color-text)]">
Top P
<input
type="number"
min={0}
max={1}
step={0.01}
value={form.topP}
onChange={(event) => setForm((previous) => ({ ...previous, topP: Number(event.target.value) }))}
className="mt-2 h-10 w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
/>
</label>
</div>
</details>
<button
type="submit"
disabled={createMutation.isPending}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-bold text-white transition hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{createMutation.isPending ? <Loader2 className="animate-spin" size={18} /> : <Bot size={18} />}
</button>
</form>
</WindowSurface>
<WindowSurface title="Recent Games" showTrafficLights={false} as="aside" bodyClassName="p-3 md:p-4">
{gamesQuery.isLoading ? (
<div className="flex min-h-56 flex-col items-center justify-center text-center">
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={26} />
<p className="text-sm font-semibold text-[var(--color-text)]"> .</p>
</div>
) : recentGames.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{recentGames.map((game) => (
<GameSummaryRow key={game.gameId} game={game} />
))}
</div>
) : (
<div className="flex min-h-56 flex-col items-center justify-center text-center">
<Bot className="mb-3 text-[var(--color-accent)]" size={28} />
<p className="text-sm font-semibold text-[var(--color-text)]"> .</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]"> Maia .</p>
</div>
)}
</WindowSurface>
</section>
</ChessPageFrame>
);
}

View File

@@ -0,0 +1,382 @@
'use client';
import type { CSSProperties, ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Chess as ChessGame, type Color, type Move, type Square } from 'chess.js';
import { clsx } from 'clsx';
import {
AlertCircle,
CheckCircle2,
ExternalLink,
Lightbulb,
Loader2,
RotateCcw,
Target,
} from 'lucide-react';
import { getTodayChessPuzzle } from '@/api/chess';
import ChessBoard, { BOARD_SQUARES, type MoveSquares } from '@/components/chess/ChessBoard';
import WindowSurface from '@/components/ui/WindowSurface';
import { type ChessPuzzle } from '@/types';
type FeedbackTone = 'neutral' | 'success' | 'error';
const TIMEZONE = 'Asia/Seoul';
const BOARD_SIZE_STYLE: CSSProperties = {
width: 'min(100%, clamp(19rem, min(52vw, calc(100svh - 22.5rem)), 30rem))',
};
const turnLabel = (color: Color) => (color === 'w' ? '백' : '흑');
const getReadyFeedback = (puzzle: ChessPuzzle): { tone: FeedbackTone; message: string } => {
const game = new ChessGame(puzzle.fen);
return {
tone: 'neutral',
message: `${turnLabel(game.turn())} 차례. 체크메이트를 찾으세요.`,
};
};
const getPreferredMove = (moves: Move[], to: Square) => {
const candidates = moves.filter((move) => move.to === to);
return candidates.find((move) => move.promotion === 'q') ?? candidates[0] ?? null;
};
const formatDate = (value: string) => {
const date = new Date(`${value}T00:00:00+09:00`);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
timeZone: TIMEZONE,
}).format(date);
};
function PageHeader() {
return (
<section className="flex min-w-0 flex-col gap-2 border-b border-[var(--color-line)] pb-5">
<div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
<Target size={23} className="shrink-0" />
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)] md:text-3xl">
</h1>
</div>
<p className="max-w-2xl break-words text-sm leading-6 text-[var(--color-text-muted)]">
.
</p>
</section>
);
}
function ChessPageFrame({ children }: { children: ReactNode }) {
return (
<main className="mx-auto flex w-full min-w-0 max-w-[1160px] flex-col gap-5 px-0 py-3 md:py-6">
{children}
</main>
);
}
function BoardWindow({ children }: { children: ReactNode }) {
return (
<WindowSurface title="Board" showTrafficLights={false} bodyClassName="p-2 md:p-3">
<div className="mx-auto max-w-full" style={BOARD_SIZE_STYLE}>
{children}
</div>
</WindowSurface>
);
}
function LoadingState() {
return (
<ChessPageFrame>
<PageHeader />
<section className="grid min-w-0 items-start justify-center gap-5 lg:grid-cols-[minmax(0,30rem)_20rem]">
<BoardWindow>
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)]">
{BOARD_SQUARES.map((square, index) => (
<div
key={square}
className={clsx(
'aspect-square animate-pulse',
(index + Math.floor(index / 8)) % 2 === 0
? 'bg-[#edf2f5] dark:bg-[#c8d0d6]'
: 'bg-[#7fa18b] dark:bg-[#415d52]',
)}
/>
))}
</div>
</BoardWindow>
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="flex min-h-72 flex-col items-center justify-center p-5 text-center">
<Loader2 className="mb-3 animate-spin text-[var(--color-accent)]" size={28} />
<p className="text-sm font-semibold text-[var(--color-text)]"> .</p>
</WindowSurface>
</section>
</ChessPageFrame>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<ChessPageFrame>
<PageHeader />
<WindowSurface title="Puzzle" showTrafficLights={false} bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-red-500" size={30} />
<h2 className="break-words text-lg font-bold text-[var(--color-text)]"> .</h2>
<p className="mt-2 max-w-md break-words text-sm leading-6 text-[var(--color-text-muted)]">
API가 .
</p>
<button
type="button"
onClick={onRetry}
className="mt-5 inline-flex h-10 items-center gap-2 rounded-lg bg-[var(--color-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--color-accent-hover)]"
>
<RotateCcw size={16} />
</button>
</WindowSurface>
</ChessPageFrame>
);
}
function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
const [currentFen, setCurrentFen] = useState(puzzle.fen);
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
const [solved, setSolved] = useState(false);
const [lastMoveSquares, setLastMoveSquares] = useState<MoveSquares | null>(null);
const [feedback, setFeedback] = useState(getReadyFeedback(puzzle));
const game = useMemo(() => new ChessGame(currentFen), [currentFen]);
const legalMoves = useMemo<Move[]>(() => {
if (!selectedSquare || solved) return [];
return game.moves({ square: selectedSquare, verbose: true });
}, [game, selectedSquare, solved]);
const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]);
const resetPuzzle = () => {
setCurrentFen(puzzle.fen);
setSelectedSquare(null);
setSolved(false);
setLastMoveSquares(null);
setFeedback(getReadyFeedback(puzzle));
};
const registerSolvedPuzzle = (move: Move, nextFen: string) => {
setCurrentFen(nextFen);
setSolved(true);
setSelectedSquare(null);
setLastMoveSquares({ from: move.from, to: move.to });
setFeedback({
tone: 'success',
message: `${move.san} 정답입니다.`,
});
};
const isDraggablePuzzleSquare = (square: Square) => {
if (solved) return false;
const piece = game.get(square);
return piece?.color === game.turn();
};
const tryMove = (from: Square, to: Square) => {
if (solved || !isDraggablePuzzleSquare(from)) return;
const movesFromSquare = game.moves({ square: from, verbose: true });
const candidate = getPreferredMove(movesFromSquare, to);
if (!candidate) {
setFeedback({
tone: 'error',
message: '그 칸으로는 이동할 수 없습니다.',
});
return;
}
try {
const nextGame = new ChessGame(currentFen);
const move = nextGame.move({
from,
to,
promotion: candidate.promotion,
});
if (nextGame.isCheckmate()) {
registerSolvedPuzzle(move, nextGame.fen());
return;
}
setSelectedSquare(null);
setLastMoveSquares(null);
setFeedback({
tone: 'error',
message: `${move.san}은 아직 메이트가 아닙니다.`,
});
} catch {
setFeedback({
tone: 'error',
message: '합법적인 수가 아닙니다.',
});
}
};
const handleSquareClick = (square: Square) => {
if (solved) return;
const piece = game.get(square);
const isTurnPiece = piece?.color === game.turn();
if (!selectedSquare) {
if (isTurnPiece) {
setSelectedSquare(square);
setFeedback(getReadyFeedback(puzzle));
}
return;
}
if (selectedSquare === square) {
setSelectedSquare(null);
return;
}
if (legalTargets.has(square)) {
tryMove(selectedSquare, square);
return;
}
if (isTurnPiece) {
setSelectedSquare(square);
setFeedback(getReadyFeedback(puzzle));
return;
}
setSelectedSquare(null);
};
const showHint = () => {
setFeedback({
tone: 'neutral',
message: puzzle.hint,
});
};
return (
<ChessPageFrame>
<PageHeader />
<section className="grid min-w-0 items-start justify-center gap-5 lg:grid-cols-[minmax(0,30rem)_20rem]">
<BoardWindow>
<ChessBoard
fen={currentFen}
selectedSquare={selectedSquare}
legalTargets={legalTargets}
lastMoveSquares={lastMoveSquares}
disabled={solved}
isDraggableSquare={isDraggablePuzzleSquare}
onSquareClick={handleSquareClick}
onSquareDrop={tryMove}
/>
</BoardWindow>
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
<div className="mb-4 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-subtle)]">
{formatDate(puzzle.date)}
</p>
<h2 className="mt-1 break-words text-xl font-bold tracking-normal text-[var(--color-text)]">
{puzzle.title}
</h2>
<p className="mt-1 break-words text-sm text-[var(--color-text-muted)]">{puzzle.theme}</p>
</div>
{solved && <CheckCircle2 size={24} className="shrink-0 text-emerald-500" />}
</div>
<div
className={clsx(
'mb-4 break-words rounded-lg border px-3 py-3 text-sm font-semibold leading-6',
feedback.tone === 'success' && 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
feedback.tone === 'error' && 'border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300',
feedback.tone === 'neutral' && 'border-[var(--color-line)] bg-black/[0.025] text-[var(--color-text-muted)] dark:bg-white/[0.06]',
)}
>
{feedback.message}
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
<dt className="text-xs text-[var(--color-text-subtle)]"></dt>
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">{puzzle.rating}</dd>
</div>
<div className="min-w-0 rounded-lg bg-black/[0.025] px-3 py-2 dark:bg-white/[0.06]">
<dt className="text-xs text-[var(--color-text-subtle)]"></dt>
<dd className="mt-0.5 truncate font-semibold text-[var(--color-text)]">
{solved ? puzzle.answer : '숨김'}
</dd>
</div>
</dl>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={resetPuzzle}
className="inline-flex h-10 items-center justify-center rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
aria-label="다시 시작"
title="다시 시작"
>
<RotateCcw size={18} />
</button>
<button
type="button"
onClick={showHint}
className="inline-flex h-10 items-center justify-center rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
aria-label="힌트"
title="힌트"
>
<Lightbulb size={18} />
</button>
</div>
<a
href={puzzle.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-4 inline-flex max-w-full items-center gap-1.5 break-words text-xs font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-accent)]"
>
<span className="truncate">Lichess </span>
<ExternalLink size={13} className="shrink-0" />
</a>
</WindowSurface>
</section>
</ChessPageFrame>
);
}
export default function ChessPuzzleClient() {
const {
data: puzzle,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ['chess-puzzle', 'today', TIMEZONE],
queryFn: () => getTodayChessPuzzle(TIMEZONE),
retry: 0,
});
if (isLoading) {
return <LoadingState />;
}
if (isError || !puzzle) {
return <ErrorState onRetry={() => void refetch()} />;
}
return <ChessPuzzleBoard key={`${puzzle.id}-${puzzle.date}`} puzzle={puzzle} />;
}

View File

@@ -0,0 +1,60 @@
import axios from 'axios';
import { ChessOutcome } from '@/types';
export type OutcomeFilter = 'ALL' | 'IN_PROGRESS' | 'WIN' | 'LOSS' | 'DRAW';
export const outcomeLabels: Record<ChessOutcome, string> = {
IN_PROGRESS: '진행중',
WIN: '승',
LOSS: '패',
DRAW: '무',
UNKNOWN: '미정',
};
export const outcomeBadgeTones: Record<ChessOutcome, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
IN_PROGRESS: 'info',
WIN: 'success',
LOSS: 'danger',
DRAW: 'warning',
UNKNOWN: 'neutral',
};
export const getChessOutcomeLabel = (outcome: ChessOutcome, status?: string) => {
if (status === 'RESIGNED' && outcome === 'LOSS') return '기권패';
return outcomeLabels[outcome];
};
export const formatChessDateTime = (value?: string) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
export const getChessErrorMessage = (error: unknown, fallback = '요청을 처리하지 못했습니다.') => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401 || error.response?.status === 403) {
return '로그인이 필요합니다.';
}
const message = error.response?.data?.message;
if (typeof message === 'string' && message.trim()) return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
export const isAuthError = (error: unknown) => {
return axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403);
};

View File

@@ -5,7 +5,6 @@ import { useAuthStore } from '@/store/authStore';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createComment } from '@/api/comments'; import { createComment } from '@/api/comments';
import { Loader2, Send } from 'lucide-react'; import { Loader2, Send } from 'lucide-react';
import { clsx } from 'clsx';
interface CommentFormProps { interface CommentFormProps {
postSlug: string; postSlug: string;
@@ -15,7 +14,7 @@ interface CommentFormProps {
} }
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) { export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
const { isLoggedIn, role } = useAuthStore(); const { isLoggedIn } = useAuthStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [content, setContent] = useState(''); const [content, setContent] = useState('');
@@ -29,8 +28,16 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] }); queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
}, },
onError: (err: any) => { onError: (error: unknown) => {
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message)); const responseError = error as {
response?: {
data?: {
message?: string;
};
};
};
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
alert('댓글 작성 실패: ' + (responseError.response?.data?.message || fallbackMessage));
}, },
}); });
@@ -55,7 +62,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
}; };
return ( return (
<form onSubmit={handleSubmit} className="bg-gray-50 p-4 rounded-xl border border-gray-100"> <form onSubmit={handleSubmit} className="rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-4">
{/* 비회원 입력 필드 */} {/* 비회원 입력 필드 */}
{!isLoggedIn && ( {!isLoggedIn && (
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mb-3">
@@ -64,7 +71,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
placeholder="닉네임" placeholder="닉네임"
value={guestInfo.nickname} value={guestInfo.nickname}
onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })} onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })}
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500" className="w-1/2 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
required required
/> />
<input <input
@@ -72,7 +79,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
placeholder="비밀번호" placeholder="비밀번호"
value={guestInfo.password} value={guestInfo.password}
onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })} onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })}
className="w-1/2 px-3 py-2 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500" className="w-1/2 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
required required
/> />
</div> </div>
@@ -84,13 +91,13 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'} placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'}
className="w-full p-3 pr-12 text-sm border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 resize-none h-24 bg-white" className="h-24 w-full resize-none rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-3 pr-12 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
required required
/> />
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
className="absolute bottom-3 right-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300" className="absolute bottom-3 right-3 rounded-lg bg-[var(--color-accent)] p-2 text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
title="등록" title="등록"
> >
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />} {mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}

View File

@@ -4,9 +4,9 @@ import { useState } from 'react';
import { Comment } from '@/types'; import { Comment } from '@/types';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteComment, deleteAdminComment } from '@/api/comments'; import { deleteComment } from '@/api/comments';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, ShieldAlert, X } from 'lucide-react'; // X 아이콘 추가 import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, X } from 'lucide-react'; // X 아이콘 추가
import CommentForm from './CommentForm'; import CommentForm from './CommentForm';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import toast from 'react-hot-toast'; // 🎨 Toast 추가 import toast from 'react-hot-toast'; // 🎨 Toast 추가
@@ -17,8 +17,19 @@ interface CommentItemProps {
depth?: number; depth?: number;
} }
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response;
if (response?.data?.message) return response.data.message;
}
if (error instanceof Error) return error.message;
return fallback;
};
export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) { export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) {
const { isLoggedIn, role, user } = useAuthStore(); const { isLoggedIn, user } = useAuthStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isReplying, setIsReplying] = useState(false); const [isReplying, setIsReplying] = useState(false);
@@ -26,7 +37,6 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [guestPassword, setGuestPassword] = useState(''); const [guestPassword, setGuestPassword] = useState('');
const isAdmin = isLoggedIn && role?.includes('ADMIN');
const isGuestComment = !comment.memberId; const isGuestComment = !comment.memberId;
const isMyComment = isLoggedIn && !isGuestComment && ( const isMyComment = isLoggedIn && !isGuestComment && (
@@ -34,7 +44,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
(user?.nickname === comment.author) (user?.nickname === comment.author)
); );
const showDeleteButton = isAdmin || isGuestComment || isMyComment; const showDeleteButton = isGuestComment || isMyComment;
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password), mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password),
@@ -42,32 +52,12 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] }); queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
toast.success('댓글이 삭제되었습니다.'); toast.success('댓글이 삭제되었습니다.');
}, },
onError: (err: any) => { onError: (error) => {
toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.')); toast.error(`삭제 실패: ${getErrorMessage(error, '비밀번호가 틀렸습니다.')}`);
},
});
const adminDeleteMutation = useMutation({
mutationFn: deleteAdminComment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
toast.success('관리자 권한으로 삭제했습니다.');
},
onError: (err: any) => {
toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message));
}, },
}); });
const handleDeleteClick = () => { const handleDeleteClick = () => {
// A. 관리자 -> 즉시 삭제 (컨펌만)
if (isAdmin) {
if (confirm('관리자 권한으로 삭제하시겠습니까?')) {
adminDeleteMutation.mutate(comment.id);
}
return;
}
// B. 내 댓글 -> 즉시 삭제 (컨펌만)
if (isMyComment) { if (isMyComment) {
if (confirm('이 댓글을 삭제하시겠습니까?')) { if (confirm('이 댓글을 삭제하시겠습니까?')) {
deleteMutation.mutate({ id: comment.id }); deleteMutation.mutate({ id: comment.id });
@@ -94,17 +84,19 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div <div
className={clsx( className={clsx(
"relative p-4 rounded-xl transition-colors group", "relative p-4 rounded-xl transition-colors group",
comment.isPostAuthor ? "bg-blue-50/50 border border-blue-100" : "bg-white border border-gray-100" comment.isPostAuthor
? "border border-blue-500/15 bg-[var(--color-accent-soft)]"
: "border border-[var(--color-line)] bg-[var(--color-surface)]"
)} )}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={clsx( className={clsx(
"p-1.5 rounded-full flex items-center justify-center", "flex items-center justify-center rounded-full p-1.5",
comment.isPostAuthor ? "bg-blue-100 text-blue-600" : comment.isPostAuthor ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
!isGuestComment ? "bg-green-100 text-green-600" : !isGuestComment ? "bg-green-500/15 text-green-600 dark:text-green-300" :
"bg-gray-100 text-gray-500" "bg-black/[0.05] text-[var(--color-text-muted)] dark:bg-white/10"
)} )}
> >
{comment.isPostAuthor ? <CheckCircle2 size={14} /> : {comment.isPostAuthor ? <CheckCircle2 size={14} /> :
@@ -113,11 +105,11 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
<span className={clsx("text-sm font-bold flex items-center gap-1", comment.isPostAuthor ? "text-blue-700" : "text-gray-700")}> <span className={clsx("flex items-center gap-1 text-sm font-bold", comment.isPostAuthor ? "text-[var(--color-accent)]" : "text-[var(--color-text)]")}>
{comment.author} {comment.author}
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium"></span>} {comment.isPostAuthor && <span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]"></span>}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-[var(--color-text-subtle)]">
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')} {format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
</span> </span>
</div> </div>
@@ -126,7 +118,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => setIsReplying(!isReplying)} onClick={() => setIsReplying(!isReplying)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" className="rounded p-1.5 text-[var(--color-text-subtle)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)]"
title="답글 달기" title="답글 달기"
> >
<MessageSquare size={14} /> <MessageSquare size={14} />
@@ -137,30 +129,30 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
onClick={handleDeleteClick} onClick={handleDeleteClick}
className={clsx( className={clsx(
"p-1.5 rounded transition-colors", "p-1.5 rounded transition-colors",
isDeleting ? "bg-red-50 text-red-600" : isDeleting ? "bg-red-500/10 text-red-600" :
isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50" "text-[var(--color-text-subtle)] hover:bg-red-500/10 hover:text-red-600"
)} )}
title={isAdmin ? "관리자 삭제" : "삭제"} title="삭제"
> >
{isAdmin ? <ShieldAlert size={14} /> : <Trash2 size={14} />} <Trash2 size={14} />
</button> </button>
)} )}
</div> </div>
</div> </div>
<p className="text-gray-800 text-sm whitespace-pre-wrap leading-relaxed pl-1"> <p className="pl-1 text-sm leading-relaxed text-[var(--color-text)] whitespace-pre-wrap">
{comment.content} {comment.content}
</p> </p>
{/* 🎨 비회원 비밀번호 입력창 (인라인) */} {/* 🎨 비회원 비밀번호 입력창 (인라인) */}
{isDeleting && isGuestComment && ( {isDeleting && isGuestComment && (
<div className="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg animate-in fade-in slide-in-from-top-1 duration-200"> <div className="mt-3 flex animate-in items-center gap-2 rounded-lg bg-black/[0.04] p-2 duration-200 fade-in slide-in-from-top-1 dark:bg-white/10">
<input <input
type="password" type="password"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
value={guestPassword} value={guestPassword}
onChange={(e) => setGuestPassword(e.target.value)} onChange={(e) => setGuestPassword(e.target.value)}
className="text-xs px-2 py-1.5 border border-gray-200 rounded focus:outline-none focus:border-blue-500 bg-white" className="rounded border border-[var(--color-line)] bg-[var(--color-control)] px-2 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoFocus autoFocus
/> />
<button <button
@@ -171,7 +163,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</button> </button>
<button <button
onClick={() => { setIsDeleting(false); setGuestPassword(''); }} onClick={() => { setIsDeleting(false); setGuestPassword(''); }}
className="p-1 text-gray-400 hover:bg-gray-200 rounded-full" className="rounded-full p-1 text-[var(--color-text-subtle)] hover:bg-[var(--card-bg)]"
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -180,7 +172,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div> </div>
{isReplying && ( {isReplying && (
<div className="mt-2 pl-4 border-l-2 border-gray-200 ml-4 animate-in fade-in slide-in-from-top-2"> <div className="mt-2 ml-4 animate-in border-l-2 border-[var(--color-line)] pl-4 fade-in slide-in-from-top-2">
<CommentForm <CommentForm
postSlug={postSlug} postSlug={postSlug}
parentId={comment.id} parentId={comment.id}
@@ -191,7 +183,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
)} )}
{comment.children && comment.children.length > 0 && ( {comment.children && comment.children.length > 0 && (
<div className="mt-2 pl-4 md:pl-8 border-l-2 border-gray-100 ml-2 md:ml-4 space-y-3"> <div className="mt-2 ml-2 space-y-3 border-l-2 border-[var(--color-line)] pl-4 md:ml-4 md:pl-8">
{comment.children.map((child) => ( {comment.children.map((child) => (
<CommentItem <CommentItem
key={child.id} key={child.id}

View File

@@ -1,24 +1,23 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2, MessageCircle } from 'lucide-react';
import { getComments } from '@/api/comments'; import { getComments } from '@/api/comments';
import { Comment } from '@/types';
import CommentForm from './CommentForm'; import CommentForm from './CommentForm';
import CommentItem from './CommentItem'; import CommentItem from './CommentItem';
import { Loader2, MessageCircle } from 'lucide-react';
interface CommentListProps { interface CommentListProps {
postSlug: string; postSlug: string;
} }
export default function CommentList({ postSlug }: CommentListProps) { export default function CommentList({ postSlug }: CommentListProps) {
// 댓글 목록 조회
const { data: comments, isLoading, error } = useQuery({ const { data: comments, isLoading, error } = useQuery({
queryKey: ['comments', postSlug], queryKey: ['comments', postSlug],
queryFn: () => getComments(postSlug), queryFn: () => getComments(postSlug),
}); });
// 총 댓글 수 계산 (재귀) const countComments = (list: Comment[]): number => {
const countComments = (list: any[]): number => {
if (!list) return 0; if (!list) return 0;
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0); return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
}; };
@@ -26,7 +25,11 @@ export default function CommentList({ postSlug }: CommentListProps) {
const totalCount = comments ? countComments(comments) : 0; const totalCount = comments ? countComments(comments) : 0;
if (isLoading) { if (isLoading) {
return <div className="py-10 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>; return (
<div className="flex justify-center py-10">
<Loader2 className="animate-spin text-[var(--color-accent)]" />
</div>
);
} }
if (error) { if (error) {
@@ -34,28 +37,26 @@ export default function CommentList({ postSlug }: CommentListProps) {
} }
return ( return (
<div className="mt-16 pt-10 border-t border-gray-100"> <div>
<div className="flex items-center gap-2 mb-6"> <div className="mb-6 flex items-center gap-2">
<MessageCircle className="text-blue-600" size={24} /> <MessageCircle className="text-[var(--color-accent)]" size={24} />
<h3 className="text-xl font-bold text-gray-800"> <h3 className="text-xl font-bold text-[var(--color-text)]">
<span className="text-blue-600">{totalCount}</span> <span className="text-[var(--color-accent)]">{totalCount}</span>
</h3> </h3>
</div> </div>
{/* 최상위 댓글 작성 폼 */}
<div className="mb-10"> <div className="mb-10">
<CommentForm postSlug={postSlug} /> <CommentForm postSlug={postSlug} />
</div> </div>
{/* 댓글 목록 */}
<div className="space-y-6"> <div className="space-y-6">
{comments && comments.length > 0 ? ( {comments && comments.length > 0 ? (
comments.map((comment) => ( comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} postSlug={postSlug} /> <CommentItem key={comment.id} comment={comment} postSlug={postSlug} />
)) ))
) : ( ) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-500 text-sm"> <div className="rounded-lg border border-dashed border-[var(--color-line)] bg-[var(--color-surface)] py-10 text-center text-sm text-[var(--color-text-muted)]">
. ! . .
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,643 @@
'use client';
import { type CSSProperties, type FocusEvent, useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { clsx } from 'clsx';
import type { LucideIcon } from 'lucide-react';
import {
Archive,
Crown,
Home,
LogIn,
LogOut,
Menu,
MoreHorizontal,
PenLine,
Pin,
PinOff,
Settings,
UserPlus,
} from 'lucide-react';
import ThemeToggle from '@/components/theme/ThemeToggle';
import { useAuthStore } from '@/store/authStore';
interface DesktopDockProps {
onOpenMobileMenu: () => void;
}
type DockAction = {
key: string;
href?: string;
label: string;
icon: LucideIcon;
isActive?: (pathname: string) => boolean;
onClick?: () => void;
};
const isActivePath = (target: string) => (pathname: string) => {
if (target === '/') return pathname === '/';
return pathname.startsWith(target);
};
const DOCK_LEAVE_DELAY_MS = 120;
const DOCK_COLLAPSE_MS = 560;
const DOCK_PINNED_STORAGE_KEY = 'dock-pinned-v2';
type DockToneStyle = CSSProperties & {
'--dock-item-bg': string;
'--dock-item-bg-hover': string;
'--dock-item-bg-active': string;
'--dock-item-border': string;
'--dock-item-border-hover': string;
'--dock-item-fg': string;
'--dock-item-fg-strong': string;
'--dock-item-ring': string;
};
const createDockTone = (
bg: string,
hover: string,
active: string,
border: string,
borderHover: string,
fg: string,
fgStrong: string,
ring: string,
): DockToneStyle => ({
'--dock-item-bg': bg,
'--dock-item-bg-hover': hover,
'--dock-item-bg-active': active,
'--dock-item-border': border,
'--dock-item-border-hover': borderHover,
'--dock-item-fg': fg,
'--dock-item-fg-strong': fgStrong,
'--dock-item-ring': ring,
});
const dockGlassTone = createDockTone(
'rgba(255, 255, 255, 0.24)',
'rgba(255, 255, 255, 0.36)',
'rgba(255, 255, 255, 0.46)',
'rgba(255, 255, 255, 0.2)',
'rgba(255, 255, 255, 0.34)',
'var(--color-text-muted)',
'var(--color-text)',
'rgba(255, 255, 255, 0.34)',
);
const homeDockTone = createDockTone(
'rgba(255, 255, 255, 0.58)',
'rgba(255, 255, 255, 0.68)',
'rgba(255, 255, 255, 0.76)',
'rgba(255, 255, 255, 0.28)',
'rgba(255, 255, 255, 0.46)',
'var(--color-text-muted)',
'var(--color-text)',
'rgba(255, 255, 255, 0.42)',
);
const dockToneStyles: Record<string, DockToneStyle> = {
home: homeDockTone,
archive: createDockTone('rgba(222, 216, 255, 0.54)', 'rgba(222, 216, 255, 0.64)', 'rgba(222, 216, 255, 0.72)', 'rgba(255, 255, 255, 0.26)', 'rgba(151, 132, 214, 0.34)', '#6f668f', '#514274', 'rgba(151, 132, 214, 0.2)'),
chess: createDockTone('rgba(255, 229, 162, 0.54)', 'rgba(255, 229, 162, 0.64)', 'rgba(255, 229, 162, 0.72)', 'rgba(255, 255, 255, 0.28)', 'rgba(184, 134, 42, 0.34)', '#7b6d4a', '#665125', 'rgba(184, 134, 42, 0.2)'),
admin: createDockTone('rgba(192, 226, 255, 0.54)', 'rgba(192, 226, 255, 0.64)', 'rgba(192, 226, 255, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(81, 145, 195, 0.34)', '#627f96', '#3f6686', 'rgba(81, 145, 195, 0.2)'),
write: createDockTone('rgba(255, 209, 184, 0.54)', 'rgba(255, 209, 184, 0.64)', 'rgba(255, 209, 184, 0.72)', 'rgba(255, 255, 255, 0.28)', 'rgba(187, 106, 70, 0.34)', '#8c705f', '#724d38', 'rgba(187, 106, 70, 0.2)'),
login: createDockTone('rgba(190, 236, 238, 0.54)', 'rgba(190, 236, 238, 0.64)', 'rgba(190, 236, 238, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(68, 150, 158, 0.34)', '#63898c', '#446d72', 'rgba(68, 150, 158, 0.2)'),
signup: createDockTone('rgba(237, 207, 250, 0.54)', 'rgba(237, 207, 250, 0.64)', 'rgba(237, 207, 250, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(153, 91, 176, 0.34)', '#846c8e', '#684a78', 'rgba(153, 91, 176, 0.2)'),
logout: createDockTone('rgba(255, 207, 216, 0.54)', 'rgba(255, 207, 216, 0.64)', 'rgba(255, 207, 216, 0.72)', 'rgba(255, 255, 255, 0.28)', 'rgba(188, 84, 99, 0.34)', '#8f6970', '#714850', 'rgba(188, 84, 99, 0.2)'),
pin: createDockTone('rgba(255, 219, 170, 0.56)', 'rgba(255, 219, 170, 0.66)', 'rgba(255, 219, 170, 0.74)', 'rgba(255, 255, 255, 0.28)', 'rgba(184, 118, 44, 0.36)', '#8a704e', '#6b4f2d', 'rgba(184, 118, 44, 0.22)'),
menu: createDockTone('rgba(213, 234, 203, 0.54)', 'rgba(213, 234, 203, 0.64)', 'rgba(213, 234, 203, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(98, 143, 80, 0.34)', '#6f8366', '#526f44', 'rgba(98, 143, 80, 0.2)'),
more: createDockTone('rgba(210, 228, 255, 0.54)', 'rgba(210, 228, 255, 0.64)', 'rgba(210, 228, 255, 0.72)', 'rgba(255, 255, 255, 0.27)', 'rgba(86, 126, 183, 0.34)', '#6d7f98', '#4f688d', 'rgba(86, 126, 183, 0.2)'),
default: dockGlassTone,
};
const getDockToneStyle = (key: string) => dockToneStyles[key] ?? dockToneStyles.default;
export default function DesktopDock({ onOpenMobileMenu }: DesktopDockProps) {
const pathname = usePathname();
const router = useRouter();
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
const [isPinned, setIsPinned] = useState(true);
const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isDockHovered, setIsDockHovered] = useState(false);
const [isDockFocused, setIsDockFocused] = useState(false);
const [isDockClosing, setIsDockClosing] = useState(false);
const isPinnedRef = useRef(isPinned);
const dockZoneRef = useRef<HTMLDivElement>(null);
const dockLeaveTimerRef = useRef<number | null>(null);
const dockCollapseTimerRef = useRef<number | null>(null);
const pinToggleStartedByPointerRef = useRef(false);
const isAdmin = _hasHydrated && isLoggedIn && role?.includes('ADMIN');
const isDockOpen = isPinned || isDockHovered || isDockFocused;
const isDockRangeExpanded = isDockOpen || isDockClosing;
const clearDockTimers = useCallback(() => {
if (dockLeaveTimerRef.current !== null) {
window.clearTimeout(dockLeaveTimerRef.current);
dockLeaveTimerRef.current = null;
}
if (dockCollapseTimerRef.current !== null) {
window.clearTimeout(dockCollapseTimerRef.current);
dockCollapseTimerRef.current = null;
}
}, []);
const expandDock = () => {
clearDockTimers();
setIsDockClosing(false);
setIsDockHovered(true);
};
const startDockCollapse = useCallback(() => {
clearDockTimers();
setIsDockHovered(false);
setIsDockClosing(true);
dockCollapseTimerRef.current = window.setTimeout(() => {
setIsDockClosing(false);
dockCollapseTimerRef.current = null;
}, DOCK_COLLAPSE_MS);
}, [clearDockTimers]);
const scheduleDockCollapse = () => {
if (isPinned || isDockFocused) return;
clearDockTimers();
dockLeaveTimerRef.current = window.setTimeout(() => {
startDockCollapse();
}, DOCK_LEAVE_DELAY_MS);
};
const blurFocusedDockItem = useCallback(() => {
if (
document.activeElement instanceof HTMLElement &&
dockZoneRef.current?.contains(document.activeElement)
) {
document.activeElement.blur();
}
}, []);
const releaseUnpinnedDockAfterNavigation = useCallback(() => {
if (isPinnedRef.current) return;
const isPointerInsideDock = dockZoneRef.current?.matches(':hover') ?? false;
blurFocusedDockItem();
setIsDockFocused(false);
if (isPointerInsideDock) {
clearDockTimers();
setIsDockClosing(false);
setIsDockHovered(true);
return;
}
startDockCollapse();
}, [blurFocusedDockItem, clearDockTimers, startDockCollapse]);
useEffect(() => {
isPinnedRef.current = isPinned;
}, [isPinned]);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
const savedValue = window.localStorage.getItem(DOCK_PINNED_STORAGE_KEY);
const nextValue = savedValue === null ? true : savedValue === 'true';
isPinnedRef.current = nextValue;
setIsPinned(nextValue);
});
return () => window.cancelAnimationFrame(frameId);
}, []);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsMoreOpen(false);
releaseUnpinnedDockAfterNavigation();
});
return () => window.cancelAnimationFrame(frameId);
}, [pathname, releaseUnpinnedDockAfterNavigation]);
useEffect(() => {
return () => clearDockTimers();
}, [clearDockTimers]);
const handlePinnedChange = () => {
const nextValue = !isPinned;
const startedByPointer = pinToggleStartedByPointerRef.current;
const isPointerInsideDock = dockZoneRef.current?.matches(':hover') ?? false;
pinToggleStartedByPointerRef.current = false;
window.localStorage.setItem(DOCK_PINNED_STORAGE_KEY, String(nextValue));
clearDockTimers();
isPinnedRef.current = nextValue;
setIsPinned(nextValue);
if (nextValue) {
setIsDockClosing(false);
setIsDockHovered(false);
return;
}
if (startedByPointer) {
setIsDockFocused(false);
blurFocusedDockItem();
}
if (isPointerInsideDock) {
setIsDockClosing(false);
setIsDockHovered(true);
return;
}
if (!isDockFocused || startedByPointer) {
startDockCollapse();
return;
}
setIsDockHovered(false);
setIsDockClosing(false);
};
const handleDockZoneFocus = () => {
clearDockTimers();
setIsDockClosing(false);
setIsDockFocused(true);
};
const handleDockHandleFocus = () => {
handleDockZoneFocus();
window.requestAnimationFrame(() => {
dockZoneRef.current
?.querySelector<HTMLElement>('[data-dock-panel="true"] a, [data-dock-panel="true"] button')
?.focus();
});
};
const handleDockBlur = (event: FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
if (!nextTarget) {
window.setTimeout(() => {
if (dockZoneRef.current?.contains(document.activeElement)) return;
setIsDockFocused(false);
if (!isPinned && !isDockHovered) {
startDockCollapse();
}
}, 80);
return;
}
setIsDockFocused(false);
if (!isPinned && !isDockHovered) {
startDockCollapse();
}
};
const handleLogout = () => {
if (confirm('로그아웃 하시겠습니까?')) {
logout();
setIsMoreOpen(false);
router.push('/');
}
};
const baseItems: DockAction[] = [
{
key: 'home',
href: '/',
label: '홈',
icon: Home,
isActive: isActivePath('/'),
},
{
key: 'archive',
href: '/archive',
label: '아카이브',
icon: Archive,
isActive: isActivePath('/archive'),
},
{
key: 'chess',
href: '/chess',
label: '체스',
icon: Crown,
isActive: (currentPath) => currentPath.startsWith('/chess') || currentPath.startsWith('/play/chess'),
},
];
const authItems: DockAction[] = [];
if (_hasHydrated && isAdmin) {
authItems.push(
{
key: 'admin',
href: '/admin',
label: '관리자',
icon: Settings,
isActive: (currentPath) => currentPath === '/admin',
},
{
key: 'write',
href: '/admin/posts/new',
label: '글쓰기',
icon: PenLine,
isActive: isActivePath('/admin/posts/new'),
},
{
key: 'logout',
label: '로그아웃',
icon: LogOut,
onClick: handleLogout,
},
);
} else if (_hasHydrated && isLoggedIn) {
authItems.push({
key: 'logout',
label: '로그아웃',
icon: LogOut,
onClick: handleLogout,
});
} else if (_hasHydrated) {
authItems.push(
{
key: 'login',
href: '/login',
label: '로그인',
icon: LogIn,
isActive: isActivePath('/login'),
},
{
key: 'signup',
href: '/signup',
label: '회원가입',
icon: UserPlus,
isActive: isActivePath('/signup'),
},
);
}
const desktopItems = [...baseItems, ...authItems];
const renderDesktopItem = (item: DockAction) => {
const Icon = item.icon;
const active = item.isActive?.(pathname) ?? false;
const toneStyle = getDockToneStyle(item.key);
const itemClass = clsx(
'group/item relative flex h-12 w-12 items-center justify-center rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition duration-150 hover:-translate-y-1 hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)] focus-visible:-translate-y-1',
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
);
const content = (
<>
<Icon size={20} strokeWidth={2.1} />
<span className="pointer-events-none absolute -top-9 whitespace-nowrap rounded-full border border-[var(--card-border)] bg-[var(--card-bg-strong)] px-2.5 py-1 text-xs font-semibold text-[var(--color-text)] opacity-0 shadow-[var(--shadow-control)] backdrop-blur-[18px] transition group-hover/item:opacity-100">
{item.label}
</span>
{active && (
<span className="absolute -bottom-1 h-1 w-1 rounded-full bg-[var(--dock-item-fg-strong)]" />
)}
</>
);
if (item.href) {
return (
<Link
key={item.key}
href={item.href}
title={item.label}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
className={itemClass}
style={toneStyle}
onClick={releaseUnpinnedDockAfterNavigation}
>
{content}
</Link>
);
}
return (
<button
key={item.key}
type="button"
title={item.label}
aria-label={item.label}
onClick={item.onClick}
className={itemClass}
style={toneStyle}
>
{content}
</button>
);
};
const renderMobileItem = (item: DockAction, activeOverride?: boolean) => {
const Icon = item.icon;
const active = activeOverride ?? item.isActive?.(pathname) ?? false;
const toneStyle = getDockToneStyle(item.key);
const itemClass = clsx(
'flex h-12 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] px-1 text-[10px] font-semibold leading-none text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors',
'hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)] focus-visible:bg-[var(--dock-item-bg-active)]',
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
);
const content = (
<>
<Icon size={18} strokeWidth={2.1} />
<span className="max-w-full truncate">{item.label}</span>
</>
);
if (item.href) {
return (
<Link
key={item.key}
href={item.href}
title={item.label}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
className={itemClass}
style={toneStyle}
onClick={() => setIsMoreOpen(false)}
>
{content}
</Link>
);
}
return (
<button
key={item.key}
type="button"
title={item.label}
aria-label={item.label}
onClick={item.onClick}
className={itemClass}
style={toneStyle}
>
{content}
</button>
);
};
return (
<>
<nav
aria-label="빠른 실행 Dock"
className={clsx(
'fixed bottom-1 left-1/2 z-50 hidden -translate-x-1/2 md:block',
)}
>
<div
ref={dockZoneRef}
onMouseEnter={expandDock}
onMouseLeave={scheduleDockCollapse}
onFocus={handleDockZoneFocus}
onBlur={handleDockBlur}
className={clsx(
'relative flex items-end justify-center transition-[width,height,padding] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
isDockRangeExpanded
? 'h-32 w-[min(calc(100vw-2rem),56rem)] px-8 pb-7 pt-12'
: 'h-14 w-16 px-0 pb-0 pt-0',
)}
>
<button
type="button"
aria-label="Dock 열기"
tabIndex={isDockRangeExpanded ? -1 : 0}
onMouseEnter={expandDock}
onFocus={handleDockHandleFocus}
className={clsx(
'absolute bottom-0 left-1/2 z-10 flex h-10 w-12 -translate-x-1/2 items-start justify-center rounded-t-lg border border-b-0 border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] pt-1.5 text-[var(--dock-item-fg-strong)] shadow-[var(--shadow-control)] backdrop-blur-[22px]',
'transition-[transform,opacity,width,height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-[var(--dock-item-bg-hover)] focus-visible:bg-[var(--dock-item-bg-hover)]',
isDockRangeExpanded
? 'pointer-events-none translate-y-5 scale-75 opacity-0'
: 'pointer-events-auto translate-y-0 scale-100 opacity-100 hover:h-10 hover:w-14',
)}
style={getDockToneStyle('more')}
>
<MoreHorizontal size={18} strokeWidth={2.3} />
</button>
<div
data-dock-panel="true"
className={clsx(
'flex max-w-[calc(100vw-2rem)] origin-bottom items-end gap-2 overflow-visible rounded-xl border border-[var(--dock-border)] bg-[var(--dock-bg)] px-2 py-2 shadow-[var(--shadow-dock)] backdrop-blur-[30px]',
'transition-[transform,opacity,filter] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
isDockOpen
? 'pointer-events-auto translate-y-0 scale-x-100 scale-y-100 opacity-100 blur-0'
: clsx(
'translate-y-8 scale-x-[0.16] scale-y-[0.22] opacity-0 blur-sm',
isDockClosing ? 'pointer-events-auto' : 'pointer-events-none',
),
)}
>
{desktopItems.map(renderDesktopItem)}
<button
type="button"
title={isPinned ? 'Dock 고정 해제' : 'Dock 고정'}
aria-label={isPinned ? 'Dock 고정 해제' : 'Dock 고정'}
aria-pressed={isPinned}
onPointerDown={() => {
pinToggleStartedByPointerRef.current = true;
}}
onClick={handlePinnedChange}
className={clsx(
'group/item relative ml-1 flex h-12 w-12 items-center justify-center rounded-lg border border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] shadow-[var(--shadow-control)] ring-1 ring-[var(--dock-item-ring)] backdrop-blur-[18px] transition duration-150 hover:-translate-y-1 hover:bg-[var(--dock-item-bg-hover)]',
isPinned && 'shadow-[var(--shadow-dock)]',
)}
style={getDockToneStyle('pin')}
>
{isPinned ? <PinOff size={19} /> : <Pin size={19} />}
<span className="pointer-events-none absolute -top-9 whitespace-nowrap rounded-full border border-[var(--card-border)] bg-[var(--card-bg-strong)] px-2.5 py-1 text-xs font-semibold text-[var(--color-text)] opacity-0 shadow-[var(--shadow-control)] backdrop-blur-[18px] transition group-hover/item:opacity-100">
{isPinned ? '고정 해제' : '고정'}
</span>
</button>
</div>
</div>
</nav>
{isMoreOpen && (
<div className="fixed inset-x-2 bottom-[4.75rem] z-50 md:hidden">
<div className="mx-auto max-w-md rounded-lg border border-[var(--dock-border)] bg-[var(--dock-bg)] p-3 shadow-[var(--shadow-dock)] backdrop-blur-[30px]">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
</span>
<ThemeToggle />
</div>
<div className="grid grid-cols-2 gap-2">
{authItems.map((item) => {
const Icon = item.icon;
const active = item.isActive?.(pathname) ?? false;
const toneStyle = getDockToneStyle(item.key);
const className = clsx(
'inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-[var(--dock-item-border)] bg-[var(--dock-item-bg)] px-3 text-sm font-semibold text-[var(--dock-item-fg)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:border-[var(--dock-item-border-hover)] hover:bg-[var(--dock-item-bg-hover)] hover:text-[var(--dock-item-fg-strong)]',
active && 'border-[var(--dock-item-border-hover)] bg-[var(--dock-item-bg-active)] text-[var(--dock-item-fg-strong)] ring-1 ring-[var(--dock-item-ring)]',
);
if (item.href) {
return (
<Link
key={item.key}
href={item.href}
className={className}
style={toneStyle}
onClick={() => setIsMoreOpen(false)}
>
<Icon size={16} />
<span className="truncate">{item.label}</span>
</Link>
);
}
return (
<button
key={item.key}
type="button"
className={className}
style={toneStyle}
onClick={item.onClick}
>
<Icon size={16} />
<span className="truncate">{item.label}</span>
</button>
);
})}
</div>
</div>
</div>
)}
<nav aria-label="모바일 Dock" className="fixed inset-x-2 bottom-2 z-40 md:hidden">
<div className="mx-auto flex max-w-md items-center gap-1 rounded-lg border border-[var(--dock-border)] bg-[var(--dock-bg)] p-1.5 pb-[calc(0.375rem+env(safe-area-inset-bottom))] shadow-[var(--shadow-dock)] backdrop-blur-[30px]">
{renderMobileItem(baseItems[0])}
{renderMobileItem(baseItems[1])}
{renderMobileItem({
key: 'menu',
label: '메뉴',
icon: Menu,
onClick: () => {
setIsMoreOpen(false);
onOpenMobileMenu();
},
})}
{renderMobileItem(baseItems[2])}
{renderMobileItem({
key: 'more',
label: '더보기',
icon: MoreHorizontal,
onClick: () => setIsMoreOpen((previous) => !previous),
}, isMoreOpen)}
</div>
</nav>
</>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { BatteryMedium, ChevronRight, Command, Monitor, Search, Wifi } from 'lucide-react';
import { getProfile } from '@/api/profile';
import ThemeToggle from '@/components/theme/ThemeToggle';
const defaultProfile = {
githubUrl: 'https://github.com',
email: 'user@example.com',
};
const getCategoryTitle = (pathname: string) => {
const encodedName = pathname.split('/category/')[1]?.split('/')[0] || '';
const decodedName = decodeURIComponent(encodedName);
return decodedName === 'uncategorized' ? '미분류' : decodedName;
};
const getAppTitle = (pathname: string) => {
if (pathname.startsWith('/admin/posts/new')) return 'Write';
if (pathname.startsWith('/admin/posts')) return 'Post Manager';
if (pathname.startsWith('/admin/comments')) return 'Comment Center';
if (pathname.startsWith('/admin/categories')) return 'Category Studio';
if (pathname.startsWith('/admin/profile')) return 'Profile Panel';
if (pathname.startsWith('/admin')) return 'Admin Console';
if (pathname.startsWith('/archive')) return 'Archive';
if (pathname.startsWith('/category')) return getCategoryTitle(pathname);
if (pathname.startsWith('/posts')) return 'Reader';
if (pathname.startsWith('/chess')) return 'Maia Chess';
if (pathname.startsWith('/play/chess')) return 'Chess';
if (pathname.startsWith('/login')) return 'Login';
if (pathname.startsWith('/signup')) return 'Signup';
return '홈';
};
const formatMenuTime = (date: Date | null) => {
if (!date) return '';
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
export default function DesktopMenuBar() {
const pathname = usePathname();
const [now, setNow] = useState<Date | null>(null);
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: getProfile,
retry: 0,
});
useEffect(() => {
const updateTime = () => setNow(new Date());
const frameId = window.requestAnimationFrame(updateTime);
const timerId = window.setInterval(updateTime, 60_000);
return () => {
window.cancelAnimationFrame(frameId);
window.clearInterval(timerId);
};
}, []);
const githubUrl = profile?.githubUrl || defaultProfile.githubUrl;
const email = profile?.email || defaultProfile.email;
const menuLinkClass = 'hidden rounded px-2 py-1 text-xs font-semibold text-[var(--color-text-muted)] transition hover:bg-[var(--card-bg)] hover:text-[var(--color-text)] xl:inline-flex';
const systemIconClass = 'text-[var(--color-text-muted)]';
return (
<div
className="fixed inset-x-0 top-0 z-[70] hidden h-10 border-b border-[var(--menubar-border)] bg-[var(--menubar-bg)] px-3 shadow-[var(--shadow-menubar)] backdrop-blur-[28px] md:block"
role="menubar"
aria-label="시스템 메뉴 막대"
>
<div className="flex h-full min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<Link
href="/"
className="mr-1 inline-flex h-7 shrink-0 items-center gap-1.5 rounded px-2 text-sm font-bold text-[var(--color-text)] transition hover:bg-[var(--card-bg)]"
>
<Command size={15} strokeWidth={2.4} />
WYPark
</Link>
<div className="hidden min-w-0 items-center gap-1.5 rounded px-2 py-1 text-xs font-semibold text-[var(--color-text-subtle)] sm:flex">
<Monitor size={14} />
<span className="truncate text-[var(--color-text-muted)]">{getAppTitle(pathname)}</span>
<ChevronRight size={13} />
<span className="truncate">{profile?.name || 'WYPark'}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<a href={githubUrl} target="_blank" rel="noreferrer" className={menuLinkClass}>
GitHub
</a>
<a href={`mailto:${email}`} className={menuLinkClass}>
Email
</a>
<Search size={15} className={systemIconClass} />
<Wifi size={15} className={systemIconClass} />
<BatteryMedium size={17} className={systemIconClass} />
<span className="hidden min-w-[8.5rem] text-right text-xs font-semibold tabular-nums text-[var(--color-text-muted)] lg:block">
{formatMenuTime(now)}
</span>
<div className="scale-[0.88]">
<ThemeToggle />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { clsx } from 'clsx';
import DesktopDock from '@/components/layout/DesktopDock';
import DesktopMenuBar from '@/components/layout/DesktopMenuBar';
import Sidebar from '@/components/layout/Sidebar';
export default function DesktopShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
useEffect(() => {
const savedValue = window.localStorage.getItem('sidebar-collapsed');
if (!savedValue) return undefined;
const frameId = window.requestAnimationFrame(() => {
setIsSidebarCollapsed(savedValue === 'true');
});
return () => window.cancelAnimationFrame(frameId);
}, []);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsMobileSidebarOpen(false);
});
return () => window.cancelAnimationFrame(frameId);
}, [pathname]);
const handleSidebarCollapsedChange = (nextValue: boolean) => {
setIsSidebarCollapsed(nextValue);
window.localStorage.setItem('sidebar-collapsed', String(nextValue));
};
const isReaderRoute = pathname.startsWith('/posts/');
const isChessRoute = pathname.startsWith('/chess') || pathname.startsWith('/play/chess');
return (
<div className="desktop-shell min-h-screen overflow-x-clip">
<Sidebar
isDesktopCollapsed={isSidebarCollapsed}
isMobileOpen={isMobileSidebarOpen}
onDesktopCollapsedChange={handleSidebarCollapsedChange}
onMobileOpenChange={setIsMobileSidebarOpen}
/>
<DesktopMenuBar />
<DesktopDock
onOpenMobileMenu={() => setIsMobileSidebarOpen(true)}
/>
<main
className={clsx(
'relative z-10 min-w-0 max-w-full flex-1 overflow-x-clip transition-[margin,width] duration-300 ease-out',
isSidebarCollapsed
? 'md:ml-[7rem] md:w-[calc(100%-7rem)]'
: 'md:ml-[20rem] md:w-[calc(100%-20rem)]',
)}
>
<div
className={clsx(
'mx-auto min-w-0 max-w-full px-3 pt-14 md:px-6 md:pt-16 lg:px-8',
isChessRoute ? 'pb-44 md:pb-40' : isReaderRoute ? 'pb-44 md:pb-36' : 'pb-36 md:pb-32',
)}
>
{children}
</div>
</main>
</div>
);
}

View File

@@ -1,189 +1,118 @@
'use client'; 'use client';
import { useState, useRef, useCallback, useEffect, useMemo, Suspense } from 'react'; import { Suspense, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
// 🎨 이미지 최적화를 위해 next/image 사용
import Image from 'next/image'; import Image from 'next/image';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/category';
import { getProfile, updateProfile } from '@/api/profile';
import { uploadImage } from '@/api/image';
import {
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion,
Archive
} from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { Profile, ProfileUpdateRequest, Category } from '@/types'; import {
import { useAuthStore } from '@/store/authStore'; ChevronRight,
import toast from 'react-hot-toast'; ChevronsLeft,
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가 ChevronsRight,
FileQuestion,
Folder,
FolderOpen,
} from 'lucide-react';
import { getCategories } from '@/api/category';
import { getProfile } from '@/api/profile';
import PostSearch from '@/components/post/PostSearch';
import { Category, Profile } from '@/types';
const findCategoryNameById = (categories: Category[], id: number): string | undefined => { interface SidebarProps {
for (const cat of categories) { isDesktopCollapsed: boolean;
if (cat.id === id) return cat.name; isMobileOpen: boolean;
if (cat.children && cat.children.length > 0) { onDesktopCollapsedChange: (nextValue: boolean) => void;
const found = findCategoryNameById(cat.children, id); onMobileOpenChange: (nextValue: boolean) => void;
if (found) return found;
} }
}
return undefined;
};
interface CategoryItemProps { interface CategoryItemProps {
category: Category; category: Category;
depth: number; depth: number;
onNavigate: () => void;
pathname: string; pathname: string;
isEditMode: boolean;
onDrop: (draggedId: number, targetId: number | null) => void;
onAdd: (parentId: number) => void;
onDelete: (id: number) => void;
} }
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) { const defaultProfile: Profile = {
name: 'Dev Park',
bio: '개발 기록과 실험을 모아두는 공간입니다.',
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
githubUrl: 'https://github.com',
email: 'user@example.com',
};
const sortCategories = (categories: Category[] = []) => {
return [...categories].sort((a, b) => a.id - b.id);
};
function CategoryItem({ category, depth, onNavigate, pathname }: CategoryItemProps) {
const sortedChildren = useMemo(() => sortCategories(category.children), [category.children]);
const hasChildren = sortedChildren.length > 0;
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`; const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
const hasActiveChild = useMemo(() => { const hasActiveChild = useMemo(() => {
const check = (cats: Category[] | undefined): boolean => { const check = (categories: Category[] | undefined): boolean => {
if (!cats) return false; if (!categories) return false;
return cats.some(c =>
decodeURIComponent(pathname) === `/category/${c.name}` || check(c.children) return categories.some((child) => {
); return decodeURIComponent(pathname) === `/category/${child.name}` || check(child.children);
});
}; };
return check(category.children); return check(category.children);
}, [category.children, pathname]); }, [category.children, pathname]);
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild); const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
const isChildrenVisible = isExpanded || isActive || hasActiveChild;
useEffect(() => {
if (isActive || hasActiveChild) {
setIsExpanded(true);
}
}, [isActive, hasActiveChild]);
const [isDragOver, setIsDragOver] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
e.dataTransfer.setData('categoryId', category.id.toString());
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (isEditMode) {
setIsDragOver(true);
e.dataTransfer.dropEffect = 'move';
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (!isEditMode) return;
const draggedId = Number(e.dataTransfer.getData('categoryId'));
if (!draggedId || draggedId === category.id) return;
if (confirm(`'${category.name}' 하위로 이동하시겠습니까?`)) {
onDrop(draggedId, category.id);
}
};
const sortedChildren = useMemo(() => {
if (!category.children) return [];
return [...category.children].sort((a, b) => a.id - b.id);
}, [category.children]);
return ( return (
<div className="mb-1"> <div className="mb-1">
<div <div
className={clsx( className={clsx(
'flex items-center justify-between px-4 py-2 text-sm rounded-lg transition-all group relative', 'flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-all',
isActive && !isEditMode ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50', isActive
isEditMode && 'border border-dashed border-gray-300 hover:border-blue-400 cursor-move bg-white', ? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)] shadow-sm'
isDragOver && 'bg-blue-100 border-blue-500' : 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)} )}
style={{ marginLeft: `${depth * 12}px` }} style={{ marginLeft: `${depth * 8}px` }}
draggable={isEditMode} >
onDragStart={isEditMode ? handleDragStart : undefined} <Link
onDragOver={isEditMode ? handleDragOver : undefined} href={`/category/${category.name}`}
onDragLeave={isEditMode ? handleDragLeave : undefined} onClick={onNavigate}
onDrop={isEditMode ? handleDrop : undefined} className="flex min-w-0 flex-1 items-center gap-2.5"
> >
{!isEditMode ? (
<Link href={`/category/${category.name}`} className="flex-1 flex items-center gap-2.5">
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />} {isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
<span>{category.name}</span> <span className="truncate">{category.name}</span>
</Link> </Link>
) : (
<div className="flex-1 flex items-center gap-2.5">
<Move size={14} className="text-gray-400" />
<span>{category.name}</span>
</div>
)}
{isEditMode && ( {hasChildren && (
<div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); onAdd(category.id); }} type="button"
className="p-1 text-green-600 hover:bg-green-100 rounded" onClick={(event) => {
title="하위 카테고리 추가" event.preventDefault();
> event.stopPropagation();
<Plus size={14} /> setIsExpanded((previous) => !previous);
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(category.id); }}
className="p-1 text-red-500 hover:bg-red-100 rounded"
title="카테고리 삭제"
>
<Trash2 size={14} />
</button>
</div>
)}
{!isEditMode && category.children && category.children.length > 0 && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
}} }}
className="p-1 hover:bg-gray-200/50 rounded-full transition-colors ml-1" className="ml-1 rounded-full p-1 transition-colors hover:bg-[var(--card-bg)]"
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
> >
<ChevronRight <ChevronRight
size={14} size={14}
className={clsx( className={clsx('text-[var(--color-text-subtle)] transition-transform duration-200', isChildrenVisible && 'rotate-90')}
"text-gray-400 transition-transform duration-200",
isExpanded && "rotate-90"
)}
/> />
</button> </button>
)} )}
</div> </div>
{isExpanded && sortedChildren.length > 0 && ( {isChildrenVisible && hasChildren && (
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in"> <div className="ml-4 border-l border-[var(--color-line)]">
{sortedChildren.map((child) => ( {sortedChildren.map((child) => (
<CategoryItem <CategoryItem
key={child.id} key={child.id}
category={child} category={child}
depth={0} depth={depth + 1}
onNavigate={onNavigate}
pathname={pathname} pathname={pathname}
isEditMode={isEditMode}
onDrop={onDrop}
onAdd={onAdd}
onDelete={onDelete}
/> />
))} ))}
</div> </div>
@@ -192,59 +121,23 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
); );
} }
function SidebarContent() { function SidebarContent({
const [isOpen, setIsOpen] = useState(true); isDesktopCollapsed,
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가 isMobileOpen,
onDesktopCollapsedChange,
onMobileOpenChange,
}: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { role, _hasHydrated } = useAuthStore();
const queryClient = useQueryClient();
// 🆕 검색 로직 추가
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const keyword = searchParams.get('keyword') || ''; const keyword = searchParams.get('keyword') || '';
useEffect(() => {
setIsMounted(true); // 🛠️ [Fix] 클라이언트 마운트 후 true로 설정
}, []);
const handleSearch = (newKeyword: string) => {
if (newKeyword.trim()) {
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
} else {
router.push('/');
}
};
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
});
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isCategoryEditMode, setIsCategoryEditMode] = useState(false);
const [isRootDragOver, setIsRootDragOver] = useState(false);
const isAdmin = _hasHydrated && role?.includes('ADMIN');
useEffect(() => {
if (!isAdmin) {
setIsCategoryEditMode(false);
setIsEditModalOpen(false);
}
}, [isAdmin]);
const { data: categories } = useQuery({ const { data: categories } = useQuery({
queryKey: ['categories'], queryKey: ['categories'],
queryFn: getCategories, queryFn: getCategories,
}); });
const sortedCategories = useMemo(() => { const sortedCategories = useMemo(() => sortCategories(categories), [categories]);
if (!categories) return undefined;
return [...categories].sort((a, b) => a.id - b.id);
}, [categories]);
const { data: profile, isLoading: isProfileLoading } = useQuery({ const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
@@ -252,147 +145,81 @@ function SidebarContent() {
retry: 0, retry: 0,
}); });
const createCategoryMutation = useMutation({ const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
mutationFn: createCategory, const decodedPathname = decodeURIComponent(pathname);
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
toast.success('카테고리가 생성되었습니다.');
},
onError: (err: any) => toast.error('생성 실패: ' + (err.response?.data?.message || err.message)),
});
const updateCategoryMutation = useMutation({ const closeSidebar = () => onMobileOpenChange(false);
mutationFn: ({ id, data }: { id: number; data: any }) => updateCategory(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
onError: (err: any) => toast.error('이동 실패: ' + (err.response?.data?.message || err.message)),
});
const deleteCategoryMutation = useMutation({ const handleSearch = (newKeyword: string) => {
mutationFn: deleteCategory, const trimmedKeyword = newKeyword.trim();
onSuccess: () => { if (trimmedKeyword) {
queryClient.invalidateQueries({ queryKey: ['categories'] }); router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
toast.success('카테고리가 삭제되었습니다.'); closeSidebar();
},
onError: (err: any) => toast.error('삭제 실패: ' + (err.response?.data?.message || err.message)),
});
const updateProfileMutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
setIsEditModalOpen(false);
toast.success('프로필이 수정되었습니다!');
},
onError: (error: any) => toast.error('수정 실패: ' + (error.response?.data?.message || error.message)),
});
const handleAddCategory = (parentId: number | null) => {
const name = prompt('새 카테고리 이름을 입력하세요:');
if (!name || !name.trim()) return;
createCategoryMutation.mutate({ name, parentId });
};
const handleDeleteCategory = (id: number) => {
if (confirm('정말로 이 카테고리를 삭제하시겠습니까?\n하위 카테고리와 게시글이 모두 삭제될 수 있습니다.')) {
deleteCategoryMutation.mutate(id);
}
};
const handleMoveCategory = useCallback((draggedId: number, targetParentId: number | null) => {
if (draggedId === targetParentId) return;
if (!categories) return;
const currentName = findCategoryNameById(categories, draggedId);
if (!currentName) {
toast.error('카테고리 정보를 찾을 수 없습니다.');
return; return;
} }
updateCategoryMutation.mutate({ router.push('/');
id: draggedId, closeSidebar();
data: { name: currentName, parentId: targetParentId }
});
}, [categories, updateCategoryMutation]);
const handleRootDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsRootDragOver(false);
const draggedId = Number(e.dataTransfer.getData('categoryId'));
if (!draggedId) return;
if (confirm('이 카테고리를 최상위로 이동하시겠습니까?')) {
handleMoveCategory(draggedId, null);
}
}; };
const defaultProfile: Profile = { const compactCategoryItems = sortedCategories?.slice(0, 8) ?? [];
name: 'Dev Park',
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
githubUrl: 'https://github.com',
email: 'user@example.com',
};
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
const handleEditClick = () => {
setEditForm({
name: displayProfile.name,
bio: displayProfile.bio,
imageUrl: displayProfile.imageUrl,
githubUrl: displayProfile.githubUrl || '',
email: displayProfile.email || '',
});
setIsEditModalOpen(true);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const res = await uploadImage(file);
if (res.code === 'SUCCESS' && res.data) {
setEditForm((prev) => ({ ...prev, imageUrl: res.data }));
}
} catch (error) {
toast.error('이미지 업로드 오류');
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateProfileMutation.mutate(editForm);
};
return ( return (
<> <>
<button onClick={() => setIsOpen(!isOpen)} className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors"> <button
{isOpen ? <X size={20} /> : <Menu size={20} />} type="button"
</button> aria-label="사이드바 닫기"
onClick={closeSidebar}
{/* 🛠️ [수정] overflow-y-auto 제거, overflow-hidden 추가 */} className={clsx(
<aside className={clsx('fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out flex flex-col overflow-hidden', isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0')}> 'fixed inset-0 z-50 bg-black/35 backdrop-blur-sm transition-opacity md:hidden',
isMobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
{/* 🛠️ [수정] 고정 영역: 프로필 (shrink-0 추가) */}
<div className={clsx('p-6 text-center transition-opacity duration-200 relative group shrink-0', !isOpen && 'md:opacity-0 md:hidden')}>
{isAdmin && (
<button onClick={handleEditClick} className="absolute top-4 right-4 p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-all opacity-0 group-hover:opacity-100 z-10" title="프로필 수정">
<Edit3 size={16} />
</button>
)} )}
<Link href="/" className="block hover:opacity-80 transition-opacity"> />
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
{/* 🛠️ [Fix] isMounted를 체크하여 첫 렌더링 시 무조건 스켈레톤을 보여줍니다. (서버와 일치시킴) */} <aside
{!isMounted || isProfileLoading ? ( className={clsx(
<div className="w-full h-full bg-gray-200 animate-pulse" /> 'fixed left-0 top-0 z-[60] flex h-screen w-72 max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-[var(--sidebar-bg)] shadow-[var(--shadow-sidebar)] backdrop-blur-[30px] transition-[transform,width] duration-300 ease-out md:left-4 md:top-14 md:z-40 md:h-[calc(100vh-9rem)] md:max-w-none md:translate-x-0 md:rounded-lg md:border',
isDesktopCollapsed ? 'md:w-[4.5rem]' : 'md:w-72',
isMobileOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4">
<div className={clsx('min-w-0', isDesktopCollapsed && 'md:hidden')}>
<p className="truncate text-sm font-bold text-[var(--color-text)]"></p>
<p className="truncate text-[11px] font-medium text-[var(--color-text-subtle)]"> </p>
</div>
<button
type="button"
onClick={() => onDesktopCollapsedChange(!isDesktopCollapsed)}
className="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] md:flex"
aria-label={isDesktopCollapsed ? '사이드바 펼치기' : '사이드바 접기'}
>
{isDesktopCollapsed ? <ChevronsRight size={16} /> : <ChevronsLeft size={16} />}
</button>
</div>
<div className={clsx('relative shrink-0', isDesktopCollapsed ? 'px-5 pb-4 pt-5 md:px-3 md:pb-3 md:pt-4' : 'px-5 pb-4 pt-5')}>
<Link
href="/"
onClick={closeSidebar}
className={clsx(
'flex min-w-0 items-center gap-3 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 transition hover:bg-[var(--card-bg-strong)]',
isDesktopCollapsed && 'md:justify-center md:border-transparent md:bg-transparent md:p-0 md:hover:bg-transparent',
)}
>
<div
className={clsx(
'relative shrink-0 overflow-hidden rounded-lg bg-black/[0.04] shadow-inner ring-1 ring-[var(--color-line)] transition-all dark:bg-white/10',
isDesktopCollapsed ? 'h-12 w-12' : 'h-12 w-12',
)}
>
{isProfileLoading ? (
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
) : ( ) : (
<Image <Image
src={displayProfile.imageUrl || defaultProfile.imageUrl!} src={displayProfile.imageUrl || defaultProfile.imageUrl!}
alt="Profile" alt="프로필"
fill fill
sizes="96px" sizes="96px"
className="object-cover" className="object-cover"
@@ -401,28 +228,27 @@ function SidebarContent() {
/> />
)} )}
</div> </div>
{/* 🛠️ [Fix] 텍스트 영역도 동일하게 처리 */}
{!isMounted || isProfileLoading ? ( <div className={clsx('min-w-0 text-left', isDesktopCollapsed && 'md:hidden')}>
<div className="space-y-2 flex flex-col items-center"><div className="h-6 w-24 bg-gray-200 rounded animate-pulse" /><div className="h-4 w-32 bg-gray-100 rounded animate-pulse" /></div> {isProfileLoading ? (
<div className="space-y-2">
<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> <h2 className="truncate text-sm font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
<p className="text-sm text-gray-500 mt-1 whitespace-pre-line leading-relaxed">{displayProfile.bio}</p> <p className="mt-0.5 line-clamp-2 whitespace-pre-line text-xs leading-5 text-[var(--color-text-muted)]">{displayProfile.bio}</p>
</> </>
)} )}
</div>
</Link> </Link>
</div> </div>
{/* 🛠️ [수정] min-h-0 추가하여 내부 스크롤 동작 보장 */} <nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
<nav className="flex-1 px-4 py-2 flex flex-col min-h-0"> <div className={clsx('flex h-full flex-col', isDesktopCollapsed && 'md:hidden')}>
<div className={clsx('flex flex-col items-center gap-4 mt-4 shrink-0', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
<div className={clsx('flex flex-col h-full', !isOpen && 'md:hidden')}>
{/* 🛠️ [수정] 상단 고정 네비게이션 영역 (shrink-0) */}
<div className="shrink-0 space-y-1"> <div className="shrink-0 space-y-1">
{/* 🆕 전체 검색바 (Archives 위) */} <div id="blog-search" className="mb-5 mt-2 px-1">
<div className="px-1 mb-6 mt-2">
<PostSearch <PostSearch
onSearch={handleSearch} onSearch={handleSearch}
placeholder="검색..." placeholder="검색..."
@@ -430,128 +256,115 @@ function SidebarContent() {
/> />
</div> </div>
{/* 2. 아카이브 링크 */} <div className="mb-4 border-t border-[var(--color-line)]" />
<div className="mb-4">
<Link <div className="mb-3 flex h-8 items-center justify-between px-3">
href="/archive" <p className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-subtle)]"></p>
className={clsx( </div>
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all group',
pathname === '/archive'
? 'bg-blue-50 text-blue-600 font-medium'
: 'text-gray-600 hover:bg-gray-50'
)}
>
<Archive size={16} />
<span>Archives</span>
</Link>
</div> </div>
{/* 구분선 */} <div className="-mx-2 flex-1 overflow-y-auto px-2 scrollbar-hide">
<div className="border-t border-gray-100 mb-4" /> {!categories && (
<div className="space-y-2 px-4">
{/* 1. 카테고리 섹션 헤더 (고정) */} {[1, 2, 3].map((item) => (
<div className="flex items-center justify-between px-4 mb-3 h-8"> <div key={item} className="h-8 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p> ))}
{isAdmin && (
<div className="flex items-center gap-1">
{!isCategoryEditMode ? (
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
) : (
<>
<button onClick={() => handleAddCategory(null)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="최상위 카테고리 추가"><Plus size={16} /></button>
<button onClick={() => setIsCategoryEditMode(false)} className="p-1 text-gray-500 hover:bg-gray-100 rounded" title="관리 종료"><X size={16} /></button>
</>
)}
</div> </div>
)} )}
</div>
</div>
{/* 🛠️ [수정] 스크롤 가능한 카테고리 리스트 영역 (flex-1, overflow-y-auto) */} <div className="mb-6 min-h-[50px] rounded-lg">
<div className="flex-1 overflow-y-auto scrollbar-hide -mx-2 px-2"> {sortedCategories?.map((category) => (
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>} <CategoryItem
key={category.id}
<div category={category}
className={clsx("min-h-[50px] transition-colors rounded-lg mb-6", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")} depth={0}
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined} onNavigate={closeSidebar}
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined} pathname={pathname}
onDrop={isCategoryEditMode ? handleRootDrop : undefined} />
>
{sortedCategories?.map((cat) => (
<CategoryItem key={cat.id} category={cat} depth={0} pathname={pathname} isEditMode={isCategoryEditMode} onDrop={handleMoveCategory} onAdd={handleAddCategory} onDelete={handleDeleteCategory} />
))} ))}
{!isCategoryEditMode && (
<div className="mb-1 mt-2"> <div className="mb-1 mt-2">
<Link <Link
href="/category/uncategorized" href="/category/uncategorized"
onClick={closeSidebar}
className={clsx( className={clsx(
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all', 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
pathname === '/category/uncategorized' pathname === '/category/uncategorized'
? 'bg-blue-50 text-blue-600 font-medium' ? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-gray-600 hover:bg-gray-50' : 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)} )}
> >
<FileQuestion size={16} /> <FileQuestion size={16} />
<span></span> <span></span>
</Link> </Link>
</div> </div>
)}
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ .</div>}
</div> </div>
</div> </div>
</div> </div>
<div className={clsx('hidden flex-1 flex-col items-center gap-2 overflow-y-auto px-0 pb-4 pt-2 md:flex', !isDesktopCollapsed && 'md:hidden')}>
{compactCategoryItems.map((category) => {
const isActive = decodedPathname === `/category/${category.name}`;
return (
<Link
key={category.id}
href={`/category/${category.name}`}
title={category.name}
aria-label={category.name}
className={clsx(
'flex h-11 w-11 items-center justify-center rounded-xl border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]',
isActive && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)]',
)}
>
<Folder size={18} />
</Link>
);
})}
<Link
href="/category/uncategorized"
title="미분류"
aria-label="미분류"
className={clsx(
'flex h-11 w-11 items-center justify-center rounded-xl border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]',
pathname === '/category/uncategorized' && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)]',
)}
>
<FileQuestion size={18} />
</Link>
</div>
</nav> </nav>
{/* 🛠️ [수정] 고정 영역: 푸터 (shrink-0 추가) */} <div
<div className={clsx('p-6 border-t border-gray-100 bg-white shrink-0', !isOpen && 'hidden')}> className={clsx(
<div className="flex justify-center gap-3"> 'shrink-0 border-t border-[var(--color-line)] bg-[var(--window-titlebar)] p-5',
<a href={displayProfile.githubUrl || '#'} target="_blank" rel="noreferrer" className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm"><Github size={18} /></a> isDesktopCollapsed && 'md:hidden',
<a href={`mailto:${displayProfile.email}`} className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm"><Mail size={18} /></a> )}
</div> >
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.</p> <p className="text-center text-[10px] font-light text-[var(--color-text-subtle)]">
© {new Date().getFullYear()} {displayProfile.name}
</p>
</div> </div>
</aside> </aside>
{isEditModalOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2"><Edit3 size={18} className="text-blue-600" /> </h3>
<button onClick={() => setIsEditModalOpen(false)} className="text-gray-400 hover:text-gray-600"><XCircle size={24} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="flex flex-col items-center mb-6">
<div className="relative w-24 h-24 mb-3 group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
<Image src={editForm.imageUrl || defaultProfile.imageUrl!} alt="Preview" fill className="rounded-full object-cover border-2 border-gray-100 group-hover:border-blue-300 transition-colors" unoptimized />
<div className="absolute inset-0 bg-black/30 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"><Camera className="text-white" size={24} /></div>
</div>
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImageUpload} />
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-600 font-medium hover:underline" disabled={isUploading}>{isUploading ? '업로드 중...' : '이미지 변경'}</button>
</div>
<div><label className="block text-xs font-bold text-gray-500 mb-1"> (Name)</label><input type="text" value={editForm.name} onChange={(e) => setEditForm({...editForm, name: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" required /></div>
<div><label className="block text-xs font-bold text-gray-500 mb-1"> (Bio)</label><textarea value={editForm.bio} onChange={(e) => setEditForm({...editForm, bio: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm resize-none h-24" required /></div>
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-xs font-bold text-gray-500 mb-1">Github URL</label><input type="url" value={editForm.githubUrl || ''} onChange={(e) => setEditForm({...editForm, githubUrl: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
<div><label className="block text-xs font-bold text-gray-500 mb-1">Email</label><input type="email" value={editForm.email || ''} onChange={(e) => setEditForm({...editForm, email: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
</div>
<div className="pt-4 flex gap-2">
<button type="button" onClick={() => setIsEditModalOpen(false)} className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors"></button>
<button type="submit" disabled={updateProfileMutation.isPending || isUploading} className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-sm disabled:bg-gray-400 flex justify-center items-center gap-2">{updateProfileMutation.isPending ? '저장 중...' : <><Save size={16} /> </>}</button>
</div>
</form>
</div>
</div>
)}
</> </>
); );
} }
export default function Sidebar() { export default function Sidebar(props: SidebarProps) {
return ( return (
<Suspense fallback={<div className="w-72 h-screen bg-white border-r border-gray-100" />}> <Suspense
<SidebarContent /> fallback={(
<div
className={clsx(
'h-screen border-r border-[var(--sidebar-border)] bg-[var(--sidebar-bg)]',
props.isDesktopCollapsed ? 'w-20' : 'w-72',
)}
/>
)}
>
<SidebarContent {...props} />
</Suspense> </Suspense>
); );
} }

View File

@@ -1,49 +1,102 @@
// src/components/layout/TopHeader.tsx // src/components/layout/TopHeader.tsx
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { useEffect, useState } from 'react'; import { LogOut, Menu, PenLine, Settings, User, UserPlus, X } from 'lucide-react';
import { LogOut, PenLine, User, UserPlus } from 'lucide-react'; import ThemeToggle from '@/components/theme/ThemeToggle';
export default function TopHeader() { export default function TopHeader() {
const router = useRouter(); const router = useRouter();
const { isLoggedIn, role, logout } = useAuthStore(); // 👈 role 가져오기 const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
const [mounted, setMounted] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const isAdmin = _hasHydrated && role?.includes('ADMIN');
useEffect(() => { useEffect(() => {
setMounted(true); const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
setIsMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsMenuOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []); }, []);
const closeMenu = () => setIsMenuOpen(false);
const handleLogout = () => { const handleLogout = () => {
if (confirm('로그아웃 하시겠습니까?')) { if (confirm('로그아웃 하시겠습니까?')) {
closeMenu();
logout(); logout();
alert('로그아웃 되었습니다.'); alert('로그아웃 되었습니다.');
router.push('/'); router.push('/');
} }
}; };
if (!mounted) return null; const quietActionClass =
'flex h-9 shrink-0 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]';
return ( return (
<div className="absolute top-6 right-6 z-30 flex items-center gap-3"> <div
{isLoggedIn ? ( ref={menuRef}
className="absolute right-4 top-4 z-30 flex max-w-[calc(100vw-2rem)] items-center justify-end gap-2 md:right-6 md:top-6"
>
<div
className={[
'flex min-w-0 items-center gap-2 overflow-hidden transition-all duration-300 ease-out',
isMenuOpen
? 'max-w-[calc(100vw-5rem)] translate-x-0 opacity-100'
: 'pointer-events-none max-w-0 translate-x-3 opacity-0',
].join(' ')}
aria-hidden={!isMenuOpen}
>
<div className="flex min-w-max items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--card-bg)] p-1.5 shadow-[var(--shadow-control)] backdrop-blur-[18px]">
<ThemeToggle />
{_hasHydrated && (
isLoggedIn ? (
<>
{isAdmin && (
<> <>
{/* 👇 관리자(ADMIN)일 때만 글쓰기 버튼 노출 */}
{role && role.includes('ADMIN') && (
<Link <Link
href="/write" href="/admin"
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" onClick={closeMenu}
className={quietActionClass}
>
<Settings size={16} />
<span className="hidden sm:inline"></span>
</Link>
<Link
href="/admin/posts/new"
onClick={closeMenu}
className="flex h-9 shrink-0 items-center gap-2 rounded-full bg-[var(--color-accent)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition-colors hover:bg-[var(--color-accent-hover)]"
> >
<PenLine size={16} /> <PenLine size={16} />
<span></span> <span> </span>
</Link> </Link>
</>
)} )}
<button <button
type="button"
onClick={handleLogout} 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 h-9 shrink-0 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-medium text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:bg-[var(--card-bg-strong)] hover:text-red-500"
> >
<LogOut size={16} /> <LogOut size={16} />
<span className="hidden sm:inline"></span> <span className="hidden sm:inline"></span>
@@ -53,21 +106,36 @@ export default function TopHeader() {
<> <>
<Link <Link
href="/login" 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" onClick={closeMenu}
className="flex h-9 shrink-0 items-center gap-2 rounded-full border border-transparent px-2.5 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)] sm:px-3"
> >
<User size={18} /> <User size={18} />
<span></span> <span className="hidden sm:inline"></span>
</Link> </Link>
<Link <Link
href="/signup" 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" onClick={closeMenu}
className="flex h-9 shrink-0 items-center gap-2 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-all hover:bg-[var(--card-bg-strong)]"
> >
<UserPlus size={16} /> <UserPlus size={16} />
<span></span> <span className="hidden sm:inline"></span>
</Link> </Link>
</> </>
)
)} )}
</div> </div>
</div>
<button
type="button"
aria-label={isMenuOpen ? '상단 메뉴 닫기' : '상단 메뉴 열기'}
aria-expanded={isMenuOpen}
onClick={() => setIsMenuOpen((previous) => !previous)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-[var(--control-border)] bg-[var(--color-control)] text-[var(--color-text)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:bg-[var(--card-bg-strong)]"
>
{isMenuOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
); );
} }

View File

@@ -0,0 +1,253 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { clsx } from 'clsx';
import { Calendar, ChevronRight, FileText, Rows3, SlidersHorizontal } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import { Post } from '@/types';
type ArchiveExplorerProps = {
posts: Post[];
};
type Density = 'comfortable' | 'compact';
const ALL = 'all';
const getPostDate = (post: Post) => {
const date = new Date(post.createdAt);
return Number.isNaN(date.getTime()) ? null : date;
};
const getMonthKey = (date: Date) => {
return (date.getMonth() + 1).toString().padStart(2, '0');
};
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);
};
const sortNewestFirst = (posts: Post[]) => {
return [...posts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
};
export default function ArchiveExplorer({ posts }: ArchiveExplorerProps) {
const [selectedYear, setSelectedYear] = useState(ALL);
const [selectedMonth, setSelectedMonth] = useState(ALL);
const [selectedCategory, setSelectedCategory] = useState(ALL);
const [density, setDensity] = useState<Density>('compact');
const years = useMemo(() => {
return Array.from(new Set(posts.map(getPostDate).filter(Boolean).map((date) => date!.getFullYear().toString())))
.sort((a, b) => Number(b) - Number(a));
}, [posts]);
const monthOptions = useMemo(() => {
return Array.from(new Set(posts
.map((post) => {
const date = getPostDate(post);
if (!date) return null;
if (selectedYear !== ALL && date.getFullYear().toString() !== selectedYear) return null;
return getMonthKey(date);
})
.filter(Boolean) as string[]))
.sort((a, b) => Number(b) - Number(a));
}, [posts, selectedYear]);
const categories = useMemo(() => {
return Array.from(new Set(posts.map((post) => post.categoryName || '미분류'))).sort((a, b) => a.localeCompare(b, 'ko-KR'));
}, [posts]);
useEffect(() => {
if (selectedMonth !== ALL && !monthOptions.includes(selectedMonth)) {
setSelectedMonth(ALL);
}
}, [monthOptions, selectedMonth]);
const filteredPosts = useMemo(() => {
return sortNewestFirst(posts.filter((post) => {
const date = getPostDate(post);
if (!date) return false;
const year = date.getFullYear().toString();
const month = getMonthKey(date);
const category = post.categoryName || '미분류';
return (
(selectedYear === ALL || year === selectedYear)
&& (selectedMonth === ALL || month === selectedMonth)
&& (selectedCategory === ALL || category === selectedCategory)
);
}));
}, [posts, selectedCategory, selectedMonth, selectedYear]);
const groupedPosts = useMemo(() => {
const groups: Record<string, Record<string, Post[]>> = {};
filteredPosts.forEach((post) => {
const date = getPostDate(post);
if (!date) return;
const year = date.getFullYear().toString();
const month = getMonthKey(date);
groups[year] ??= {};
groups[year][month] ??= [];
groups[year][month].push(post);
});
return groups;
}, [filteredPosts]);
const sortedYears = Object.keys(groupedPosts).sort((a, b) => Number(b) - Number(a));
const isCompact = density === 'compact';
return (
<div className="min-w-0 space-y-7">
<Surface className="p-3 shadow-none md:p-4">
<div className="grid min-w-0 gap-3 md:grid-cols-[repeat(3,minmax(0,1fr))_auto]">
<label className="min-w-0 text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
<select
value={selectedYear}
onChange={(event) => setSelectedYear(event.target.value)}
className="mt-1 h-10 w-full rounded-lg border px-3 text-sm font-semibold normal-case"
>
<option value={ALL}> </option>
{years.map((year) => (
<option key={year} value={year}>{year}</option>
))}
</select>
</label>
<label className="min-w-0 text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
<select
value={selectedMonth}
onChange={(event) => setSelectedMonth(event.target.value)}
className="mt-1 h-10 w-full rounded-lg border px-3 text-sm font-semibold normal-case"
>
<option value={ALL}> </option>
{monthOptions.map((month) => (
<option key={month} value={month}>{Number(month)}</option>
))}
</select>
</label>
<label className="min-w-0 text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
className="mt-1 h-10 w-full rounded-lg border px-3 text-sm font-semibold normal-case"
>
<option value={ALL}> </option>
{categories.map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
</label>
<div className="flex items-end">
<button
type="button"
aria-pressed={isCompact}
onClick={() => setDensity((previous) => (previous === 'compact' ? 'comfortable' : 'compact'))}
className={clsx(
'inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-colors hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] md:w-auto',
isCompact && 'text-[var(--color-accent)]',
)}
>
{isCompact ? <Rows3 size={16} /> : <SlidersHorizontal size={16} />}
<span>{isCompact ? 'Compact' : 'Comfort'}</span>
</button>
</div>
</div>
</Surface>
<div className="flex min-w-0 items-center justify-between gap-3 border-b border-[var(--color-line)] pb-4">
<p className="min-w-0 break-words text-sm font-semibold text-[var(--color-text-muted)]">
<span className="text-[var(--color-accent)]">{filteredPosts.length.toLocaleString()}</span>
</p>
</div>
{sortedYears.length > 0 ? (
<div className={clsx('relative', isCompact ? 'space-y-8' : 'space-y-12')}>
<div className="absolute bottom-4 left-4 top-4 hidden w-px bg-[var(--color-line)] md:block" />
{sortedYears.map((year) => {
const months = groupedPosts[year];
const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a));
return (
<div key={year} className="relative min-w-0">
<div className={clsx('flex items-center gap-4', isCompact ? 'mb-4' : 'mb-6')}>
<div className="z-10 hidden h-9 w-9 items-center justify-center rounded-full border-4 border-[var(--window-bg-strong)] bg-[var(--color-accent-soft)] shadow-[var(--shadow-control)] md:flex">
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" />
</div>
<h2 className="text-2xl font-bold text-[var(--color-text)]">{year}</h2>
</div>
<div className={clsx(isCompact ? 'space-y-5 md:pl-12' : 'space-y-8 md:pl-12')}>
{sortedMonths.map((month) => {
const monthPosts = months[month];
return (
<div key={month} className="min-w-0">
<h3 className={clsx('flex items-center gap-2 font-bold text-[var(--color-text-muted)]', isCompact ? 'mb-2 text-base' : 'mb-4 text-lg')}>
<Calendar size={18} className="shrink-0 text-[var(--color-text-subtle)]" />
{Number(month)}
<StatusBadge tone="neutral">{monthPosts.length}</StatusBadge>
</h3>
<div className={clsx('grid min-w-0', isCompact ? 'gap-2' : 'gap-3')}>
{monthPosts.map((post) => (
<Link
key={post.id}
href={`/posts/${post.slug}`}
className="group/item block min-w-0"
>
<Surface interactive className={clsx('flex min-w-0 items-center justify-between gap-3 shadow-none', isCompact ? 'p-3' : 'p-4')}>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<FileText size={17} />
</span>
<div className="min-w-0 flex-1">
<h4 className="line-clamp-2 break-words text-sm font-semibold text-[var(--color-text)] transition-colors group-hover/item:text-[var(--color-accent)] md:text-base">
{post.title}
</h4>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-2 text-xs text-[var(--color-text-subtle)]">
<StatusBadge tone="neutral" className="px-1.5 py-0.5">{post.categoryName || '미분류'}</StatusBadge>
<span className="tabular-nums">
{formatDate(post.createdAt)}
</span>
</div>
</div>
<ChevronRight className="shrink-0 text-[var(--color-text-subtle)] transition-colors group-hover/item:text-[var(--color-accent)]" size={18} />
</Surface>
</Link>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<EmptyState title="조건에 맞는 글이 없습니다." className="py-20" />
)}
</div>
);
}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import React, { useState } from 'react'; import { useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize from 'rehype-sanitize';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -15,19 +16,14 @@ interface MarkdownRendererProps {
} }
export default function MarkdownRenderer({ content }: MarkdownRendererProps) { export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return ( const components: Components = {
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeSlug]}
components={{
// 1. 코드 블록 커스텀 // 1. 코드 블록 커스텀
code({ node, inline, className, children, ...props }: any) { code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : ''; const language = match ? match[1] : '';
const codeString = String(children).replace(/\n$/, ''); const codeString = String(children).replace(/\n$/, '');
if (!inline && match) { if (match) {
return ( return (
<CodeBlock language={language} code={codeString} /> <CodeBlock language={language} code={codeString} />
); );
@@ -35,7 +31,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return ( return (
<code <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" className="mx-1 max-w-full break-words rounded-md bg-black/[0.05] px-1.5 py-0.5 font-mono text-[0.9em] font-medium text-red-600 [overflow-wrap:anywhere] dark:bg-white/10 dark:text-red-300"
{...props} {...props}
> >
{children} {children}
@@ -46,7 +42,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
// 2. 인용구 // 2. 인용구
blockquote({ children }) { blockquote({ children }) {
return ( 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"> <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} {children}
</blockquote> </blockquote>
); );
@@ -60,10 +56,10 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
href={href} href={href}
target={isExternal ? '_blank' : undefined} target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : 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" className="break-words font-semibold text-[var(--color-accent)] underline-offset-4 transition-colors [overflow-wrap:anywhere] hover:underline"
> >
{children} {children}
{isExternal && <ExternalLink size={12} className="opacity-70" />} {isExternal && <ExternalLink size={12} className="ml-0.5 inline-block align-text-bottom opacity-70" />}
</a> </a>
); );
}, },
@@ -71,51 +67,50 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
// 4. 테이블 // 4. 테이블
table({ children }) { table({ children }) {
return ( return (
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm"> <div className="my-8 max-w-full overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
<table className="w-full text-sm text-left text-gray-700 bg-white"> <table className="w-full min-w-[520px] text-left text-sm text-[var(--color-text-muted)]">
{children} {children}
</table> </table>
</div> </div>
); );
}, },
thead({ children }) { thead({ children }) {
return <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200">{children}</thead>; 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 }) { th({ children }) {
return <th className="px-6 py-3 font-bold text-gray-900">{children}</th>; return <th className="break-words px-5 py-3 font-bold text-[var(--color-text)]">{children}</th>;
}, },
td({ children }) { td({ children }) {
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>; return <td className="whitespace-pre-wrap break-words border-b border-[var(--color-line)] px-5 py-4">{children}</td>;
}, },
// 5. 이미지 (비율 유지 및 중앙 정렬) // 5. 이미지
img({ src, alt }) { img({ src, alt }) {
return ( return (
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬 <span className="my-8 flex flex-col items-center justify-center">
// items-center 추가: 가로축 중앙 정렬 {/* eslint-disable-next-line @next/next/no-img-element */}
<span className="block my-8 flex flex-col items-center justify-center">
<img <img
src={src} src={src}
alt={alt} 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" 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" loading="lazy"
onError={(e) => { onError={(event) => {
e.currentTarget.style.display = 'none'; event.currentTarget.style.display = 'none';
}} }}
/> />
{alt && <span className="block text-center text-sm text-gray-400 mt-2 w-full">{alt}</span>} {alt && <span className="mt-3 block w-full text-center text-sm text-[var(--color-text-subtle)]">{alt}</span>}
</span> </span>
); );
}, },
// 6. 리스트 스타일 // 6. 리스트 스타일
ul({ children }) { ul({ children }) {
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>; 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 }: any) { ol({ children, ...props }) {
return ( return (
<ol <ol
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium" className="my-4 list-decimal space-y-2 pl-6 font-medium text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]"
{...props} {...props}
> >
{children} {children}
@@ -126,17 +121,24 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return <li className="pl-1">{children}</li>; return <li className="pl-1">{children}</li>;
}, },
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함) // 7. 헤딩 스타일
h1({ children, ...props }: any) { h1({ children, ...props }) {
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900" {...props}>{children}</h1>; 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 }: any) { h2({ children, ...props }) {
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>; return <h2 className="mt-11 mb-5 text-2xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h2>;
}, },
h3({ children, ...props }: any) { h3({ children, ...props }) {
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>; 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={components}
> >
{content} {content}
</ReactMarkdown> </ReactMarkdown>
@@ -155,18 +157,18 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
}; };
return ( 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 max-w-full 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 min-w-0 items-center justify-between gap-3 border-b border-gray-700 bg-[#2d2d2d] px-4 py-2.5 select-none">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-[#FF5F56] border border-[#E0443E]" /> <div className="w-3 h-3 rounded-full bg-[#FF5F56] border border-[#E0443E]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E] border border-[#DEA123]" /> <div className="w-3 h-3 rounded-full bg-[#FFBD2E] border border-[#DEA123]" />
<div className="w-3 h-3 rounded-full bg-[#27C93F] border border-[#1AAB29]" /> <div className="w-3 h-3 rounded-full bg-[#27C93F] border border-[#1AAB29]" />
</div> </div>
{language && ( {language && (
<div className="ml-4 flex items-center gap-1.5 px-2 py-0.5 rounded text-[10px] font-mono font-medium text-gray-400 bg-gray-700/50 border border-gray-600/50"> <div className="ml-2 flex min-w-0 items-center gap-1.5 rounded border border-gray-600/50 bg-gray-700/50 px-2 py-0.5 font-mono text-[10px] font-medium text-gray-400 sm:ml-4">
<Terminal size={10} /> <Terminal size={10} />
<span className="uppercase tracking-wider">{language}</span> <span className="truncate uppercase tracking-wider">{language}</span>
</div> </div>
)} )}
</div> </div>
@@ -174,7 +176,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
<button <button
onClick={handleCopy} onClick={handleCopy}
className={clsx( className={clsx(
"flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-all duration-200 border", "flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium transition-all duration-200",
isCopied isCopied
? "bg-green-500/10 text-green-400 border-green-500/20" ? "bg-green-500/10 text-green-400 border-green-500/20"
: "bg-gray-700/50 text-gray-400 border-transparent hover:bg-gray-600 hover:text-white" : "bg-gray-700/50 text-gray-400 border-transparent hover:bg-gray-600 hover:text-white"
@@ -182,21 +184,23 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
title="코드 복사" title="코드 복사"
> >
{isCopied ? <Check size={12} /> : <Copy size={12} />} {isCopied ? <Check size={12} /> : <Copy size={12} />}
<span>{isCopied ? 'Copied!' : 'Copy'}</span> <span>{isCopied ? '복사됨' : '복사'}</span>
</button> </button>
</div> </div>
<div className="relative font-mono text-[14px] leading-relaxed"> <div className="relative max-w-full overflow-x-auto font-mono text-[14px] leading-relaxed">
<SyntaxHighlighter <SyntaxHighlighter
style={vscDarkPlus} style={vscDarkPlus}
language={language} language={language}
PreTag="div" PreTag="div"
showLineNumbers={true} showLineNumbers={true}
wrapLongLines={false}
lineNumberStyle={{ minWidth: '2.5em', paddingRight: '1em', color: '#6e7681', textAlign: 'right' }} lineNumberStyle={{ minWidth: '2.5em', paddingRight: '1em', color: '#6e7681', textAlign: 'right' }}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: '1.5rem', padding: '1.5rem',
background: 'transparent', background: 'transparent',
maxWidth: '100%',
}} }}
> >
{code} {code}

View File

@@ -1,75 +1,73 @@
import { Post } from '@/types';
import Link from 'next/link'; import Link from 'next/link';
import { format } from 'date-fns'; import { FileText } from 'lucide-react';
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용 import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import { Post } from '@/types';
const isNoticePost = (post: Post) => {
const categoryName = post.categoryName || '';
return categoryName === '공지' || 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) { function getSummary(content?: string) {
if (!content) return ''; if (!content) return '';
return content return content
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거 .replace(/```[\s\S]*?```/g, ' ')
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자 .replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/\n/g, ' ') // 줄바꿈을 공백으로 .replace(/\[(.*?)\]\(.*?\)/g, '$1')
.substring(0, 120); // 120자까지만 자르기 .replace(/[#*`_~>]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
} }
export default function PostCard({ post }: { post: Post }) { export default function PostCard({ post }: { post: Post }) {
// 요약문 생성 const isNotice = isNoticePost(post);
const summary = getSummary(post.content); const summary = getSummary(post.content);
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
return ( return (
<Link href={`/posts/${post.slug}`} className="block group h-full"> <Link href={`/posts/${post.slug}`} className="group block h-full">
<article className={clsx( <Surface
"flex flex-col h-full bg-white rounded-2xl p-6 transition-all duration-300 border", as="article"
// 공지글이면 테두리에 살짝 붉은 기운을 줌 interactive
isNotice className="flex h-full min-h-52 flex-col p-5 shadow-none"
? "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)]", <div className="mb-4 flex items-center justify-between gap-3">
"hover:-translate-y-1" <div className="flex min-w-0 items-center gap-2">
)}> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)]">
<FileText size={17} />
{/* 상단 카테고리 & 날짜 */}
<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> </span>
<time className="text-gray-400 font-light"> <StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
{format(new Date(post.createdAt), 'MMM dd, yyyy')} {post.categoryName || '미분류'}
</StatusBadge>
</div>
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time> </time>
</div> </div>
{/* 제목 */} <h2 className="line-clamp-2 text-xl font-bold leading-snug tracking-normal text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
<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"
)}>
{post.title} {post.title}
</h2> </h2>
{/* 요약글 */} {summary && (
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]"> <p className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
{summary} {summary}
</p> </p>
)}
{/* 하단 정보 */} </Surface>
<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>
</span>
</div>
</article>
</Link> </Link>
); );
} }

View File

@@ -1,34 +1,48 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getPost, deletePost } from '@/api/posts'; import { AlertCircle, ArrowLeft, Calendar, ChevronLeft, ChevronRight, Loader2, Monitor, User } from 'lucide-react';
import { getProfile } from '@/api/profile'; import Image from 'next/image';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
import CommentList from '@/components/comment/CommentList';
import TOC from '@/components/post/TOC';
import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import Link from 'next/link'; import Link from 'next/link';
import { Post } from '@/types'; // 타입 임포트 import { useRouter } from 'next/navigation';
import { getPost } from '@/api/posts';
import { getProfile } from '@/api/profile';
import CommentList from '@/components/comment/CommentList';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
import TOC from '@/components/post/TOC';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
import { Post } from '@/types';
interface PostDetailClientProps { interface PostDetailClientProps {
slug: string; slug: string;
initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수) initialPost: Post;
} }
const getPostErrorInfo = (error: unknown) => {
if (typeof error === 'object' && error !== null && 'response' in error) {
const response = (error as { response?: { status?: number; data?: { message?: string } } }).response;
return {
status: response?.status,
message: response?.data?.message,
};
}
return {
status: undefined,
message: error instanceof Error ? error.message : undefined,
};
};
export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) { export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
// 1. 게시글 상세 조회
const { data: post, isLoading: isPostLoading, error } = useQuery({ const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug], queryKey: ['post', slug],
queryFn: () => getPost(slug), queryFn: () => getPost(slug),
enabled: !!slug, enabled: !!slug,
// 🌟 핵심: 서버에서 가져온 데이터를 초기값으로 사용하여 즉시 렌더링 (Hydration)
initialData: initialPost, initialData: initialPost,
}); });
@@ -37,131 +51,155 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
queryFn: getProfile, queryFn: getProfile,
}); });
const deleteMutation = useMutation({
mutationFn: deletePost,
onSuccess: () => {
alert('게시글이 삭제되었습니다.');
queryClient.invalidateQueries({ queryKey: ['posts'] });
router.push('/');
},
onError: (err: any) => {
alert('삭제 실패: ' + (err.response?.data?.message || err.message));
},
});
// 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다.
// initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다.
if (isPostLoading) { if (isPostLoading) {
return ( return (
<div className="flex justify-center items-center h-screen"> <div className="flex h-screen items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={40} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div> </div>
); );
} }
// 에러 처리
if (error || !post) { if (error || !post) {
const errorStatus = (error as any)?.response?.status; const { status: errorStatus, message } = getPostErrorInfo(error);
const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.'; const errorMessage = message || '게시글을 찾을 수 없습니다.';
const isAuthError = errorStatus === 401 || errorStatus === 403; const isAuthError = errorStatus === 401 || errorStatus === 403;
return ( return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center"> <WindowSurface className="mx-auto max-w-[980px]" bodyClassName="px-4 py-20 text-center">
<div className="flex justify-center mb-4"> <div className="mb-4 flex justify-center">
<AlertCircle className="text-gray-300" size={64} /> <AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
</div> </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 ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'} {isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
</h2> </h2>
<p className="text-gray-500 mb-6"> <p className="mb-6 text-[var(--color-text-muted)]">
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage} {isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
</p> </p>
<div className="flex justify-center gap-3"> <button
<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> type="button"
</div> onClick={() => router.push('/')}
</div> className="rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-[var(--card-bg-strong)]"
>
</button>
</WindowSurface>
); );
} }
// 백엔드 데이터 사용 (이전글/다음글)
const prevPost = post.prevPost; const prevPost = post.prevPost;
const nextPost = post.nextPost; const nextPost = post.nextPost;
const handleDelete = () => {
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
deleteMutation.mutate(post.id);
}
};
const handleEdit = () => {
router.push(`/write?slug=${post.slug}`);
};
return ( return (
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12"> <div className="mx-auto min-w-0 max-w-[1120px] px-0 py-3 md:py-6">
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors"> <Link href="/" className="mb-5 inline-flex items-center gap-1 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] px-3 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-[18px] transition-colors hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-accent)]">
<ArrowLeft size={18} /> <ArrowLeft size={18} />
<span className="text-sm font-medium"></span> <span>Finder</span>
</Link> </Link>
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative"> <div className="relative grid min-w-0 gap-6 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-8">
<main className="min-w-0 space-y-6">
<main className="min-w-0 xl:flex-1"> <WindowSurface
<article> as="article"
<header className="mb-10 border-b border-gray-100 pb-8"> title="Reader"
<div className="flex justify-between items-start mb-4"> subtitle={post.categoryName || '미분류'}
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full"> className="mx-auto w-full max-w-[820px]"
<Folder size={14} /> bodyClassName="px-5 py-7 md:px-10 md:py-10"
<span>{post.categoryName || 'Uncategorized'}</span> controls={(
</div> <div className="hidden items-center gap-1.5 text-xs font-semibold text-[var(--color-text-subtle)] sm:flex">
{isAdmin && ( <Monitor size={14} />
<div className="flex gap-2"> <span className="max-w-44 truncate">{post.slug}</span>
<button onClick={handleEdit} className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"><Edit2 size={18} /></button>
<button onClick={handleDelete} className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"><Trash2 size={18} /></button>
</div> </div>
)} )}
>
<header className="mb-9 border-b border-[var(--color-line)] pb-7">
<StatusBadge tone="neutral" className="mb-5">
{post.categoryName || '미분류'}
</StatusBadge>
<h1 className="mb-6 break-words text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-4xl lg:text-[2.75rem]">
{post.title}
</h1>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-[var(--color-text-muted)]">
<div className="flex min-w-0 items-center gap-2">
{profile?.imageUrl ? (
<Image
src={profile.imageUrl}
alt="작성자"
width={32}
height={32}
className="h-8 w-8 shrink-0 rounded-full border border-[var(--color-line)] object-cover shadow-sm"
unoptimized
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10">
<User size={16} />
</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"> <span className="min-w-0 truncate font-bold text-[var(--color-text)]">{profile?.name || 'Dev Park'}</span>
<div className="flex items-center gap-2"> </div>
{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>} <div className="flex items-center gap-1.5">
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span> <Calendar size={16} />
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
</div> </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> </div>
</header> </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-base min-w-0 max-w-none break-words prose-headings:font-bold prose-headings:tracking-normal prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-p:leading-8 prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-li:leading-8 prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 md:prose-lg [overflow-wrap:anywhere]">
<MarkdownRenderer content={post.content || ''} /> <MarkdownRenderer content={post.content || ''} />
</div> </div>
</article> </WindowSurface>
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16"> <WindowSurface title="Navigation" showTrafficLights={false} className="mx-auto max-w-[820px]" bodyClassName="p-4 md:p-5">
<nav className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
{prevPost ? ( {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"> <Link href={`/posts/${prevPost.slug}`} className="group flex min-w-0 flex-col items-start gap-1 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-4 shadow-[var(--shadow-card)] transition-colors hover:bg-[var(--card-bg-strong)] md:p-5">
<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="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span> <ChevronLeft size={16} />
</span>
<span className="line-clamp-2 w-full break-words text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">
{prevPost.title}
</span>
</Link> </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>} ) : (
<Surface className="hidden w-full cursor-not-allowed p-5 opacity-60 md:block">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">
<ChevronLeft size={16} />
</span>
</Surface>
)}
{nextPost ? ( {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"> <Link href={`/posts/${nextPost.slug}`} className="group flex min-w-0 flex-col items-end gap-1 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-4 shadow-[var(--shadow-card)] transition-colors hover:bg-[var(--card-bg-strong)] md:p-5">
<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="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
<ChevronRight size={16} />
</span>
<span className="line-clamp-2 w-full break-words text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">
{nextPost.title}
</span>
</Link> </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>} ) : (
<Surface className="hidden w-full cursor-not-allowed flex-col items-end gap-1 p-5 opacity-60 md:flex">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">
<ChevronRight size={16} />
</span>
</Surface>
)}
</nav> </nav>
</WindowSurface>
<WindowSurface title="Comments" showTrafficLights={false} className="mx-auto max-w-[820px]" bodyClassName="p-5 md:p-6">
<CommentList postSlug={post.slug} /> <CommentList postSlug={post.slug} />
</WindowSurface>
</main> </main>
<aside className="hidden 2xl:block w-[220px] shrink-0"> <aside className="hidden w-[220px] shrink-0 xl:block">
<div className="sticky top-24"> <WindowSurface title="목차" showTrafficLights={false} bodyClassName="p-4" className="sticky top-16 shadow-[var(--shadow-card)]">
<TOC content={post.content || ''} /> <TOC content={post.content || ''} />
</div> </WindowSurface>
</aside> </aside>
</div> </div>
</div> </div>
); );

View File

@@ -1,60 +1,68 @@
import Link from 'next/link'; import Link from 'next/link';
import { Post } from '@/types'; import { ChevronRight, Eye, FileText } from 'lucide-react';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import StatusBadge from '@/components/ui/StatusBadge';
import { Post } from '@/types';
interface PostListItemProps { interface PostListItemProps {
post: Post; post: Post;
showViews?: boolean;
} }
export default function PostListItem({ post }: PostListItemProps) { const isNoticePost = (post: Post) => {
// 📢 공지 카테고리 여부 확인 const categoryName = post.categoryName || '';
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice'; return categoryName === '공지' || 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 ( return (
<Link href={`/posts/${post.slug}`} className="block group"> <Link href={`/posts/${post.slug}`} className="group block">
<div className={clsx( <div
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors", className={clsx(
isNotice 'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게 isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-[var(--card-bg)]',
: "border-gray-100 hover:bg-gray-50" )}
)}> >
<div className="flex items-center gap-4 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-3">
{/* 카테고리 라벨 */} <span className="hidden h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--card-border)] bg-[var(--card-bg-strong)] text-[var(--color-accent)] shadow-[var(--shadow-control)] sm:flex">
<span className={clsx( <FileText size={17} />
"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> </span>
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
{/* 제목 */} {post.categoryName || '미분류'}
<h3 className={clsx( </StatusBadge>
"text-base font-medium truncate transition-colors", <div className="min-w-0">
isNotice <h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
? "text-gray-900 group-hover:text-red-600 font-semibold"
: "text-gray-800 group-hover:text-blue-600"
)}>
{post.title} {post.title}
</h3> </h3>
</div> <time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
<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> </time>
</div> </div>
</div> </div>
<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> </Link>
); );
} }

View File

@@ -48,17 +48,18 @@ export default function PostSearch({
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 bg-gray-50 border border-gray-200 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 transition-all placeholder:text-gray-400" className="w-full rounded-full border border-[var(--control-border)] bg-[var(--color-control)] py-2.5 pl-10 pr-10 text-sm text-[var(--color-text)] shadow-[var(--shadow-control)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:bg-[var(--card-bg-strong)]"
/> />
<Search <Search
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 cursor-pointer hover:text-blue-500 transition-colors" className="absolute left-3.5 top-1/2 -translate-y-1/2 cursor-pointer text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-accent)]"
size={18} size={18}
onClick={handleSearch} onClick={handleSearch}
/> />
{keyword && ( {keyword && (
<button <button
onClick={handleClear} onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-200 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-[var(--color-text-subtle)] transition-colors hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]"
aria-label="검색어 지우기"
> >
<X size={14} /> <X size={14} />
</button> </button>

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
// npm install github-slugger 실행 필요
import GithubSlugger from 'github-slugger'; import GithubSlugger from 'github-slugger';
interface TOCProps { interface TOCProps {
@@ -18,42 +16,37 @@ interface HeadingItem {
export default function TOC({ content }: TOCProps) { export default function TOC({ content }: TOCProps) {
const [activeId, setActiveId] = useState<string>(''); const [activeId, setActiveId] = useState<string>('');
const [headings, setHeadings] = useState<HeadingItem[]>([]);
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성 const headings = useMemo(() => {
useEffect(() => { const slugger = new GithubSlugger();
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
const lines = content.split('\n'); const lines = content.split('\n');
// 코드 블럭 내의 #은 무시 const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
let inCodeBlock = false;
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
// 코드 블럭 진입/이탈 체크 (```)
if (line.trim().startsWith('```')) { if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock; return {
return acc; ...acc,
inCodeBlock: !acc.inCodeBlock,
};
} }
if (inCodeBlock) return acc; if (acc.inCodeBlock) return acc;
// 헤딩 매칭 (# 1~3개)
const match = line.match(/^(#{1,3})\s+(.+)$/); const match = line.match(/^(#{1,3})\s+(.+)$/);
if (match) { if (match) {
const level = match[1].length; const level = match[1].length;
const text = match[2].replace(/(\*\*|__)/g, '').trim(); // 볼드체 마크다운 제거 const text = match[2].replace(/(\*\*|__)/g, '').trim();
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
const slug = slugger.slug(text); const slug = slugger.slug(text);
acc.push({ text, level, slug }); return {
...acc,
headings: [...acc.headings, { text, level, slug }],
};
} }
return acc; return acc;
}, []); }, { headings: [], inCodeBlock: false });
setHeadings(matches); return result.headings;
}, [content]); }, [content]);
// 2. 스크롤 감지 (IntersectionObserver)
useEffect(() => { useEffect(() => {
if (headings.length === 0) return; if (headings.length === 0) return;
@@ -65,7 +58,6 @@ export default function TOC({ content }: TOCProps) {
} }
}); });
}, },
// 상단 헤더 공간 등을 고려하여 감지 영역 조정
{ rootMargin: '-10% 0px -80% 0px' } { rootMargin: '-10% 0px -80% 0px' }
); );
@@ -77,13 +69,11 @@ export default function TOC({ content }: TOCProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, [headings]); }, [headings]);
// 3. 클릭 핸들러
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => { const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault(); e.preventDefault();
const element = document.getElementById(slug); const element = document.getElementById(slug);
if (element) { if (element) {
// 상단 고정 헤더 높이 여유 공간
const headerOffset = 100; const headerOffset = 100;
const elementPosition = element.getBoundingClientRect().top; const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset; const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
@@ -93,7 +83,6 @@ export default function TOC({ content }: TOCProps) {
behavior: 'smooth' behavior: 'smooth'
}); });
// URL 해시 변경 및 상태 즉시 업데이트
history.pushState(null, '', `#${slug}`); history.pushState(null, '', `#${slug}`);
setActiveId(slug); setActiveId(slug);
} }
@@ -103,18 +92,18 @@ export default function TOC({ content }: TOCProps) {
return ( return (
<aside className="w-full"> <aside className="w-full">
<div className="border-l-2 border-gray-100 pl-4 py-2"> <div className="border-l border-[var(--color-line)] pl-4">
<h4 className="font-bold text-gray-900 mb-4 text-sm uppercase tracking-wider text-opacity-80">On this page</h4> <h4 className="mb-4 text-xs font-semibold uppercase text-[var(--color-text-subtle)]"></h4>
<ul className="space-y-2.5"> <ul className="space-y-2.5">
{headings.map((heading, index) => ( {headings.map((heading, index) => (
<li <li
key={`${heading.slug}-${index}`} key={`${heading.slug}-${index}`}
className={clsx( className={clsx(
"text-sm transition-all duration-200", "text-sm transition-colors duration-150",
heading.level === 3 ? "pl-4 text-xs" : "", // h3는 들여쓰기 heading.level === 3 ? "ml-3 text-xs" : "",
activeId === heading.slug activeId === heading.slug
? "text-blue-600 font-bold translate-x-1" ? "font-semibold text-[var(--color-accent)]"
: "text-gray-500 hover:text-gray-900" : "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
)} )}
> >
<a <a

View File

@@ -0,0 +1,125 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
import {
isThemeMode,
ResolvedTheme,
ThemeMode,
THEME_STORAGE_KEY,
} from './theme';
interface ThemeContextValue {
mode: ThemeMode;
resolvedTheme: ResolvedTheme;
setMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const THEME_MODE_EVENT = 'wyp-theme-mode-change';
const getSystemTheme = (): ResolvedTheme => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const resolveTheme = (mode: ThemeMode): ResolvedTheme => {
if (mode === 'system') return getSystemTheme();
return mode;
};
const applyTheme = (mode: ThemeMode, resolvedTheme = resolveTheme(mode)) => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.theme = resolvedTheme;
root.dataset.themeMode = mode;
root.style.colorScheme = resolvedTheme;
};
const getStoredMode = (): ThemeMode => {
if (typeof window === 'undefined') return 'system';
const savedMode = window.localStorage.getItem(THEME_STORAGE_KEY);
if (isThemeMode(savedMode)) return savedMode;
const documentMode = document.documentElement.dataset.themeMode;
if (isThemeMode(documentMode)) return documentMode;
return 'system';
};
const subscribeThemeMode = (callback: () => void) => {
if (typeof window === 'undefined') return () => undefined;
const handleStorage = (event: StorageEvent) => {
if (event.key === THEME_STORAGE_KEY) callback();
};
window.addEventListener(THEME_MODE_EVENT, callback);
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener(THEME_MODE_EVENT, callback);
window.removeEventListener('storage', handleStorage);
};
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const mode = useSyncExternalStore<ThemeMode>(subscribeThemeMode, getStoredMode, () => 'system');
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme());
const resolvedTheme = mode === 'system' ? systemTheme : mode;
useEffect(() => {
applyTheme(mode, resolvedTheme);
}, [mode, resolvedTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
setSystemTheme(getSystemTheme());
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
};
}, []);
const setMode = useCallback((nextMode: ThemeMode) => {
window.localStorage.setItem(THEME_STORAGE_KEY, nextMode);
applyTheme(nextMode);
window.dispatchEvent(new Event(THEME_MODE_EVENT));
}, []);
const value = useMemo(
() => ({
mode,
resolvedTheme,
setMode,
}),
[mode, resolvedTheme, setMode],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,52 @@
'use client';
import { Monitor, Moon, Sun } from 'lucide-react';
import { clsx } from 'clsx';
import { ThemeMode } from './theme';
import { useTheme } from './ThemeProvider';
const options: Array<{
value: ThemeMode;
label: string;
icon: typeof Sun;
}> = [
{ value: 'light', label: '라이트', icon: Sun },
{ value: 'system', label: '시스템', icon: Monitor },
{ value: 'dark', label: '다크', icon: Moon },
];
export default function ThemeToggle() {
const { mode, setMode } = useTheme();
return (
<div
className="inline-flex h-9 shrink-0 items-center rounded-full border border-[var(--control-border)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-[18px]"
role="group"
aria-label="화면 테마"
>
{options.map((option) => {
const Icon = option.icon;
const isSelected = mode === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={isSelected}
aria-label={`${option.label} 모드`}
title={`${option.label} 모드`}
onClick={() => setMode(option.value)}
className={clsx(
'inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition duration-150 sm:w-8',
isSelected
? 'bg-[var(--card-bg-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)}
>
<Icon size={15} strokeWidth={2.2} />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export type ThemeMode = 'light' | 'system' | 'dark';
export type ResolvedTheme = 'light' | 'dark';
export const THEME_STORAGE_KEY = 'wyp-theme-mode';
export const themeModes: ThemeMode[] = ['light', 'system', 'dark'];
export const isThemeMode = (value: unknown): value is ThemeMode => {
return typeof value === 'string' && themeModes.includes(value as ThemeMode);
};
export const THEME_INIT_SCRIPT = `
(function () {
try {
var storageKey = '${THEME_STORAGE_KEY}';
var savedMode = window.localStorage.getItem(storageKey);
var mode = savedMode === 'light' || savedMode === 'dark' || savedMode === 'system' ? savedMode : 'system';
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = mode === 'dark' || (mode === 'system' && prefersDark) ? 'dark' : 'light';
var root = document.documentElement;
root.dataset.theme = theme;
root.dataset.themeMode = mode;
root.style.colorScheme = theme;
} catch (error) {
document.documentElement.dataset.theme = 'light';
document.documentElement.dataset.themeMode = 'system';
document.documentElement.style.colorScheme = 'light';
}
})();
`;

View 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(--card-border)] bg-[var(--card-bg)] px-5 py-10 text-center shadow-[var(--shadow-card)] backdrop-blur-[18px]',
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>
);
}

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

View 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: readonly 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(--control-border)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-[18px]',
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(--card-bg-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)}
>
{option.label}
</button>
);
})}
</div>
);
}

View 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-[var(--color-accent-soft)] text-[var(--color-accent)]',
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}
/>
);
}

View File

@@ -0,0 +1,32 @@
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(--card-border)] backdrop-blur-[20px]',
strong
? 'bg-[var(--card-bg-strong)] shadow-[var(--shadow-card)]'
: 'bg-[var(--card-bg)] shadow-[var(--shadow-card)]',
interactive && 'transition duration-150 hover:-translate-y-0.5 hover:border-[var(--card-border-hover)] hover:bg-[var(--card-bg-strong)] hover:shadow-[var(--shadow-control)]',
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,70 @@
import { clsx } from 'clsx';
import { HTMLAttributes, ReactNode } from 'react';
type WindowSurfaceElement = 'div' | 'section' | 'article' | 'aside' | 'main';
interface WindowSurfaceProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
as?: WindowSurfaceElement;
title?: ReactNode;
subtitle?: ReactNode;
controls?: ReactNode;
showTrafficLights?: boolean;
bodyClassName?: string;
}
export default function WindowSurface({
as: Component = 'section',
title,
subtitle,
controls,
showTrafficLights = true,
className,
bodyClassName,
children,
...props
}: WindowSurfaceProps) {
const hasTitlebar = Boolean(title || subtitle || controls || showTrafficLights);
return (
<Component
className={clsx(
'window-surface w-full overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-[30px]',
className,
)}
{...props}
>
{hasTitlebar && (
<div className="flex min-h-11 items-center justify-between gap-4 border-b border-[var(--window-titlebar-border)] bg-[var(--window-titlebar)] px-4 py-2.5 backdrop-blur-[28px]">
<div className="flex min-w-0 items-center gap-3">
{showTrafficLights && (
<div className="flex shrink-0 items-center gap-1.5" aria-hidden="true">
<span className="h-3 w-3 rounded-full border border-black/10 bg-[#ff5f57]" />
<span className="h-3 w-3 rounded-full border border-black/10 bg-[#ffbd2e]" />
<span className="h-3 w-3 rounded-full border border-black/10 bg-[#28c840]" />
</div>
)}
{(title || subtitle) && (
<div className="min-w-0">
{title && (
<div className="truncate text-sm font-semibold text-[var(--color-text)]">
{title}
</div>
)}
{subtitle && (
<div className="truncate text-xs font-medium text-[var(--color-text-subtle)]">
{subtitle}
</div>
)}
</div>
)}
</div>
{controls && <div className="shrink-0">{controls}</div>}
</div>
)}
<div className={bodyClassName}>{children}</div>
</Component>
);
}

33
src/lib/site.ts Normal file
View File

@@ -0,0 +1,33 @@
export const SITE_URL = 'https://blog.wypark.me';
export const SITE_NAME = 'WYPark Blog';
export const DEFAULT_DESCRIPTION = '개발 블로그';
const API_DATE_TIMEZONE = '+09:00';
const TIMEZONE_SUFFIX_PATTERN = /(z|[+-]\d{2}:?\d{2})$/i;
const trimSubMilliseconds = (value: string) => {
return value.replace(/(\.\d{3})\d+/, '$1');
};
const withApiTimezone = (value: string) => {
if (TIMEZONE_SUFFIX_PATTERN.test(value)) return value;
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00${API_DATE_TIMEZONE}`;
return `${value}${API_DATE_TIMEZONE}`;
};
export const getCanonicalUrl = (path = '/') => {
return new URL(path, SITE_URL).toString();
};
export const parseApiDate = (value?: string | null, fallback = new Date()) => {
if (!value) return fallback;
const date = new Date(withApiTimezone(trimSubMilliseconds(value.trim())));
if (Number.isNaN(date.getTime())) return fallback;
const now = new Date();
return date > now ? now : date;
};

69
src/proxy.ts Normal file
View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
const HSTS_HEADER = 'max-age=31536000; includeSubDomains';
const getForwardedProtocol = (request: NextRequest) => {
const forwardedProtocol = request.headers.get('x-forwarded-proto');
if (forwardedProtocol) {
return forwardedProtocol.split(',')[0]?.trim().toLowerCase();
}
return request.nextUrl.protocol.replace(':', '').toLowerCase();
};
const getRequestHost = (request: NextRequest) => {
const forwardedHost = request.headers.get('x-forwarded-host');
if (forwardedHost) {
return forwardedHost.split(',')[0]?.trim();
}
return request.headers.get('host') || request.nextUrl.host;
};
const getHostname = (host: string) => {
if (host.startsWith('[')) {
return host.slice(1, host.indexOf(']'));
}
return host.split(':')[0];
};
const getRedirectHost = (host: string) => {
if (host.startsWith('[')) {
return host.slice(0, host.indexOf(']') + 1);
}
return getHostname(host);
};
const isLocalRequest = (hostname: string) => {
return LOCAL_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost');
};
export function proxy(request: NextRequest) {
const protocol = getForwardedProtocol(request);
const requestHost = getRequestHost(request);
const hostname = getHostname(requestHost);
if (!isLocalRequest(hostname) && protocol === 'http') {
const redirectUrl = request.nextUrl.clone();
redirectUrl.protocol = 'https:';
redirectUrl.host = getRedirectHost(requestHost);
redirectUrl.port = '';
return NextResponse.redirect(redirectUrl, 301);
}
const response = NextResponse.next();
if (!isLocalRequest(hostname)) {
response.headers.set('Strict-Transport-Security', HSTS_HEADER);
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -21,7 +21,7 @@ interface JwtPayload {
nickname?: string; nickname?: string;
name?: string; name?: string;
sub?: string; sub?: string;
[key: string]: any; [key: string]: unknown;
} }
interface AuthState { interface AuthState {
@@ -50,7 +50,7 @@ const parseToken = (token: string): { role: string; user: UserInfo | null } => {
email: decoded.sub || '', email: decoded.sub || '',
} }
}; };
} catch (e) { } catch {
// console.error('Token parsing error:', e); // console.error('Token parsing error:', e);
return { role: 'USER', user: null }; return { role: 'USER', user: null };
} }

View File

@@ -19,6 +19,7 @@ export interface Post {
categoryName: string; categoryName: string;
viewCount: number; viewCount: number;
createdAt: string; createdAt: string;
updatedAt?: string | null;
content?: string; content?: string;
tags: string[]; tags: string[];
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가 // 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
@@ -26,12 +27,103 @@ export interface Post {
nextPost?: PostNeighbor | null; nextPost?: PostNeighbor | null;
} }
// 3. 게시글 목록 페이징 응답 export interface PageMeta {
export interface PostListResponse {
content: Post[];
totalPages: number; totalPages: number;
totalElements: number; totalElements: number;
last: boolean; number?: number;
last?: boolean;
}
// 3. 게시글 목록 페이징 응답
export interface PostListResponse extends PageMeta {
content: Post[];
page?: PageMeta;
}
export interface PostSaveRequest {
title: string;
content: string;
categoryId: number;
tags: string[];
}
export interface ChessPuzzle {
id: number;
date: string;
title: string;
theme: string;
fen: string;
answer: string;
answerUci: string;
hint: string;
rating: number;
sourceUrl: string;
}
export type ChessOutcome = 'IN_PROGRESS' | 'WIN' | 'LOSS' | 'DRAW' | 'UNKNOWN';
export type ChessColor = 'white' | 'black';
export type MaiaModel = '3m' | '5m' | '23m' | '79m';
export interface ChessGameCreateRequest {
rating?: number;
playerColor?: ChessColor;
model?: MaiaModel;
temperature?: number;
topP?: number;
}
export interface ChessGameResponse {
gameId: string;
rating: number;
playerColor: ChessColor;
model: MaiaModel;
fen: string;
turn: ChessColor;
moves: string[];
status: string;
result: string | null;
outcome: ChessOutcome;
pgn: string;
maiaMove: string | null;
}
export interface ChessGameSummaryResponse {
gameId: string;
rating: number;
playerColor: ChessColor;
model: string;
status: string;
result: string | null;
outcome: ChessOutcome;
movesCount: number;
createdAt: string;
updatedAt: string;
}
export interface ChessGameStatsResponse {
total: number;
inProgress: number;
wins: number;
losses: number;
draws: number;
unknown: number;
}
export interface ChessGamePgnResponse {
gameId: string;
pgn: string;
}
export interface ChessMoveRequest {
move: string;
}
export interface ChessGamePageResponse extends PageMeta {
content: ChessGameSummaryResponse[];
page?: PageMeta;
size?: number;
first?: boolean;
empty?: boolean;
} }
// 4. 로그인 응답 // 4. 로그인 응답
@@ -129,3 +221,84 @@ export interface CommentSaveRequest {
export interface CommentDeleteRequest { export interface CommentDeleteRequest {
guestPassword?: string; guestPassword?: string;
} }
export interface AdminComment {
id: number;
content: string;
author?: string;
guestNickname?: string;
memberNickname?: string;
postSlug?: string;
postTitle?: string;
createdAt: string;
}
export interface AdminCommentListResponse extends PageMeta {
content: AdminComment[];
page?: PageMeta;
}
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;
}

View File

@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography";
const config: Config = { const config: Config = {
content: [ content: [
@@ -15,8 +16,6 @@ const config: Config = {
}, },
}, },
}, },
plugins: [ plugins: [typography],
require('@tailwindcss/typography'), // 👈 아까 추가하려던 게 이겁니다!
],
}; };
export default config; export default config;

View File

@@ -1128,6 +1128,11 @@ character-reference-invalid@^2.0.0:
resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz" resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz"
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
chess.js@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz"
integrity sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==
client-only@0.0.1: client-only@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"