Compare commits
30 Commits
116b9a4fd2
...
091f97968b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091f97968b | ||
|
|
f1668f08f2 | ||
|
|
3fe1b0e6d5 | ||
|
|
49fc7b5331 | ||
|
|
d1a0340eb0 | ||
|
|
895b19d284 | ||
|
|
a56e95e6de | ||
|
|
0b6ce404e1 | ||
|
|
e3654a7bd5 | ||
|
|
f26713e0ea | ||
|
|
b371200515 | ||
|
|
8e854a1afa | ||
|
|
d264a7a139 | ||
|
|
3ecccbe28f | ||
|
|
5e37995fbb | ||
|
|
98dfb651a2 | ||
|
|
7535fb6ac6 | ||
|
|
7f9bc1f83b | ||
|
|
ab585b5faa | ||
|
|
4bc0441490 | ||
|
|
f5bef6eeed | ||
|
|
fa3eb96d08 | ||
|
|
caa7196608 | ||
|
|
701bbdea4a | ||
|
|
7bc75a32b2 | ||
|
|
8a2e05b003 | ||
|
|
390778da3e | ||
|
|
0273cae6e4 | ||
|
|
58a012621a | ||
|
|
f4481b88cb |
116
AGENTS.md
Normal file
116
AGENTS.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# AGENTS.md
|
||||
|
||||
Repository-specific instructions for coding agents working on `blog-frontend`.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- This is the frontend for WYPark Blog, built with Next.js 16 App Router, React 19, TypeScript, Tailwind CSS 4, TanStack React Query, Zustand, and Axios.
|
||||
- The app talks to a backend API through `NEXT_PUBLIC_API_URL`.
|
||||
- Local default: `http://localhost:8080`
|
||||
- Production API used by deploy: `https://blogserver.wypark.me`
|
||||
- Public site metadata uses: `https://blog.wypark.me`
|
||||
- Node 20 is the production baseline. The Docker build uses `node:20-alpine`.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `src/app/`: Next.js App Router routes, root layout, providers, metadata, sitemap, and robots.
|
||||
- `src/components/layout/`: persistent layout components such as `Sidebar` and `TopHeader`.
|
||||
- `src/components/post/`: post listing, detail, Markdown rendering, search, and TOC components.
|
||||
- `src/components/comment/`: comment form, list, and item components.
|
||||
- `src/api/`: API client wrappers. Use these instead of calling backend endpoints directly from components.
|
||||
- `src/api/http.ts`: shared Axios instance, auth header injection, and token refresh handling.
|
||||
- `src/store/authStore.ts`: persisted auth state with Zustand.
|
||||
- `src/types/index.ts`: shared API and domain types.
|
||||
- `public/`: static assets.
|
||||
- `.gitea/workflows/deploy.yml`: Gitea deployment pipeline.
|
||||
- `DockerFile`: production Docker image definition. Keep the current filename casing unless changing the deploy workflow too.
|
||||
|
||||
## Package Manager And Commands
|
||||
|
||||
- Prefer `npm` for scripts and dependency work because `package-lock.json`, Docker, and CI use npm.
|
||||
- Do not update both `package-lock.json` and `yarn.lock` for routine changes. If dependencies must change, update the npm lockfile and leave a note about the lockfile decision.
|
||||
- Common commands:
|
||||
- `npm ci` to install dependencies from the lockfile.
|
||||
- `npm run dev` to start local development on port 3000.
|
||||
- `npm run lint` for ESLint.
|
||||
- `npm run build` for production build validation.
|
||||
- `npm run start` after a successful build.
|
||||
|
||||
## Environment
|
||||
|
||||
- Use `NEXT_PUBLIC_API_URL` when testing against a non-local backend.
|
||||
- Features that fetch categories, posts, profile, comments, auth, or uploads require the backend API to be reachable.
|
||||
- If the backend is not running, prefer validating static rendering, linting, and isolated UI logic instead of inventing mock API behavior.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- Keep TypeScript strict. Add or refine shared types in `src/types/index.ts` instead of spreading untyped `any` through components.
|
||||
- Use the `@/*` alias for imports from `src`.
|
||||
- Prefer existing local patterns over new abstractions.
|
||||
- Keep components focused and colocate small UI-only helpers near the component that uses them.
|
||||
- Preserve the existing Tailwind-first styling approach. Use global CSS only for app-wide behavior or third-party component overrides.
|
||||
- Prefer icons from the existing icon libraries (`lucide-react`, `@heroicons/react`) instead of custom inline SVGs.
|
||||
- User-facing copy is Korean-first. When editing Korean text, save files as UTF-8 and verify the rendered text or diff so strings do not become mojibake.
|
||||
- Some existing files contain Korean comments and UI strings. Do not mass-rewrite text while making unrelated changes; fix only the text needed for the task.
|
||||
|
||||
## Next.js App Router Rules
|
||||
|
||||
- Use Server Components by default.
|
||||
- Add `'use client'` only when a component uses hooks, browser APIs, localStorage, React Query hooks, Zustand hooks, events, or client-only libraries.
|
||||
- Components that use `useSearchParams` should remain under a `Suspense` boundary.
|
||||
- Dynamic route params follow the current Next.js 16 style in this repo, where route components may receive `params` as a `Promise`.
|
||||
- Keep metadata, sitemap, and robots changes aligned with the production domain `blog.wypark.me`.
|
||||
|
||||
## Data Fetching And API Rules
|
||||
|
||||
- Use `src/api/*` functions from UI code. Add new backend calls there before consuming them in components.
|
||||
- Use `http` from `src/api/http.ts` for authenticated JSON API calls so auth headers, credentials, and refresh behavior stay consistent.
|
||||
- Direct `axios` calls should be limited to token refresh paths or cases where the shared interceptor would recurse.
|
||||
- API responses generally follow `ApiResponse<T>` with useful data under `response.data.data`.
|
||||
- Keep React Query keys stable and descriptive, following existing patterns such as `['posts', ...]`, `['post', slug]`, `['categories']`, `['profile']`, and `['comments', postSlug]`.
|
||||
- After mutations, invalidate or reset the related queries rather than relying on stale cache state.
|
||||
|
||||
## Auth And Permissions
|
||||
|
||||
- Auth state lives in `useAuthStore` and is persisted under `auth-storage`.
|
||||
- Admin-only UI checks generally use `_hasHydrated` plus `role?.includes('ADMIN')`.
|
||||
- Avoid reading localStorage during server rendering. Gate browser-only auth logic behind client components and effects.
|
||||
- Be careful around token refresh. There is queueing and Web Locks logic in `src/api/http.ts`; do not simplify it without testing concurrent 401 behavior.
|
||||
- Do not expose refresh tokens or auth tokens in logs, UI, or error messages.
|
||||
|
||||
## Markdown And Content Rendering
|
||||
|
||||
- Render post content through `src/components/post/MarkdownRenderer.tsx`.
|
||||
- Keep `rehype-sanitize` in the Markdown pipeline unless there is a deliberate, reviewed security change.
|
||||
- When adding Markdown features, consider XSS, external links, image behavior, syntax highlighting, TOC generation, and SSR/client compatibility.
|
||||
- Uploaded images are inserted as Markdown image syntax by the write page. Preserve that convention unless changing backend upload semantics too.
|
||||
|
||||
## UI And UX Guidelines
|
||||
|
||||
- Preserve the main layout: fixed sidebar on larger screens, mobile sidebar toggle, top-right auth/write controls, content area with responsive widths.
|
||||
- Keep blog reading surfaces calm and content-focused. Avoid large decorative sections unless the user explicitly asks for a redesign.
|
||||
- Ensure text fits on mobile and desktop, especially Korean labels in buttons, cards, modals, and sidebar items.
|
||||
- For admin flows, keep destructive actions confirmed and visually distinct.
|
||||
- For forms, preserve loading, disabled, error, and success states with `react-hot-toast` or the existing local patterns.
|
||||
|
||||
## Validation Expectations
|
||||
|
||||
- For most code changes, run `npm run lint`.
|
||||
- Run `npm run build` when changing routing, layout, metadata, data fetching, auth, Markdown rendering, or build/deploy config.
|
||||
- For visible UI changes, start `npm run dev` and verify the affected route in a browser when feasible.
|
||||
- If validation cannot run because dependencies or the backend are unavailable, state that clearly and explain what was checked instead.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- The production Docker image uses standalone Next output. Keep `next.config.ts` `output: 'standalone'` unless the deployment strategy changes.
|
||||
- The Gitea workflow builds on pushes to `main`, passes `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me`, runs the container as `blog-frontend`, and maps host port `3005` to container port `3000`.
|
||||
- Keep Docker, Gitea workflow, and Next config changes synchronized.
|
||||
|
||||
## Change Discipline
|
||||
|
||||
- Keep edits scoped to the requested task.
|
||||
- Record completed work in `LOG.md` as part of each task. Include the date, a brief summary of changes, and any validation performed or skipped.
|
||||
- When finishing a task and updating `LOG.md`, also leave a concise note about the recommended next task so future agents can continue smoothly.
|
||||
- Do not rewrite generated files, `.next`, `node_modules`, build output, or unrelated lockfiles.
|
||||
- Do not silently change public URLs, analytics IDs, SEO behavior, auth storage keys, or backend endpoint paths.
|
||||
- When touching fragile areas such as auth refresh, Markdown sanitization, deployment, or route params, include a short explanation and stronger validation.
|
||||
562
DESIGN.md
Normal file
562
DESIGN.md
Normal 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.07–1.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:** 8–11px vertical, 15–22px 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:** 20–24px 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 48–64px 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 20–40px 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 4–5 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 | 420–640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px |
|
||||
| Large phone | 641–735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps |
|
||||
| Tablet portrait | 736–833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA |
|
||||
| Tablet landscape | 834–1023px | Global nav returns fully expanded; 3-column utility grids become 2-column |
|
||||
| Small desktop | 1024–1068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px |
|
||||
| Desktop | 1069–1440px | Full layout; 4–5 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.
|
||||
69
LOG.md
Normal file
69
LOG.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Work Log
|
||||
|
||||
## 2026-06-06
|
||||
|
||||
- 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
7
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"axios": "^1.13.2",
|
||||
"chess.js": "^1.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
@@ -3157,6 +3158,12 @@
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
@@ -16,6 +16,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"axios": "^1.13.2",
|
||||
"chess.js": "^1.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
|
||||
10
src/api/chess.ts
Normal file
10
src/api/chess.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { http } from './http';
|
||||
import { ApiResponse, ChessPuzzle } from '@/types';
|
||||
|
||||
export const getTodayChessPuzzle = async (timezone = 'Asia/Seoul') => {
|
||||
const response = await http.get<ApiResponse<ChessPuzzle>>('/api/chess-puzzles/today', {
|
||||
params: { timezone },
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -1,5 +1,41 @@
|
||||
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. 댓글 목록 조회
|
||||
export const getComments = async (postSlug: string) => {
|
||||
@@ -24,12 +60,62 @@ export const deleteComment = async (id: number, password?: string) => {
|
||||
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. 관리자 댓글 목록 조회 (대시보드용)
|
||||
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 },
|
||||
});
|
||||
return response.data.data;
|
||||
return normalizeAdminComments(response.data.data);
|
||||
};
|
||||
|
||||
// 5. 관리자 댓글 강제 삭제
|
||||
|
||||
13
src/api/dashboard.ts
Normal file
13
src/api/dashboard.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { http } from './http';
|
||||
import { AdminDashboardResponse, ApiResponse, DashboardRange } from '@/types';
|
||||
|
||||
export const getAdminDashboard = async (
|
||||
range: DashboardRange = '30d',
|
||||
timezone = 'Asia/Seoul',
|
||||
) => {
|
||||
const response = await http.get<ApiResponse<AdminDashboardResponse>>('/api/admin/dashboard', {
|
||||
params: { range, timezone },
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 🛠️ 환경 변수 처리 (배포 환경 대응)
|
||||
@@ -26,10 +26,33 @@ http.interceptors.request.use(
|
||||
|
||||
// --- 토큰 갱신 관련 변수 ---
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
type QueuedRequest = {
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
type AuthStorageData = {
|
||||
state?: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RetriableRequestConfig = AxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
type NavigatorWithLocks = Navigator & {
|
||||
locks?: {
|
||||
request: <T>(name: string, callback: () => Promise<T> | T) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
let failedQueue: QueuedRequest[] = [];
|
||||
|
||||
// 실패한 요청들을 큐에 담아두었다가 토큰 갱신 후 재시도하는 함수
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
const processQueue = (error: unknown, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
@@ -43,7 +66,7 @@ const processQueue = (error: any, token: string | null = null) => {
|
||||
// 실제 토큰 갱신을 수행하는 함수 (Lock 안에서 실행됨)
|
||||
async function handleTokenRefresh() {
|
||||
try {
|
||||
const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login, logout } = useAuthStore.getState();
|
||||
const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login } = useAuthStore.getState();
|
||||
|
||||
// [1] 로컬 스토리지 확인 (다른 탭에서 이미 갱신했는지 체크)
|
||||
let actualRefreshToken = currentRefreshToken;
|
||||
@@ -53,12 +76,12 @@ async function handleTokenRefresh() {
|
||||
const storageData = localStorage.getItem('auth-storage');
|
||||
if (storageData) {
|
||||
try {
|
||||
const parsed = JSON.parse(storageData);
|
||||
const parsed = JSON.parse(storageData) as AuthStorageData;
|
||||
const storedRefreshToken = parsed.state?.refreshToken;
|
||||
const storedAccessToken = parsed.state?.accessToken;
|
||||
|
||||
// 저장된 토큰이 현재 메모리의 토큰과 다르다면? => 이미 다른 탭/요청이 갱신을 완료함!
|
||||
if (storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) {
|
||||
if (storedAccessToken && storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) {
|
||||
// 현재 탭의 스토어 상태를 스토리지와 동기화
|
||||
login(storedAccessToken, storedRefreshToken);
|
||||
// 대기 중인 요청들 해소
|
||||
@@ -70,7 +93,7 @@ async function handleTokenRefresh() {
|
||||
// 갱신 시도할 토큰 정보를 최신 스토리지 값으로 설정
|
||||
if (storedRefreshToken) actualRefreshToken = storedRefreshToken;
|
||||
if (storedAccessToken) actualAccessToken = storedAccessToken;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// console.error('Storage parse error', e);
|
||||
}
|
||||
}
|
||||
@@ -113,9 +136,10 @@ async function handleTokenRefresh() {
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const originalRequest = error.config as RetriableRequestConfig | undefined;
|
||||
|
||||
if (!error.response) return Promise.reject(error);
|
||||
if (!originalRequest) return Promise.reject(error);
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
@@ -143,9 +167,11 @@ http.interceptors.response.use(
|
||||
try {
|
||||
let newToken;
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'locks' in navigator) {
|
||||
const locks = typeof navigator !== 'undefined' ? (navigator as NavigatorWithLocks).locks : undefined;
|
||||
|
||||
if (locks) {
|
||||
// Lock을 획득한 놈만 handleTokenRefresh 실행
|
||||
newToken = await (navigator as any).locks.request('auth-refresh-lock', async () => {
|
||||
newToken = await locks.request('auth-refresh-lock', async () => {
|
||||
return await handleTokenRefresh();
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http } from './http';
|
||||
import { ApiResponse, PostListResponse, Post } from '@/types';
|
||||
import { ApiResponse, PostListResponse, Post, PostSaveRequest } from '@/types';
|
||||
|
||||
// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원)
|
||||
export const getPosts = async (params?: {
|
||||
@@ -37,13 +37,13 @@ export const getPost = async (slug: string) => {
|
||||
};
|
||||
|
||||
// 4. 게시글 작성
|
||||
export const createPost = async (data: any) => {
|
||||
export const createPost = async (data: PostSaveRequest) => {
|
||||
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 5. 게시글 수정
|
||||
export const updatePost = async (id: number, data: any) => {
|
||||
export const updatePost = async (id: number, data: PostSaveRequest) => {
|
||||
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
75
src/api/publicPosts.ts
Normal file
75
src/api/publicPosts.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
5
src/app/admin/categories/page.tsx
Normal file
5
src/app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
return <AdminCategoryPanel />;
|
||||
}
|
||||
5
src/app/admin/comments/page.tsx
Normal file
5
src/app/admin/comments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminCommentsPanel from '@/components/admin/AdminCommentsPanel';
|
||||
|
||||
export default function AdminCommentsPage() {
|
||||
return <AdminCommentsPanel />;
|
||||
}
|
||||
5
src/app/admin/layout.tsx
Normal file
5
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminRouteShell from '@/components/admin/AdminRouteShell';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminRouteShell>{children}</AdminRouteShell>;
|
||||
}
|
||||
335
src/app/admin/page.tsx
Normal file
335
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
'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,
|
||||
PageMeta,
|
||||
Post,
|
||||
} from '@/types';
|
||||
|
||||
const countCategories = (categories: Category[] = []): number => {
|
||||
return categories.reduce((count, category) => {
|
||||
return count + 1 + countCategories(category.children || []);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const emptyPageMeta: PageMeta = {
|
||||
totalPages: 0,
|
||||
totalElements: 0,
|
||||
number: 0,
|
||||
last: true,
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | 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,
|
||||
});
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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={dashboard?.traffic ?? []}
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
isFallback={isFallback}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||
|
||||
interface EditAdminPostPageProps {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditAdminPostPage({ params }: EditAdminPostPageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
return <AdminPostEditor editSlug={slug} />;
|
||||
}
|
||||
5
src/app/admin/posts/new/page.tsx
Normal file
5
src/app/admin/posts/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||
|
||||
export default function NewAdminPostPage() {
|
||||
return <AdminPostEditor />;
|
||||
}
|
||||
5
src/app/admin/posts/page.tsx
Normal file
5
src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPostsPanel from '@/components/admin/AdminPostsPanel';
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
return <AdminPostsPanel />;
|
||||
}
|
||||
5
src/app/admin/profile/page.tsx
Normal file
5
src/app/admin/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
return <AdminProfilePanel />;
|
||||
}
|
||||
@@ -1,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';
|
||||
import { getPosts } from '@/api/posts';
|
||||
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 const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
export default function ArchivePage() {
|
||||
// 1. 전체 게시글 조회 (최대 1000개)
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['posts', 'all'],
|
||||
queryFn: () => getPosts({ page: 0, size: 1000 }),
|
||||
staleTime: 1000 * 60 * 5, // 5분 캐시
|
||||
});
|
||||
export const metadata: Metadata = {
|
||||
title: 'Archive',
|
||||
alternates: {
|
||||
canonical: '/archive',
|
||||
},
|
||||
openGraph: {
|
||||
title: `Archive | ${SITE_NAME}`,
|
||||
url: '/archive',
|
||||
siteName: SITE_NAME,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
// 2. 게시글 그룹화 (연도 -> 월)
|
||||
const archiveGroups = useMemo(() => {
|
||||
if (!data?.content) return {};
|
||||
const getTotalElements = (data?: PostListResponse | null) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
|
||||
const groups: { [year: string]: { [month: string]: Post[] } } = {};
|
||||
|
||||
data.content.forEach((post) => {
|
||||
const date = new Date(post.createdAt);
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default async function ArchivePage() {
|
||||
const data = await fetchPublicPosts({ page: 0, size: 1000, sort: 'createdAt,desc' });
|
||||
const posts = data?.content || [];
|
||||
const totalPosts = getTotalElements(data);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-10 px-4">
|
||||
{/* 헤더 섹션 */}
|
||||
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3">
|
||||
<Archive className="text-blue-600" size={32} />
|
||||
<span>Archives</span>
|
||||
<main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
|
||||
<WindowSurface
|
||||
title="Archive"
|
||||
subtitle={`${totalPosts.toLocaleString()} posts`}
|
||||
bodyClassName="p-4 md:p-7"
|
||||
>
|
||||
<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>
|
||||
<p className="text-gray-500">
|
||||
지금까지 작성한 <span className="text-blue-600 font-bold">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||
<p className="break-words text-sm leading-6 text-[var(--color-text-muted)] md:text-base">
|
||||
지금까지 작성한 <span className="font-bold text-[var(--color-accent)]">{totalPosts.toLocaleString()}</span>개의 글을 기록 순서로 정리했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 컨텐츠 */}
|
||||
{sortedYears.length > 0 ? (
|
||||
<div className="space-y-12 relative">
|
||||
{/* 타임라인 수직선 (좌측 장식) */}
|
||||
<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>
|
||||
<ArchiveExplorer posts={posts} />
|
||||
</WindowSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState, useEffect } from 'react';
|
||||
import { use, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용
|
||||
import { clsx } from 'clsx';
|
||||
import { ChevronLeft, ChevronRight, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
|
||||
import { getPostsByCategory } from '@/api/posts';
|
||||
import PostCard from '@/components/post/PostCard';
|
||||
import PostListItem from '@/components/post/PostListItem';
|
||||
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
|
||||
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import PostSearch from '@/components/post/PostSearch';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
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 }> }) {
|
||||
const { id } = use(params);
|
||||
const categoryName = decodeURIComponent(id);
|
||||
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
||||
|
||||
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
|
||||
const isNoticeCategory = isNoticeCategoryName(apiCategoryName);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const PAGE_SIZE = 10;
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [pageSize, setPageSize] = useState<PageSizeOption>(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
if (isNoticeCategory) {
|
||||
setViewMode('list');
|
||||
return;
|
||||
}
|
||||
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
||||
if (savedMode) setViewMode(savedMode);
|
||||
}, [isNoticeCategory]);
|
||||
const savedSize = localStorage.getItem(CATEGORY_PAGE_SIZE_STORAGE_KEY);
|
||||
return isPageSizeOption(savedSize) ? savedSize : DEFAULT_PAGE_SIZE;
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
||||
if (isNoticeCategory || typeof window === 'undefined') return isNoticeCategory ? 'list' : 'grid';
|
||||
|
||||
const savedMode = localStorage.getItem('postViewMode');
|
||||
return savedMode === 'list' ? 'list' : 'grid';
|
||||
});
|
||||
const activeViewMode = isNoticeCategory ? 'list' : viewMode;
|
||||
|
||||
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||
setViewMode(mode);
|
||||
@@ -37,95 +61,109 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 검색어가 변경되면 페이지를 0으로 초기화
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
setKeyword(newKeyword);
|
||||
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({
|
||||
queryKey: ['posts', 'category', apiCategoryName, page, keyword], // 🔑 쿼리 키에 keyword 추가
|
||||
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword), // 🆕 검색어 전달
|
||||
// 🛠️ 수정됨: 검색어가 바뀌면 이전 데이터를 보여주지 않고 로딩 상태로 전환
|
||||
// (페이지 이동 시에는 부드럽게 보여주기 위해 유지)
|
||||
placeholderData: (previousData, previousQuery) => {
|
||||
const prevKeyword = previousQuery?.queryKey[4];
|
||||
if (prevKeyword !== keyword) return undefined;
|
||||
return previousData;
|
||||
},
|
||||
queryKey: ['posts', 'category', apiCategoryName, page, pageSize, keyword],
|
||||
queryFn: () => getPostsByCategory(apiCategoryName, page, Number(pageSize), keyword),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
if (isLoading || (postsData === undefined && !error)) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-10 text-red-500">
|
||||
게시글을 불러오는 중 오류가 발생했습니다.<br/>
|
||||
<span className="text-sm text-gray-400">카테고리 이름을 확인해주세요.</span>
|
||||
</div>
|
||||
<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-[var(--color-text-subtle)]">카테고리 이름을 확인해 주세요.</span>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const posts = postsData?.content || [];
|
||||
|
||||
// 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음)
|
||||
// postsData가 any로 캐스팅되어 안전하게 접근
|
||||
const pagingData = (postsData as any)?.page || postsData;
|
||||
const pagingData = postsData?.page || postsData;
|
||||
const totalElements = pagingData?.totalElements ?? 0;
|
||||
const totalPages = pagingData?.totalPages ?? 0;
|
||||
// page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용
|
||||
const isLast = pagingData?.number !== undefined
|
||||
? (pagingData.number + 1 >= pagingData.totalPages)
|
||||
? pagingData.number + 1 >= pagingData.totalPages
|
||||
: (postsData?.last ?? true);
|
||||
|
||||
const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
|
||||
const handleNextPage = () => {
|
||||
if (!isLast) {
|
||||
setPage((old) => old + 1);
|
||||
}
|
||||
if (!isLast) setPage((old) => old + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2 shrink-0">
|
||||
{apiCategoryName} <span className="text-gray-400 text-lg font-normal">글 목록</span>
|
||||
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
|
||||
<WindowSurface
|
||||
title={apiCategoryName}
|
||||
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 shrink-0 items-baseline gap-2 text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||
{apiCategoryName}
|
||||
<span className="text-lg font-normal text-[var(--color-text-subtle)]">글 목록</span>
|
||||
</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
|
||||
onSearch={handleSearch}
|
||||
placeholder={`'${apiCategoryName}' 내 검색`}
|
||||
placeholder={`${apiCategoryName} 검색`}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
|
||||
{/* 뷰 모드 버튼 */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg shrink-0">
|
||||
<SegmentedControl
|
||||
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
|
||||
type="button"
|
||||
onClick={() => handleViewModeChange('grid')}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-all duration-200",
|
||||
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
'rounded-full p-2 transition-all duration-150',
|
||||
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} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewModeChange('list')}
|
||||
className={clsx(
|
||||
"p-2 rounded-md transition-all duration-200",
|
||||
viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
||||
'rounded-full p-2 transition-all duration-150',
|
||||
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} />
|
||||
</button>
|
||||
@@ -133,56 +171,56 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
|
||||
{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">
|
||||
<SearchIcon size={16} className="text-blue-500" />
|
||||
<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-[var(--color-accent)]" />
|
||||
<span>
|
||||
"{keyword}" 검색 결과: <strong>{totalElements}</strong>건
|
||||
검색어 <strong>{keyword}</strong> 결과: <strong>{totalElements}</strong>건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-gray-400 mb-2">
|
||||
{keyword ? `"${keyword}"에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
|
||||
className="py-20"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{activeViewMode === 'grid' ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col border-t border-gray-100">
|
||||
<Surface className="flex flex-col p-2 shadow-none">
|
||||
{posts.map((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
|
||||
type="button"
|
||||
onClick={handlePrevPage}
|
||||
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="이전 페이지"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Page <span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
<span className="text-sm font-medium text-[var(--color-text-muted)]">
|
||||
페이지 <span className="font-bold text-[var(--color-text)]">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextPage}
|
||||
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="다음 페이지"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
@@ -190,6 +228,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
BIN
src/app/fonts/PretendardVariable.woff2
Normal file
BIN
src/app/fonts/PretendardVariable.woff2
Normal file
Binary file not shown.
@@ -1,37 +1,524 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--font-apple-sans:
|
||||
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: #f7f4fb;
|
||||
--desktop-bg:
|
||||
linear-gradient(135deg, rgba(255, 232, 238, 0.78) 0%, rgba(255, 232, 238, 0) 36%),
|
||||
linear-gradient(155deg, rgba(218, 235, 255, 0.86) 6%, rgba(218, 235, 255, 0) 50%),
|
||||
linear-gradient(42deg, rgba(222, 251, 236, 0.74) 48%, rgba(222, 251, 236, 0) 88%),
|
||||
linear-gradient(180deg, #fbf8ff 0%, #eef5ff 100%);
|
||||
--window-bg: rgba(255, 255, 255, 0.6);
|
||||
--window-bg-strong: rgba(255, 255, 255, 0.8);
|
||||
--window-titlebar: rgba(255, 255, 255, 0.5);
|
||||
--window-border: rgba(255, 255, 255, 0.68);
|
||||
--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.5);
|
||||
--menubar-border: rgba(255, 255, 255, 0.7);
|
||||
--dock-bg: rgba(248, 250, 252, 0.46);
|
||||
--dock-border: rgba(255, 255, 255, 0.62);
|
||||
--sidebar-bg: rgba(255, 255, 255, 0.48);
|
||||
--sidebar-border: rgba(255, 255, 255, 0.6);
|
||||
--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 28px 80px rgba(70, 92, 130, 0.18), 0 10px 28px rgba(70, 92, 130, 0.08), 0 1px 0 rgba(255, 255, 255, 0.72) inset;
|
||||
--shadow-sidebar: 16px 0 42px rgba(70, 92, 130, 0.12), -1px 0 0 rgba(255, 255, 255, 0.46) inset;
|
||||
--shadow-menubar: 0 16px 40px rgba(70, 92, 130, 0.12), 0 1px 0 rgba(255, 255, 255, 0.58) 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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: var(--font-apple-sans);
|
||||
--font-mono: var(--font-apple-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--color-page: #11131d;
|
||||
--desktop-bg:
|
||||
linear-gradient(135deg, rgba(88, 56, 104, 0.42) 0%, rgba(88, 56, 104, 0) 38%),
|
||||
linear-gradient(150deg, rgba(43, 73, 110, 0.5) 10%, rgba(43, 73, 110, 0) 54%),
|
||||
linear-gradient(42deg, rgba(38, 91, 72, 0.38) 48%, rgba(38, 91, 72, 0) 88%),
|
||||
linear-gradient(180deg, #131620 0%, #07090f 100%);
|
||||
--window-bg: rgba(28, 31, 42, 0.68);
|
||||
--window-bg-strong: rgba(39, 42, 56, 0.84);
|
||||
--window-titlebar: rgba(255, 255, 255, 0.075);
|
||||
--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(24, 27, 38, 0.62);
|
||||
--menubar-border: rgba(255, 255, 255, 0.16);
|
||||
--dock-bg: rgba(18, 20, 28, 0.46);
|
||||
--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 {
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
background: var(--desktop-bg);
|
||||
background-attachment: fixed;
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
border-radius: 0.75rem !important; /* 둥근 모서리 */
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border-color: #e5e7eb !important;
|
||||
border-radius: 0.5rem !important;
|
||||
border-color: var(--card-border) !important;
|
||||
background-color: var(--card-bg-strong) !important;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar {
|
||||
border-radius: 0.75rem 0.75rem 0 0 !important;
|
||||
background-color: #f9fafb !important;
|
||||
border-radius: 0.5rem 0.5rem 0 0 !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;
|
||||
}
|
||||
@@ -1,13 +1,37 @@
|
||||
import type { Metadata } from 'next';
|
||||
import localFont from 'next/font/local';
|
||||
import Script from 'next/script';
|
||||
import './globals.css';
|
||||
import Providers from './providers';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import TopHeader from '@/components/layout/TopHeader';
|
||||
import Script from 'next/script';
|
||||
import DesktopShell from '@/components/layout/DesktopShell';
|
||||
import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
|
||||
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 = {
|
||||
title: 'WYPark Blog',
|
||||
description: '개발 블로그',
|
||||
metadataBase: new URL(SITE_URL),
|
||||
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({
|
||||
@@ -16,12 +40,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<html lang="ko" data-theme="light" data-theme-mode="system" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
|
||||
</head>
|
||||
<body className="bg-[#f8f9fa] text-gray-800">
|
||||
{/* 🌟 Google Analytics 스크립트 */}
|
||||
<body className={`${pretendard.variable} ${pretendard.className} text-[var(--color-text)] antialiased`}>
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
||||
strategy="afterInteractive"
|
||||
@@ -37,23 +64,10 @@ export default function RootLayout({
|
||||
</Script>
|
||||
|
||||
<Providers>
|
||||
<div className="min-h-screen flex">
|
||||
{/* 사이드바 */}
|
||||
<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>
|
||||
<DesktopShell>{children}</DesktopShell>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Suspense, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { login } from '@/api/auth';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { login } from '@/api/auth';
|
||||
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 searchParams = useSearchParams();
|
||||
const { login: setLoginState } = useAuthStore();
|
||||
const redirectPath = getSafeRedirectPath(searchParams.get('redirect'));
|
||||
|
||||
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await login(formData);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
// ✨ 수정됨: AccessToken과 RefreshToken 모두 저장
|
||||
setLoginState(res.data.accessToken, res.data.refreshToken);
|
||||
|
||||
// 로그인 성공 후 메인으로 이동
|
||||
router.push('/');
|
||||
const response = await login(formData);
|
||||
if (response.code === 'SUCCESS' && response.data) {
|
||||
setLoginState(response.data.accessToken, response.data.refreshToken);
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
setError(res.message || '로그인에 실패했습니다.');
|
||||
setError(response.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.');
|
||||
} catch (loginError) {
|
||||
setError(getErrorMessage(loginError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// 🎨 배경색 수정: bg-gray-50 -> bg-white
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-white">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
||||
<p className="text-gray-500 mt-2">블로그에 오신 것을 환영합니다.</p>
|
||||
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
||||
<WindowSurface
|
||||
title="Login"
|
||||
subtitle="WYPark OS"
|
||||
className="w-full max-w-md"
|
||||
bodyClassName="p-8"
|
||||
>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-[var(--color-text)]">로그인</h1>
|
||||
<p className="mt-2 text-[var(--color-text-muted)]">블로그 데스크톱으로 다시 들어갑니다.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.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"
|
||||
onChange={(event) => setFormData({ ...formData, email: event.target.value })}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</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
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.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"
|
||||
onChange={(event) => setFormData({ ...formData, password: event.target.value })}
|
||||
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="••••••••"
|
||||
required
|
||||
/>
|
||||
</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
|
||||
type="submit"
|
||||
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} />}
|
||||
로그인
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
454
src/app/page.tsx
454
src/app/page.tsx
@@ -1,184 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import PostCard from '@/components/post/PostCard';
|
||||
import PostListItem from '@/components/post/PostListItem';
|
||||
// PostSearch 컴포넌트 제거 (사이드바로 이동)
|
||||
import { Post } from '@/types';
|
||||
import { Loader2, Megaphone, Flame, Clock, ChevronRight, Search as SearchIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
// URL 쿼리 스트링에서 검색어 가져오기
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: SITE_NAME,
|
||||
},
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
openGraph: {
|
||||
title: SITE_NAME,
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
url: '/',
|
||||
siteName: SITE_NAME,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
// 1. 공지사항 조회
|
||||
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
|
||||
queryKey: ['posts', 'notices'],
|
||||
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
|
||||
});
|
||||
type HomePageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
// 2. 최신 게시글 조회
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'latest'],
|
||||
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
|
||||
});
|
||||
const isNoticePost = (post: Post) => {
|
||||
const categoryName = post.categoryName || '';
|
||||
const normalizedName = categoryName.toLowerCase();
|
||||
|
||||
// 3. 인기 게시글 조회
|
||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||
queryKey: ['posts', 'popular'],
|
||||
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
|
||||
});
|
||||
return categoryName === '공지' || normalizedName === 'notice' || normalizedName === 'announcement';
|
||||
};
|
||||
|
||||
// 4. 검색 결과 조회
|
||||
const { data: searchData, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: ['posts', 'search', keyword],
|
||||
queryFn: () => getPosts({ keyword, size: 20 }),
|
||||
enabled: !!keyword,
|
||||
});
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getSummary = (content?: string, maxLength = 118) => {
|
||||
if (!content) return '';
|
||||
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
};
|
||||
|
||||
const 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 (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<WindowSurface
|
||||
title="Search"
|
||||
subtitle={`"${keyword}"`}
|
||||
bodyClassName="p-4 md:p-6"
|
||||
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
<div className="mb-6 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)]">
|
||||
<Search size={22} className="shrink-0" />
|
||||
<h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||
검색 결과
|
||||
</h1>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{searchResults.map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
|
||||
)}
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeStrip({ notices }: { notices: Post[] }) {
|
||||
if (notices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2" aria-label="공지">
|
||||
{notices.slice(0, 3).map((notice) => (
|
||||
<Link key={notice.id} href={`/posts/${notice.slug}`} className="group block min-w-0">
|
||||
<Surface
|
||||
interactive
|
||||
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"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<StatusBadge tone="danger" className="shrink-0">공지</StatusBadge>
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
{notice.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<time 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>
|
||||
)}
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const filterPosts = (posts: Post[] | undefined, limit: number) => {
|
||||
if (!posts) return [];
|
||||
return posts
|
||||
.filter((post) => post.categoryName !== '공지' && post.categoryName.toLowerCase() !== 'notice')
|
||||
.slice(0, limit);
|
||||
};
|
||||
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 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;
|
||||
const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 7);
|
||||
const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12">
|
||||
|
||||
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */}
|
||||
{keyword ? (
|
||||
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex items-center gap-2 mb-6 border-b border-gray-100 pb-4">
|
||||
<SearchIcon className="text-blue-500" size={24} />
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
"{keyword}" 검색 결과 <span className="text-blue-600 text-lg ml-1">{searchTotalElements}</span>건
|
||||
</h2>
|
||||
</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">
|
||||
<main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
|
||||
<h1 className="sr-only">{SITE_NAME}</h1>
|
||||
<WindowSurface
|
||||
title="WYPark"
|
||||
controls={(
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
모든 글 보러가기 <ChevronRight size={16} />
|
||||
<span className="hidden sm:inline">전체 글</span>
|
||||
<Archive size={14} />
|
||||
</Link>
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
bodyClassName="space-y-6 p-4 md:p-6"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" size={40} /></div>}>
|
||||
<HomeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
11
src/app/play/chess/page.tsx
Normal file
11
src/app/play/chess/page.tsx
Normal 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 />;
|
||||
}
|
||||
9
src/app/posts/[slug]/loading.tsx
Normal file
9
src/app/posts/[slug]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +1,90 @@
|
||||
import { Metadata } from 'next';
|
||||
import PostDetailClient from '@/components/post/PostDetailClient';
|
||||
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 = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
// 🛠️ 서버 사이드 데이터 패칭 함수 (Metadata와 Page 양쪽에서 재사용)
|
||||
async function getPostFromServer(slug: string): Promise<Post | null> {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
const getPostDescription = (content?: string) => {
|
||||
const plainText = content
|
||||
?.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 {
|
||||
const res = await fetch(`${BASE_URL}/api/posts/${slug}`, {
|
||||
// 캐시 설정: 60초마다 갱신 (블로그 특성상 적절)
|
||||
// 즉시 반영이 필요하다면 'no-store'로 설정하거나 revalidate: 0 사용
|
||||
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;
|
||||
return new URL(imageUrl, SITE_URL).toString();
|
||||
} catch {
|
||||
return new URL('/og-image.png', SITE_URL).toString();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🌟 메타데이터 생성 (SEO)
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPostFromServer(slug);
|
||||
const post = await fetchPublicPost(slug);
|
||||
const canonicalUrl = getCanonicalUrl(`/posts/${encodeURIComponent(post?.slug || slug)}`);
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: '게시글을 찾을 수 없습니다',
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 본문 요약 및 이미지 추출 로직
|
||||
const description = post.content
|
||||
?.replace(/[#*`_~]/g, '')
|
||||
.replace(/\n/g, ' ')
|
||||
.substring(0, 150) + '...';
|
||||
|
||||
const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/);
|
||||
const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png';
|
||||
const description = getPostDescription(post.content);
|
||||
const imageUrl = getFirstMarkdownImage(post.content);
|
||||
const publishedTime = parseApiDate(post.createdAt).toISOString();
|
||||
const modifiedTime = parseApiDate(post.updatedAt || post.createdAt).toISOString();
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: description,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: description,
|
||||
url: `https://blog.wypark.me/posts/${slug}`,
|
||||
siteName: 'WYPark Blog',
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
siteName: SITE_NAME,
|
||||
images: [{ url: imageUrl, width: 1200, height: 630 }],
|
||||
type: 'article',
|
||||
publishedTime: post.createdAt, // created_at 대신 createdAt 사용 주의 (타입 정의 따름)
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
authors: ['WYPark'],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.title,
|
||||
description: description,
|
||||
description,
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 🌟 실제 페이지 렌더링 (SSR 적용)
|
||||
export default async function PostDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const post = await fetchPublicPost(slug);
|
||||
|
||||
// 1. 서버에서 데이터를 미리 가져옵니다.
|
||||
const post = await getPostFromServer(slug);
|
||||
|
||||
// 2. 데이터가 없으면 404 페이지로 보냅니다. (봇에게도 404 신호를 줌)
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 3. 가져온 데이터를 클라이언트 컴포넌트에 'initialPost'로 넘겨줍니다.
|
||||
return <PostDetailClient slug={slug} initialPost={post} />;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import axios from 'axios';
|
||||
import { ThemeProvider } from '@/components/theme/ThemeProvider';
|
||||
|
||||
// 🛠️ JWT 토큰 만료 여부 체크 함수 (라이브러리 없이 구현)
|
||||
function isTokenExpired(token: string) {
|
||||
@@ -23,7 +24,7 @@ function isTokenExpired(token: string) {
|
||||
// 현재 시간(초)이 만료 시간(exp)보다 크거나 같으면 만료됨
|
||||
// 안전 마진 60초 추가 (만료 1분 전이면 미리 갱신)
|
||||
return Date.now() / 1000 >= exp - 60;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return true; // 파싱 실패 시 만료된 것으로 간주
|
||||
}
|
||||
}
|
||||
@@ -61,7 +62,7 @@ function AuthInitializer() {
|
||||
} else {
|
||||
throw new Error('Token refresh response invalid');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// console.error('❌ Failed to refresh token on init:', error);
|
||||
// 갱신 실패 시 깔끔하게 로그아웃 처리하여 꼬임 방지
|
||||
logout();
|
||||
@@ -93,11 +94,15 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = 'https://blog.wypark.me';
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
// 검색 로봇이 굳이 긁어갈 필요 없는 페이지들은 차단 (글쓰기, 로그인 등)
|
||||
disallow: ['/write', '/login', '/signup', '/admin'],
|
||||
},
|
||||
// 여기서 동적으로 생성된 sitemap.xml을 가리킵니다.
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,132 +2,149 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signup, verifyEmail } from '@/api/auth';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { SignupRequest } from '@/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
const responseError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
return responseError.response?.data?.message || fallbackMessage;
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM'); // 단계 관리
|
||||
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState(''); // 인증할 이메일 저장
|
||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
|
||||
// React Hook Form 설정
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm<SignupRequest>();
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<SignupRequest>();
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
// 1단계: 회원가입 정보 제출
|
||||
const onSignupSubmit = async (data: SignupRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await signup(data);
|
||||
if (res.code === 'SUCCESS') {
|
||||
alert(`📧 ${data.email}로 인증 코드를 보냈습니다!`);
|
||||
setRegisteredEmail(data.email); // 이메일 기억하기
|
||||
setStep('VERIFY'); // 2단계로 이동
|
||||
alert(`${data.email}로 인증 코드를 보냈습니다.`);
|
||||
setRegisteredEmail(data.email);
|
||||
setStep('VERIFY');
|
||||
} else {
|
||||
alert('회원가입 실패: ' + res.message);
|
||||
alert(`회원가입 실패: ${res.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('오류 발생: ' + (error.response?.data?.message || error.message));
|
||||
} catch (error) {
|
||||
alert(`오류 발생: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 2단계: 인증 코드 제출
|
||||
const onVerifySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!verifyCode) return alert('인증 코드를 입력해주세요.');
|
||||
const onVerifySubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!verifyCode) {
|
||||
alert('인증 코드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await verifyEmail({ email: registeredEmail, code: verifyCode });
|
||||
if (res.code === 'SUCCESS') {
|
||||
alert('인증되었습니다! 로그인 페이지로 이동합니다.');
|
||||
alert('인증되었습니다. 로그인 페이지로 이동합니다.');
|
||||
router.push('/login');
|
||||
} else {
|
||||
alert('인증 실패: ' + res.message);
|
||||
alert(`인증 실패: ${res.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('오류 발생: ' + (error.response?.data?.message || error.message));
|
||||
} catch (error) {
|
||||
alert(`오류 발생: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// 🎨 배경색 수정: bg-gray-50 -> bg-white
|
||||
<div className="min-h-screen flex items-center justify-center bg-white px-4 py-12">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">회원가입</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{step === 'FORM' ? '' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
||||
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
||||
<WindowSurface
|
||||
title="Signup"
|
||||
subtitle={step === 'FORM' ? 'Create account' : registeredEmail}
|
||||
className="w-full max-w-md"
|
||||
bodyClassName="p-8"
|
||||
>
|
||||
<div className="mb-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* STEP 1: 가입 정보 입력 폼 */}
|
||||
{step === 'FORM' && (
|
||||
<form onSubmit={handleSubmit(onSignupSubmit)} className="space-y-5">
|
||||
<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
|
||||
{...register('email', {
|
||||
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"
|
||||
/>
|
||||
{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>
|
||||
<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
|
||||
{...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="개발자"
|
||||
/>
|
||||
{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>
|
||||
<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
|
||||
type="password"
|
||||
{...register('password', { required: '비밀번호를 입력해주세요.', minLength: { value: 6, message: '6자 이상 입력해주세요.' } })}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
{...register('password', {
|
||||
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="••••••••"
|
||||
/>
|
||||
{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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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 ? '처리 중...' : '인증 메일 받기'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* STEP 2: 인증 코드 입력 폼 */}
|
||||
{step === 'VERIFY' && (
|
||||
<form onSubmit={onVerifySubmit} className="space-y-5">
|
||||
<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
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
onChange={(event) => setVerifyCode(event.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -135,7 +152,7 @@ export default function SignupPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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 ? '처리 중...' : '인증 완료'}
|
||||
</button>
|
||||
@@ -143,21 +160,20 @@ export default function SignupPage() {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</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="/login" className="text-blue-600 font-semibold hover:underline">
|
||||
<Link href="/login" className="font-semibold text-[var(--color-accent)] hover:underline">
|
||||
로그인하기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,40 @@
|
||||
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> {
|
||||
const baseUrl = 'https://blog.wypark.me';
|
||||
// API 주소 환경변수 사용 (없으면 로컬)
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
// 1. 고정된 정적 페이지들
|
||||
const generatedAt = new Date();
|
||||
const routes: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
url: SITE_URL,
|
||||
lastModified: generatedAt,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/archive`,
|
||||
lastModified: new Date(),
|
||||
url: `${SITE_URL}/archive`,
|
||||
lastModified: generatedAt,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
// 2. API에서 게시글 목록 가져오기
|
||||
const response = await fetch(`${apiUrl}/api/posts?size=1000&sort=createdAt,desc`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
next: { revalidate: 3600 }
|
||||
});
|
||||
const postsData = await fetchPublicPosts({ size: 1000, sort: 'createdAt,desc' });
|
||||
const posts = postsData?.content || [];
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch posts');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const posts = json.data?.content || [];
|
||||
|
||||
// 3. 게시글 데이터를 사이트맵 형식으로 변환
|
||||
const postRoutes = posts.map((post: any) => ({
|
||||
// 🛠️ 핵심 수정: slug를 encodeURIComponent로 감싸서 특수문자(&, 한글 등)를 안전하게 처리합니다.
|
||||
url: `${baseUrl}/posts/${encodeURIComponent(post.slug)}`,
|
||||
lastModified: new Date(post.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
const postRoutes: MetadataRoute.Sitemap = posts
|
||||
.filter((post) => post.slug)
|
||||
.map((post) => ({
|
||||
url: `${SITE_URL}/posts/${encodeURIComponent(post.slug)}`,
|
||||
lastModified: parseApiDate(post.updatedAt || post.createdAt, generatedAt),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [...routes, ...postRoutes];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Sitemap generation error:', error);
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
@@ -1,524 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const MDEditor = dynamic(
|
||||
() => 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() {
|
||||
function LegacyWriteRedirect() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const editSlug = searchParams.get('slug');
|
||||
const isEditMode = !!editSlug;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('**Hello world!**');
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 💾 임시저장 관련 상태
|
||||
const [drafts, setDrafts] = useState<DraftPost[]>([]);
|
||||
const [showDraftList, setShowDraftList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (_hasHydrated && (!role || !role.includes('ADMIN'))) {
|
||||
toast.error('관리자 권한이 필요합니다.');
|
||||
router.push('/');
|
||||
}
|
||||
}, [role, _hasHydrated, router]);
|
||||
|
||||
// 컴포넌트 마운트 시 로컬스토리지에서 임시저장 목록 불러오기
|
||||
useEffect(() => {
|
||||
const savedDrafts = localStorage.getItem('temp_drafts');
|
||||
if (savedDrafts) {
|
||||
try {
|
||||
setDrafts(JSON.parse(savedDrafts));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drafts', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
|
||||
const { data: existingPost, isLoading: isLoadingPost } = useQuery({
|
||||
queryKey: ['post', editSlug],
|
||||
queryFn: () => getPost(editSlug!),
|
||||
enabled: isEditMode,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (existingPost) {
|
||||
setTitle(existingPost.title || '');
|
||||
setContent(existingPost.content || '');
|
||||
setTags(existingPost.tags ? existingPost.tags.join(', ') : '');
|
||||
|
||||
if (categories && existingPost.categoryName) {
|
||||
const found = findCategoryByName(categories, existingPost.categoryName);
|
||||
if (found) setCategoryId(found.id);
|
||||
}
|
||||
}
|
||||
}, [existingPost, categories]);
|
||||
|
||||
const findCategoryByName = (cats: any[], name: string): any => {
|
||||
for (const cat of cats) {
|
||||
if (cat.name === name) return cat;
|
||||
if (cat.children) {
|
||||
const found = findCategoryByName(cat.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 💾 임시저장 기능
|
||||
const handleTempSave = () => {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
toast.error('제목이나 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (drafts.length >= 10) {
|
||||
toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.');
|
||||
setShowDraftList(true); // 목록 열어주기
|
||||
return;
|
||||
}
|
||||
|
||||
const newDraft: DraftPost = {
|
||||
id: Date.now(),
|
||||
title: title || '(제목 없음)',
|
||||
content: content,
|
||||
savedAt: new Date().toLocaleString(),
|
||||
};
|
||||
|
||||
const newDrafts = [newDraft, ...drafts];
|
||||
setDrafts(newDrafts);
|
||||
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
|
||||
toast.success('임시저장 되었습니다!');
|
||||
};
|
||||
|
||||
// 💾 임시저장 불러오기
|
||||
const handleLoadDraft = (draft: DraftPost) => {
|
||||
if (confirm('현재 작성 중인 내용이 사라집니다.\n선택한 임시저장 글을 불러오시겠습니까?')) {
|
||||
setTitle(draft.title === '(제목 없음)' ? '' : draft.title);
|
||||
setContent(draft.content);
|
||||
setShowDraftList(false);
|
||||
toast.success('불러오기 완료');
|
||||
}
|
||||
};
|
||||
|
||||
// 💾 임시저장 삭제
|
||||
const handleDeleteDraft = (id: number) => {
|
||||
const newDrafts = drafts.filter(d => d.id !== id);
|
||||
setDrafts(newDrafts);
|
||||
localStorage.setItem('temp_drafts', JSON.stringify(newDrafts));
|
||||
toast.success('삭제되었습니다.');
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: any) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data),
|
||||
onSuccess: async (response: any) => {
|
||||
const savedPost = response?.data;
|
||||
const newSlug = savedPost?.slug || editSlug;
|
||||
|
||||
await queryClient.resetQueries({ queryKey: ['posts'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
|
||||
if (editSlug) {
|
||||
queryClient.removeQueries({ queryKey: ['post', editSlug] });
|
||||
}
|
||||
if (newSlug && newSlug !== editSlug) {
|
||||
queryClient.removeQueries({ queryKey: ['post', newSlug] });
|
||||
}
|
||||
|
||||
toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!');
|
||||
router.push(isEditMode ? `/posts/${newSlug}` : '/');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('저장 실패: ' + (err.response?.data?.message || err.message));
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const ensureAuthToken = async (): Promise<boolean> => {
|
||||
if (!accessToken || !refreshToken) {
|
||||
toast.error('로그인이 필요합니다.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isTokenExpired(accessToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 Access token expired during write. Refreshing...');
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/api/auth/reissue`,
|
||||
{ accessToken, refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' }, withCredentials: true }
|
||||
);
|
||||
|
||||
if (data.code === 'SUCCESS' && data.data) {
|
||||
login(data.data.accessToken, data.data.refreshToken);
|
||||
console.log('✅ Token refreshed successfully before save.');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to refresh token before save:', error);
|
||||
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !content.trim()) {
|
||||
toast.error('제목과 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (categoryId === '') {
|
||||
toast.error('카테고리를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const isTokenValid = await ensureAuthToken();
|
||||
if (!isTokenValid) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
title,
|
||||
content,
|
||||
categoryId: Number(categoryId),
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t),
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||
|
||||
try {
|
||||
await ensureAuthToken();
|
||||
|
||||
const res = await uploadImage(file);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
const imageUrl = res.data;
|
||||
const markdownImage = ``;
|
||||
setContent((prev) => prev + '\n' + markdownImage);
|
||||
toast.success('이미지가 업로드되었습니다.', { id: uploadToast });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('이미지 업로드 실패', { id: uploadToast });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = async (event: any) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
event.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const uploadToast = toast.loading('이미지 업로드 중...');
|
||||
|
||||
try {
|
||||
await ensureAuthToken();
|
||||
|
||||
const res = await uploadImage(file);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
const imageUrl = res.data;
|
||||
const markdownImage = ``;
|
||||
setContent((prev) => prev + '\n' + markdownImage);
|
||||
toast.success('이미지 붙여넣기 완료!', { id: uploadToast });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('이미지 업로드 실패', { id: uploadToast });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditMode && isLoadingPost) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCategoryOptions = (cats: any[], depth = 0) => {
|
||||
return cats.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
<label className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer rounded transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="category"
|
||||
value={cat.id}
|
||||
checked={categoryId === cat.id}
|
||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span style={{ marginLeft: depth * 10 + 'px' }} className={depth === 0 ? 'font-medium' : 'text-gray-600'}>
|
||||
{depth > 0 && '- '} {cat.name}
|
||||
</span>
|
||||
</label>
|
||||
{cat.children && renderCategoryOptions(cat.children, depth + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
router.replace(editSlug ? `/admin/posts/${editSlug}/edit` : '/admin/posts/new');
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}>
|
||||
{/* 상단 헤더 영역 */}
|
||||
<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 className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WritePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
||||
<WritePageContent />
|
||||
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>}>
|
||||
<LegacyWriteRedirect />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
432
src/components/admin/AdminCategoryPanel.tsx
Normal file
432
src/components/admin/AdminCategoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
src/components/admin/AdminCommentsPanel.tsx
Normal file
289
src/components/admin/AdminCommentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
698
src/components/admin/AdminPostEditor.tsx
Normal file
698
src/components/admin/AdminPostEditor.tsx
Normal 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' : ''}`);
|
||||
};
|
||||
|
||||
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="mx-auto w-full px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6" 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>
|
||||
);
|
||||
}
|
||||
671
src/components/admin/AdminPostsPanel.tsx
Normal file
671
src/components/admin/AdminPostsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
src/components/admin/AdminProfilePanel.tsx
Normal file
230
src/components/admin/AdminProfilePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/admin/AdminRouteShell.tsx
Normal file
92
src/components/admin/AdminRouteShell.tsx
Normal 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 space-y-6 px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6">
|
||||
<WindowSurface title="Admin Console" subtitle="Operations toolbar" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
82
src/components/admin/dashboard/AdminDashboardOverview.tsx
Normal file
82
src/components/admin/dashboard/AdminDashboardOverview.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { CalendarClock, Eye, FileText, FolderTree, MessageSquareText } from 'lucide-react';
|
||||
import MetricCard from '@/components/ui/MetricCard';
|
||||
import { DashboardOverview } from '@/types';
|
||||
|
||||
interface AdminDashboardOverviewProps {
|
||||
overview?: DashboardOverview;
|
||||
fallbackTotals: {
|
||||
totalPosts: number;
|
||||
totalComments: number;
|
||||
totalCategories: number;
|
||||
lastPublishedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '최근 발행 정보 없음';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '최근 발행 정보 없음';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardOverview({
|
||||
overview,
|
||||
fallbackTotals,
|
||||
}: AdminDashboardOverviewProps) {
|
||||
const totalPosts = overview?.totalPosts ?? fallbackTotals.totalPosts;
|
||||
const totalComments = overview?.totalComments ?? fallbackTotals.totalComments;
|
||||
const totalCategories = overview?.totalCategories ?? fallbackTotals.totalCategories;
|
||||
const lastPublishedAt = overview?.lastPublishedAt ?? fallbackTotals.lastPublishedAt;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MetricCard
|
||||
label="오늘 조회수"
|
||||
value={overview?.todayViews.value ?? null}
|
||||
changeRate={overview?.todayViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="최근 7일 조회수"
|
||||
value={overview?.weekViews.value ?? null}
|
||||
changeRate={overview?.weekViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="최근 30일 조회수"
|
||||
value={overview?.monthViews.value ?? null}
|
||||
changeRate={overview?.monthViews.changeRate}
|
||||
helper="백엔드 집계가 준비되면 표시됩니다."
|
||||
disabled={!overview}
|
||||
icon={<Eye size={18} />}
|
||||
/>
|
||||
<MetricCard label="총 게시글" value={totalPosts} icon={<FileText size={18} />} />
|
||||
<MetricCard label="총 댓글" value={totalComments} icon={<MessageSquareText size={18} />} />
|
||||
<MetricCard
|
||||
label="카테고리"
|
||||
value={totalCategories}
|
||||
helper={formatDateTime(lastPublishedAt)}
|
||||
icon={<FolderTree size={18} />}
|
||||
/>
|
||||
{overview?.generatedAt && (
|
||||
<div className="sm:col-span-2 xl:col-span-3">
|
||||
<p className="inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<CalendarClock size={14} />
|
||||
마지막 집계 {formatDateTime(overview.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Flame, TrendingUp } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardPostStat } from '@/types';
|
||||
|
||||
interface AdminDashboardPostPerformanceProps {
|
||||
topPosts: DashboardPostStat[];
|
||||
risingPosts: DashboardPostStat[];
|
||||
stalePopularPosts: DashboardPostStat[];
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
function PostStatRow({ post, isFallback }: { post: DashboardPostStat; isFallback: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-[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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { DashboardRange, DashboardTrafficPoint } from '@/types';
|
||||
|
||||
interface AdminDashboardTrafficChartProps {
|
||||
points: DashboardTrafficPoint[];
|
||||
range: DashboardRange;
|
||||
onRangeChange: (range: DashboardRange) => void;
|
||||
isFallback: boolean;
|
||||
}
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: '7일', value: '7d' },
|
||||
{ label: '30일', value: '30d' },
|
||||
{ label: '90일', value: '90d' },
|
||||
] satisfies { label: string; value: DashboardRange }[];
|
||||
|
||||
const formatDay = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function AdminDashboardTrafficChart({
|
||||
points,
|
||||
range,
|
||||
onRangeChange,
|
||||
isFallback,
|
||||
}: AdminDashboardTrafficChartProps) {
|
||||
const maxViews = Math.max(...points.map((point) => point.views), 1);
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-6 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">트래픽 흐름</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
최근 기간별 조회수 흐름을 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
ariaLabel="트래픽 기간"
|
||||
options={rangeOptions}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{points.length > 0 ? (
|
||||
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
|
||||
{points.map((point) => {
|
||||
const height = Math.max((point.views / maxViews) * 100, 4);
|
||||
|
||||
return (
|
||||
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
||||
/>
|
||||
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
||||
{formatDay(point.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="통계 API 연결 전"
|
||||
description={isFallback ? '백엔드 집계가 준비되면 기간별 그래프가 표시됩니다.' : '표시할 트래픽 데이터가 없습니다.'}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
461
src/components/chess/ChessPuzzleClient.tsx
Normal file
461
src/components/chess/ChessPuzzleClient.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
'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 PieceSymbol, 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 WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { type ChessPuzzle } from '@/types';
|
||||
|
||||
type FeedbackTone = 'neutral' | 'success' | 'error';
|
||||
|
||||
const TIMEZONE = 'Asia/Seoul';
|
||||
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const;
|
||||
const RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const;
|
||||
const BOARD_SQUARES = RANKS.flatMap((rank) => FILES.map((file) => `${file}${rank}` as Square));
|
||||
const BOARD_SIZE_STYLE: CSSProperties = {
|
||||
width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))',
|
||||
};
|
||||
|
||||
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 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 getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => {
|
||||
if (!piece) return `${square} 빈 칸`;
|
||||
|
||||
return `${square} ${turnLabel(piece.color)} ${PIECE_NAMES[piece.type]}`;
|
||||
};
|
||||
|
||||
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-3 md:p-5">
|
||||
<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 gap-5 lg:grid-cols-[minmax(0,1fr)_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-black/[0.06]' : 'bg-black/[0.12]',
|
||||
'dark:bg-white/[0.08]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</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<{ from: Square; to: Square } | 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 tryMove = (from: Square, to: Square) => {
|
||||
const candidate = legalMoves.find((move) => move.to === 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 gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<BoardWindow>
|
||||
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
|
||||
{BOARD_SQUARES.map((square, index) => {
|
||||
const piece = game.get(square);
|
||||
const file = square[0];
|
||||
const rank = square[1];
|
||||
const fileIndex = index % 8;
|
||||
const rankIndex = Math.floor(index / 8);
|
||||
const isLight = (fileIndex + rankIndex) % 2 === 0;
|
||||
const isSelected = selectedSquare === square;
|
||||
const isTarget = legalTargets.has(square);
|
||||
const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={square}
|
||||
type="button"
|
||||
onClick={() => handleSquareClick(square)}
|
||||
className={clsx(
|
||||
'relative flex aspect-square items-center justify-center overflow-hidden text-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.75rem]',
|
||||
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
|
||||
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
|
||||
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
|
||||
!solved && 'hover:brightness-105 focus-visible:z-20',
|
||||
)}
|
||||
aria-label={getSquareLabel(square, piece)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{file === 'a' && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
|
||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
||||
)}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)}
|
||||
{rank === '1' && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
|
||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
||||
)}
|
||||
>
|
||||
{file}
|
||||
</span>
|
||||
)}
|
||||
{isTarget && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute h-4 w-4 rounded-full',
|
||||
piece ? 'h-full w-full rounded-none ring-4 ring-black/20 ring-inset' : 'bg-black/20',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{piece && (
|
||||
<span
|
||||
className={clsx(
|
||||
'relative z-10 select-none',
|
||||
piece.color === 'w'
|
||||
? 'text-[#fbfbfb] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.55)]'
|
||||
: 'text-[#202124] [text-shadow:0_1px_1px_rgb(255_255_255_/_0.22)]',
|
||||
)}
|
||||
>
|
||||
{PIECE_SYMBOLS[piece.color][piece.type]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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} />;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createComment } from '@/api/comments';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CommentFormProps {
|
||||
postSlug: string;
|
||||
@@ -15,7 +14,7 @@ interface CommentFormProps {
|
||||
}
|
||||
|
||||
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
|
||||
const { isLoggedIn, role } = useAuthStore();
|
||||
const { isLoggedIn } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
@@ -29,8 +28,16 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
|
||||
onError: (error: unknown) => {
|
||||
const responseError = error as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
alert('댓글 작성 실패: ' + (responseError.response?.data?.message || fallbackMessage));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,7 +62,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
};
|
||||
|
||||
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 && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
@@ -64,7 +71,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
placeholder="닉네임"
|
||||
value={guestInfo.nickname}
|
||||
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
|
||||
/>
|
||||
<input
|
||||
@@ -72,7 +79,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
placeholder="비밀번호"
|
||||
value={guestInfo.password}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
@@ -84,13 +91,13 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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="등록"
|
||||
>
|
||||
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useState } from 'react';
|
||||
import { Comment } from '@/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { deleteComment, deleteAdminComment } from '@/api/comments';
|
||||
import { deleteComment } from '@/api/comments';
|
||||
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 { clsx } from 'clsx';
|
||||
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
||||
@@ -17,8 +17,19 @@ interface CommentItemProps {
|
||||
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) {
|
||||
const { isLoggedIn, role, user } = useAuthStore();
|
||||
const { isLoggedIn, user } = useAuthStore();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
@@ -26,7 +37,6 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [guestPassword, setGuestPassword] = useState('');
|
||||
|
||||
const isAdmin = isLoggedIn && role?.includes('ADMIN');
|
||||
const isGuestComment = !comment.memberId;
|
||||
|
||||
const isMyComment = isLoggedIn && !isGuestComment && (
|
||||
@@ -34,7 +44,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
(user?.nickname === comment.author)
|
||||
);
|
||||
|
||||
const showDeleteButton = isAdmin || isGuestComment || isMyComment;
|
||||
const showDeleteButton = isGuestComment || isMyComment;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
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] });
|
||||
toast.success('댓글이 삭제되었습니다.');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.'));
|
||||
},
|
||||
});
|
||||
|
||||
const adminDeleteMutation = useMutation({
|
||||
mutationFn: deleteAdminComment,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
|
||||
toast.success('관리자 권한으로 삭제했습니다.');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message));
|
||||
onError: (error) => {
|
||||
toast.error(`삭제 실패: ${getErrorMessage(error, '비밀번호가 틀렸습니다.')}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
// A. 관리자 -> 즉시 삭제 (컨펌만)
|
||||
if (isAdmin) {
|
||||
if (confirm('관리자 권한으로 삭제하시겠습니까?')) {
|
||||
adminDeleteMutation.mutate(comment.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// B. 내 댓글 -> 즉시 삭제 (컨펌만)
|
||||
if (isMyComment) {
|
||||
if (confirm('이 댓글을 삭제하시겠습니까?')) {
|
||||
deleteMutation.mutate({ id: comment.id });
|
||||
@@ -94,17 +84,19 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
<div
|
||||
className={clsx(
|
||||
"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 gap-2">
|
||||
<div
|
||||
className={clsx(
|
||||
"p-1.5 rounded-full flex items-center justify-center",
|
||||
comment.isPostAuthor ? "bg-blue-100 text-blue-600" :
|
||||
!isGuestComment ? "bg-green-100 text-green-600" :
|
||||
"bg-gray-100 text-gray-500"
|
||||
"flex items-center justify-center rounded-full p-1.5",
|
||||
comment.isPostAuthor ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
|
||||
!isGuestComment ? "bg-green-500/15 text-green-600 dark:text-green-300" :
|
||||
"bg-black/[0.05] text-[var(--color-text-muted)] dark:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{comment.isPostAuthor ? <CheckCircle2 size={14} /> :
|
||||
@@ -113,11 +105,11 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
</div>
|
||||
|
||||
<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.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 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')}
|
||||
</span>
|
||||
</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">
|
||||
<button
|
||||
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="답글 달기"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
@@ -137,30 +129,30 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
onClick={handleDeleteClick}
|
||||
className={clsx(
|
||||
"p-1.5 rounded transition-colors",
|
||||
isDeleting ? "bg-red-50 text-red-600" :
|
||||
isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50"
|
||||
isDeleting ? "bg-red-500/10 text-red-600" :
|
||||
"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>
|
||||
)}
|
||||
</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}
|
||||
</p>
|
||||
|
||||
{/* 🎨 비회원 비밀번호 입력창 (인라인) */}
|
||||
{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
|
||||
type="password"
|
||||
placeholder="비밀번호 입력"
|
||||
value={guestPassword}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
@@ -171,7 +163,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
</button>
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
@@ -180,7 +172,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
</div>
|
||||
|
||||
{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
|
||||
postSlug={postSlug}
|
||||
parentId={comment.id}
|
||||
@@ -191,7 +183,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
|
||||
)}
|
||||
|
||||
{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) => (
|
||||
<CommentItem
|
||||
key={child.id}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
import { getComments } from '@/api/comments';
|
||||
import { Comment } from '@/types';
|
||||
import CommentForm from './CommentForm';
|
||||
import CommentItem from './CommentItem';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
|
||||
interface CommentListProps {
|
||||
postSlug: string;
|
||||
}
|
||||
|
||||
export default function CommentList({ postSlug }: CommentListProps) {
|
||||
// 댓글 목록 조회
|
||||
const { data: comments, isLoading, error } = useQuery({
|
||||
queryKey: ['comments', postSlug],
|
||||
queryFn: () => getComments(postSlug),
|
||||
});
|
||||
|
||||
// 총 댓글 수 계산 (재귀)
|
||||
const countComments = (list: any[]): number => {
|
||||
const countComments = (list: Comment[]): number => {
|
||||
if (!list) return 0;
|
||||
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||
};
|
||||
@@ -26,36 +25,38 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
const totalCount = comments ? countComments(comments) : 0;
|
||||
|
||||
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) {
|
||||
return <div className="py-10 text-center text-red-500">댓글을 불러오는데 실패했습니다.</div>;
|
||||
return <div className="py-10 text-center text-red-500">댓글을 불러오는 데 실패했습니다.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-16 pt-10 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<MessageCircle className="text-blue-600" size={24} />
|
||||
<h3 className="text-xl font-bold text-gray-800">
|
||||
댓글 <span className="text-blue-600">{totalCount}</span>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<MessageCircle className="text-[var(--color-accent)]" size={24} />
|
||||
<h3 className="text-xl font-bold text-[var(--color-text)]">
|
||||
댓글 <span className="text-[var(--color-accent)]">{totalCount}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 최상위 댓글 작성 폼 */}
|
||||
<div className="mb-10">
|
||||
<CommentForm postSlug={postSlug} />
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
<div className="space-y-6">
|
||||
{comments && comments.length > 0 ? (
|
||||
comments.map((comment) => (
|
||||
<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>
|
||||
|
||||
646
src/components/layout/DesktopDock.tsx
Normal file
646
src/components/layout/DesktopDock.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
'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 {
|
||||
isSidebarCollapsed: boolean;
|
||||
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({ isSidebarCollapsed, 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: '/play/chess',
|
||||
label: '체스',
|
||||
icon: Crown,
|
||||
isActive: isActivePath('/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-0 left-1/2 z-40 hidden -translate-x-1/2 md:block',
|
||||
'transition-[left] duration-300 ease-out',
|
||||
isSidebarCollapsed ? 'md:left-[calc(50%+2.5rem)]' : 'md:left-[calc(50%+9rem)]',
|
||||
)}
|
||||
>
|
||||
<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-12 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-9 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-lg 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/layout/DesktopMenuBar.tsx
Normal file
84
src/components/layout/DesktopMenuBar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { clsx } from 'clsx';
|
||||
import { getProfile } from '@/api/profile';
|
||||
import ThemeToggle from '@/components/theme/ThemeToggle';
|
||||
|
||||
interface DesktopMenuBarProps {
|
||||
isSidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
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 'Posts';
|
||||
if (pathname.startsWith('/admin/comments')) return 'Comments';
|
||||
if (pathname.startsWith('/admin/categories')) return 'Categories';
|
||||
if (pathname.startsWith('/admin/profile')) return 'Profile';
|
||||
if (pathname.startsWith('/admin')) return 'Dashboard';
|
||||
if (pathname.startsWith('/archive')) return 'Archive';
|
||||
if (pathname.startsWith('/category')) return getCategoryTitle(pathname);
|
||||
if (pathname.startsWith('/posts')) return 'Reader';
|
||||
if (pathname.startsWith('/play/chess')) return 'Chess';
|
||||
if (pathname.startsWith('/login')) return 'Login';
|
||||
if (pathname.startsWith('/signup')) return 'Signup';
|
||||
return '홈';
|
||||
};
|
||||
|
||||
export default function DesktopMenuBar({ isSidebarCollapsed }: DesktopMenuBarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const githubUrl = profile?.githubUrl || defaultProfile.githubUrl;
|
||||
const email = profile?.email || defaultProfile.email;
|
||||
const menuLinkClass = 'text-xs font-semibold text-[var(--color-text-subtle)] transition hover:text-[var(--color-text)]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-6 top-3 z-40 hidden transition-[left] duration-300 ease-out md:block',
|
||||
isSidebarCollapsed ? 'md:left-24' : 'md:left-[19rem]',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between gap-3 rounded-lg border border-[var(--menubar-border)] bg-[var(--menubar-bg)] px-3 shadow-[var(--shadow-menubar)] backdrop-blur-[24px]">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Link href="/" className="shrink-0 text-sm font-bold text-[var(--color-text)]">
|
||||
WYPark
|
||||
</Link>
|
||||
<span className="hidden h-4 w-px bg-[var(--color-line)] sm:block" />
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text-muted)]">
|
||||
{getAppTitle(pathname)}
|
||||
</span>
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<a href={githubUrl} target="_blank" rel="noreferrer" className={menuLinkClass}>
|
||||
GitHub
|
||||
</a>
|
||||
<a href={`mailto:${email}`} className={menuLinkClass}>
|
||||
Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/layout/DesktopShell.tsx
Normal file
76
src/components/layout/DesktopShell.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'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('/play/chess');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-x-clip">
|
||||
<Sidebar
|
||||
isDesktopCollapsed={isSidebarCollapsed}
|
||||
isMobileOpen={isMobileSidebarOpen}
|
||||
onDesktopCollapsedChange={handleSidebarCollapsedChange}
|
||||
onMobileOpenChange={setIsMobileSidebarOpen}
|
||||
/>
|
||||
<DesktopMenuBar isSidebarCollapsed={isSidebarCollapsed} />
|
||||
<DesktopDock
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onOpenMobileMenu={() => setIsMobileSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<main
|
||||
className={clsx(
|
||||
'relative min-w-0 max-w-full flex-1 overflow-x-clip transition-[margin,width] duration-300 ease-out',
|
||||
isSidebarCollapsed
|
||||
? 'md:ml-20 md:w-[calc(100%-5rem)]'
|
||||
: 'md:ml-72 md:w-[calc(100%-18rem)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'mx-auto min-w-0 max-w-full px-3 pt-16 md:px-6 md:pt-24 lg:px-8',
|
||||
isChessRoute ? 'pb-44 md:pb-40' : isReaderRoute ? 'pb-44 md:pb-36' : 'pb-36 md:pb-32',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, Suspense } from 'react';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
// 🎨 이미지 최적화를 위해 next/image 사용
|
||||
import Image from 'next/image';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/category';
|
||||
import { getProfile, updateProfile } from '@/api/profile';
|
||||
import { uploadImage } from '@/api/image';
|
||||
import {
|
||||
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
|
||||
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion,
|
||||
Archive
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { clsx } from 'clsx';
|
||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import toast from 'react-hot-toast';
|
||||
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트 추가
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Crown,
|
||||
FileQuestion,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Home,
|
||||
} 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 => {
|
||||
for (const cat of categories) {
|
||||
if (cat.id === id) return cat.name;
|
||||
if (cat.children && cat.children.length > 0) {
|
||||
const found = findCategoryNameById(cat.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
interface SidebarProps {
|
||||
isDesktopCollapsed: boolean;
|
||||
isMobileOpen: boolean;
|
||||
onDesktopCollapsedChange: (nextValue: boolean) => void;
|
||||
onMobileOpenChange: (nextValue: boolean) => void;
|
||||
}
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: Category;
|
||||
depth: number;
|
||||
onNavigate: () => void;
|
||||
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 hasActiveChild = useMemo(() => {
|
||||
const check = (cats: Category[] | undefined): boolean => {
|
||||
if (!cats) return false;
|
||||
return cats.some(c =>
|
||||
decodeURIComponent(pathname) === `/category/${c.name}` || check(c.children)
|
||||
);
|
||||
const check = (categories: Category[] | undefined): boolean => {
|
||||
if (!categories) return false;
|
||||
|
||||
return categories.some((child) => {
|
||||
return decodeURIComponent(pathname) === `/category/${child.name}` || check(child.children);
|
||||
});
|
||||
};
|
||||
|
||||
return check(category.children);
|
||||
}, [category.children, pathname]);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive || hasActiveChild) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [isActive, hasActiveChild]);
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('categoryId', category.id.toString());
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isEditMode) {
|
||||
setIsDragOver(true);
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (!isEditMode) return;
|
||||
|
||||
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
||||
if (!draggedId || draggedId === category.id) return;
|
||||
|
||||
if (confirm(`'${category.name}' 하위로 이동하시겠습니까?`)) {
|
||||
onDrop(draggedId, category.id);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedChildren = useMemo(() => {
|
||||
if (!category.children) return [];
|
||||
return [...category.children].sort((a, b) => a.id - b.id);
|
||||
}, [category.children]);
|
||||
const isChildrenVisible = isExpanded || isActive || hasActiveChild;
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-4 py-2 text-sm rounded-lg transition-all group relative',
|
||||
isActive && !isEditMode ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50',
|
||||
isEditMode && 'border border-dashed border-gray-300 hover:border-blue-400 cursor-move bg-white',
|
||||
isDragOver && 'bg-blue-100 border-blue-500'
|
||||
'flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
|
||||
)}
|
||||
style={{ marginLeft: `${depth * 12}px` }}
|
||||
draggable={isEditMode}
|
||||
onDragStart={isEditMode ? handleDragStart : undefined}
|
||||
onDragOver={isEditMode ? handleDragOver : undefined}
|
||||
onDragLeave={isEditMode ? handleDragLeave : undefined}
|
||||
onDrop={isEditMode ? handleDrop : undefined}
|
||||
style={{ marginLeft: `${depth * 8}px` }}
|
||||
>
|
||||
<Link
|
||||
href={`/category/${category.name}`}
|
||||
onClick={onNavigate}
|
||||
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} />}
|
||||
<span>{category.name}</span>
|
||||
<span className="truncate">{category.name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2.5">
|
||||
<Move size={14} className="text-gray-400" />
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAdd(category.id); }}
|
||||
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||
title="하위 카테고리 추가"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(category.id); }}
|
||||
className="p-1 text-red-500 hover:bg-red-100 rounded"
|
||||
title="카테고리 삭제"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditMode && category.children && category.children.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsExpanded((previous) => !previous);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200/50 rounded-full transition-colors ml-1"
|
||||
className="ml-1 rounded-full p-1 transition-colors hover:bg-[var(--card-bg)]"
|
||||
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={clsx(
|
||||
"text-gray-400 transition-transform duration-200",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
className={clsx('text-[var(--color-text-subtle)] transition-transform duration-200', isChildrenVisible && 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && sortedChildren.length > 0 && (
|
||||
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in">
|
||||
{isChildrenVisible && hasChildren && (
|
||||
<div className="ml-4 border-l border-[var(--color-line)]">
|
||||
{sortedChildren.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={child}
|
||||
depth={0}
|
||||
depth={depth + 1}
|
||||
onNavigate={onNavigate}
|
||||
pathname={pathname}
|
||||
isEditMode={isEditMode}
|
||||
onDrop={onDrop}
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -192,59 +123,23 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가
|
||||
|
||||
function SidebarContent({
|
||||
isDesktopCollapsed,
|
||||
isMobileOpen,
|
||||
onDesktopCollapsedChange,
|
||||
onMobileOpenChange,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { role, _hasHydrated } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 🆕 검색 로직 추가
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true); // 🛠️ [Fix] 클라이언트 마운트 후 true로 설정
|
||||
}, []);
|
||||
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
if (newKeyword.trim()) {
|
||||
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<ProfileUpdateRequest>({
|
||||
name: '', bio: '', imageUrl: '', githubUrl: '', email: '',
|
||||
});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isCategoryEditMode, setIsCategoryEditMode] = useState(false);
|
||||
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
||||
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
setIsCategoryEditMode(false);
|
||||
setIsEditModalOpen(false);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!categories) return undefined;
|
||||
return [...categories].sort((a, b) => a.id - b.id);
|
||||
}, [categories]);
|
||||
const sortedCategories = useMemo(() => sortCategories(categories), [categories]);
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
@@ -252,147 +147,67 @@ function SidebarContent() {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const createCategoryMutation = useMutation({
|
||||
mutationFn: createCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
toast.success('카테고리가 생성되었습니다.');
|
||||
},
|
||||
onError: (err: any) => toast.error('생성 실패: ' + (err.response?.data?.message || err.message)),
|
||||
});
|
||||
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||
const decodedPathname = decodeURIComponent(pathname);
|
||||
|
||||
const updateCategoryMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => updateCategory(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }),
|
||||
onError: (err: any) => toast.error('이동 실패: ' + (err.response?.data?.message || err.message)),
|
||||
});
|
||||
const closeSidebar = () => onMobileOpenChange(false);
|
||||
|
||||
const deleteCategoryMutation = useMutation({
|
||||
mutationFn: deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
toast.success('카테고리가 삭제되었습니다.');
|
||||
},
|
||||
onError: (err: any) => toast.error('삭제 실패: ' + (err.response?.data?.message || err.message)),
|
||||
});
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
setIsEditModalOpen(false);
|
||||
toast.success('프로필이 수정되었습니다!');
|
||||
},
|
||||
onError: (error: any) => toast.error('수정 실패: ' + (error.response?.data?.message || error.message)),
|
||||
});
|
||||
|
||||
const handleAddCategory = (parentId: number | null) => {
|
||||
const name = prompt('새 카테고리 이름을 입력하세요:');
|
||||
if (!name || !name.trim()) return;
|
||||
createCategoryMutation.mutate({ name, parentId });
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (id: number) => {
|
||||
if (confirm('정말로 이 카테고리를 삭제하시겠습니까?\n하위 카테고리와 게시글이 모두 삭제될 수 있습니다.')) {
|
||||
deleteCategoryMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCategory = useCallback((draggedId: number, targetParentId: number | null) => {
|
||||
if (draggedId === targetParentId) return;
|
||||
if (!categories) return;
|
||||
|
||||
const currentName = findCategoryNameById(categories, draggedId);
|
||||
if (!currentName) {
|
||||
toast.error('카테고리 정보를 찾을 수 없습니다.');
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
const trimmedKeyword = newKeyword.trim();
|
||||
if (trimmedKeyword) {
|
||||
router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
updateCategoryMutation.mutate({
|
||||
id: draggedId,
|
||||
data: { name: currentName, parentId: targetParentId }
|
||||
});
|
||||
}, [categories, updateCategoryMutation]);
|
||||
|
||||
const handleRootDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsRootDragOver(false);
|
||||
const draggedId = Number(e.dataTransfer.getData('categoryId'));
|
||||
if (!draggedId) return;
|
||||
|
||||
if (confirm('이 카테고리를 최상위로 이동하시겠습니까?')) {
|
||||
handleMoveCategory(draggedId, null);
|
||||
}
|
||||
router.push('/');
|
||||
closeSidebar();
|
||||
};
|
||||
|
||||
const defaultProfile: Profile = {
|
||||
name: 'Dev Park',
|
||||
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
||||
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
||||
githubUrl: 'https://github.com',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
|
||||
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditForm({
|
||||
name: displayProfile.name,
|
||||
bio: displayProfile.bio,
|
||||
imageUrl: displayProfile.imageUrl,
|
||||
githubUrl: displayProfile.githubUrl || '',
|
||||
email: displayProfile.email || '',
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
if (res.code === 'SUCCESS' && res.data) {
|
||||
setEditForm((prev) => ({ ...prev, imageUrl: res.data }));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('이미지 업로드 오류');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateProfileMutation.mutate(editForm);
|
||||
};
|
||||
const compactCategoryItems = sortedCategories?.slice(0, 8) ?? [];
|
||||
|
||||
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">
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 🛠️ [수정] overflow-y-auto 제거, overflow-hidden 추가 */}
|
||||
<aside className={clsx('fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out flex flex-col overflow-hidden', isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0')}>
|
||||
|
||||
{/* 🛠️ [수정] 고정 영역: 프로필 (shrink-0 추가) */}
|
||||
<div className={clsx('p-6 text-center transition-opacity duration-200 relative group shrink-0', !isOpen && 'md:opacity-0 md:hidden')}>
|
||||
{isAdmin && (
|
||||
<button onClick={handleEditClick} className="absolute top-4 right-4 p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-all opacity-0 group-hover:opacity-100 z-10" title="프로필 수정">
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="사이드바 닫기"
|
||||
onClick={closeSidebar}
|
||||
className={clsx(
|
||||
'fixed inset-0 z-50 bg-black/35 backdrop-blur-sm transition-opacity md:hidden',
|
||||
isMobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
||||
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
|
||||
{/* 🛠️ [Fix] isMounted를 체크하여 첫 렌더링 시 무조건 스켈레톤을 보여줍니다. (서버와 일치시킴) */}
|
||||
{!isMounted || isProfileLoading ? (
|
||||
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-[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:z-40 md:max-w-none md:translate-x-0',
|
||||
isDesktopCollapsed ? 'md:w-20' : 'md:w-72',
|
||||
isMobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('relative shrink-0 text-center', isDesktopCollapsed ? 'px-6 pb-5 pt-8 md:px-3 md:pb-4 md:pt-5' : 'px-6 pb-5 pt-8')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDesktopCollapsedChange(!isDesktopCollapsed)}
|
||||
className="absolute right-3 top-3 hidden h-8 w-8 items-center justify-center rounded-full 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>
|
||||
|
||||
<Link href="/" onClick={closeSidebar} className="block transition-opacity hover:opacity-85">
|
||||
<div
|
||||
className={clsx(
|
||||
'relative mx-auto overflow-hidden rounded-2xl bg-black/[0.04] shadow-inner ring-1 ring-[var(--color-line)] transition-all dark:bg-white/10',
|
||||
isDesktopCollapsed ? 'mb-4 h-20 w-20 md:mb-0 md:mt-8 md:h-12 md:w-12' : 'mb-4 h-20 w-20',
|
||||
)}
|
||||
>
|
||||
{isProfileLoading ? (
|
||||
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
|
||||
) : (
|
||||
<Image
|
||||
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
|
||||
alt="Profile"
|
||||
alt="프로필"
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
@@ -401,28 +216,27 @@ function SidebarContent() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 🛠️ [Fix] 텍스트 영역도 동일하게 처리 */}
|
||||
{!isMounted || isProfileLoading ? (
|
||||
<div className="space-y-2 flex flex-col items-center"><div className="h-6 w-24 bg-gray-200 rounded animate-pulse" /><div className="h-4 w-32 bg-gray-100 rounded animate-pulse" /></div>
|
||||
|
||||
<div className={clsx(isDesktopCollapsed && 'md:hidden')}>
|
||||
{isProfileLoading ? (
|
||||
<div className="flex flex-col items-center 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>
|
||||
<p className="text-sm text-gray-500 mt-1 whitespace-pre-line leading-relaxed">{displayProfile.bio}</p>
|
||||
<h2 className="text-lg font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 🛠️ [수정] min-h-0 추가하여 내부 스크롤 동작 보장 */}
|
||||
<nav className="flex-1 px-4 py-2 flex flex-col min-h-0">
|
||||
<div className={clsx('flex flex-col items-center gap-4 mt-4 shrink-0', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
||||
|
||||
<div className={clsx('flex flex-col h-full', !isOpen && 'md:hidden')}>
|
||||
|
||||
{/* 🛠️ [수정] 상단 고정 네비게이션 영역 (shrink-0) */}
|
||||
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
|
||||
<div className={clsx('flex h-full flex-col', isDesktopCollapsed && 'md:hidden')}>
|
||||
<div className="shrink-0 space-y-1">
|
||||
{/* 🆕 전체 검색바 (Archives 위) */}
|
||||
<div className="px-1 mb-6 mt-2">
|
||||
<div id="blog-search" className="mb-5 mt-2 px-1">
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder="검색..."
|
||||
@@ -430,128 +244,162 @@ function SidebarContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. 아카이브 링크 */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/archive"
|
||||
className={clsx(
|
||||
'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 className="mb-4 border-t border-[var(--color-line)]" />
|
||||
|
||||
<div className="mb-3 flex h-8 items-center justify-between px-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-subtle)]">카테고리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-100 mb-4" />
|
||||
|
||||
{/* 1. 카테고리 섹션 헤더 (고정) */}
|
||||
<div className="flex items-center justify-between px-4 mb-3 h-8">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
{!isCategoryEditMode ? (
|
||||
<button onClick={() => setIsCategoryEditMode(true)} className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="카테고리 관리"><Settings size={14} /></button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleAddCategory(null)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="최상위 카테고리 추가"><Plus size={16} /></button>
|
||||
<button onClick={() => setIsCategoryEditMode(false)} className="p-1 text-gray-500 hover:bg-gray-100 rounded" title="관리 종료"><X size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2 scrollbar-hide">
|
||||
{!categories && (
|
||||
<div className="space-y-2 px-4">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="h-8 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🛠️ [수정] 스크롤 가능한 카테고리 리스트 영역 (flex-1, overflow-y-auto) */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide -mx-2 px-2">
|
||||
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>}
|
||||
|
||||
<div
|
||||
className={clsx("min-h-[50px] transition-colors rounded-lg mb-6", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")}
|
||||
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
|
||||
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
||||
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
||||
>
|
||||
{sortedCategories?.map((cat) => (
|
||||
<CategoryItem key={cat.id} category={cat} depth={0} pathname={pathname} isEditMode={isCategoryEditMode} onDrop={handleMoveCategory} onAdd={handleAddCategory} onDelete={handleDeleteCategory} />
|
||||
<div className="mb-6 min-h-[50px] rounded-lg">
|
||||
{sortedCategories?.map((category) => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
depth={0}
|
||||
onNavigate={closeSidebar}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isCategoryEditMode && (
|
||||
<div className="mb-1 mt-2">
|
||||
<Link
|
||||
href="/category/uncategorized"
|
||||
onClick={closeSidebar}
|
||||
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'
|
||||
? 'bg-blue-50 text-blue-600 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
|
||||
)}
|
||||
>
|
||||
<FileQuestion size={16} />
|
||||
<span>미분류</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mx-2 mt-3 shrink-0 border-t border-[var(--color-line)] px-2 pt-3">
|
||||
<div className="mb-2 flex h-8 items-center px-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-subtle)]">앱</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/play/chess"
|
||||
onClick={closeSidebar}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
|
||||
pathname === '/play/chess'
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
|
||||
)}
|
||||
>
|
||||
<Crown size={16} />
|
||||
<span>체스</span>
|
||||
</Link>
|
||||
</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')}>
|
||||
<Link
|
||||
href="/"
|
||||
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 === '/' && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)]',
|
||||
)}
|
||||
>
|
||||
<Home size={18} />
|
||||
</Link>
|
||||
|
||||
<div className="my-1 h-px w-9 bg-[var(--color-line)]" />
|
||||
|
||||
{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 className="my-1 h-px w-9 bg-[var(--color-line)]" />
|
||||
|
||||
<Link
|
||||
href="/play/chess"
|
||||
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 === '/play/chess' && 'bg-[var(--card-bg-strong)] text-[var(--color-accent)]',
|
||||
)}
|
||||
>
|
||||
<Crown size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 🛠️ [수정] 고정 영역: 푸터 (shrink-0 추가) */}
|
||||
<div className={clsx('p-6 border-t border-gray-100 bg-white shrink-0', !isOpen && 'hidden')}>
|
||||
<div className="flex justify-center gap-3">
|
||||
<a href={displayProfile.githubUrl || '#'} target="_blank" rel="noreferrer" className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm"><Github size={18} /></a>
|
||||
<a href={`mailto:${displayProfile.email}`} className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm"><Mail size={18} /></a>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.</p>
|
||||
<div
|
||||
className={clsx(
|
||||
'shrink-0 border-t border-[var(--color-line)] bg-[var(--window-titlebar)] p-5',
|
||||
isDesktopCollapsed && 'md:hidden',
|
||||
)}
|
||||
>
|
||||
<p className="text-center text-[10px] font-light text-[var(--color-text-subtle)]">
|
||||
© {new Date().getFullYear()} {displayProfile.name}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2"><Edit3 size={18} className="text-blue-600" /> 프로필 수정</h3>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="text-gray-400 hover:text-gray-600"><XCircle size={24} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="relative w-24 h-24 mb-3 group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
|
||||
<Image src={editForm.imageUrl || defaultProfile.imageUrl!} alt="Preview" fill className="rounded-full object-cover border-2 border-gray-100 group-hover:border-blue-300 transition-colors" unoptimized />
|
||||
<div className="absolute inset-0 bg-black/30 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"><Camera className="text-white" size={24} /></div>
|
||||
</div>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImageUpload} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-600 font-medium hover:underline" disabled={isUploading}>{isUploading ? '업로드 중...' : '이미지 변경'}</button>
|
||||
</div>
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">이름 (Name)</label><input type="text" value={editForm.name} onChange={(e) => setEditForm({...editForm, name: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" required /></div>
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">소개 (Bio)</label><textarea value={editForm.bio} onChange={(e) => setEditForm({...editForm, bio: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm resize-none h-24" required /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">Github URL</label><input type="url" value={editForm.githubUrl || ''} onChange={(e) => setEditForm({...editForm, githubUrl: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
||||
<div><label className="block text-xs font-bold text-gray-500 mb-1">Email</label><input type="email" value={editForm.email || ''} onChange={(e) => setEditForm({...editForm, email: e.target.value})} className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none text-sm" /></div>
|
||||
</div>
|
||||
<div className="pt-4 flex gap-2">
|
||||
<button type="button" onClick={() => setIsEditModalOpen(false)} className="flex-1 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors">취소</button>
|
||||
<button type="submit" disabled={updateProfileMutation.isPending || isUploading} className="flex-1 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-sm disabled:bg-gray-400 flex justify-center items-center gap-2">{updateProfileMutation.isPending ? '저장 중...' : <><Save size={16} /> 저장하기</>}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
export default function Sidebar(props: SidebarProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className="w-72 h-screen bg-white border-r border-gray-100" />}>
|
||||
<SidebarContent />
|
||||
<Suspense
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,102 @@
|
||||
// src/components/layout/TopHeader.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LogOut, PenLine, User, UserPlus } from 'lucide-react';
|
||||
import { LogOut, Menu, PenLine, Settings, User, UserPlus, X } from 'lucide-react';
|
||||
import ThemeToggle from '@/components/theme/ThemeToggle';
|
||||
|
||||
export default function TopHeader() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, role, logout } = useAuthStore(); // 👈 role 가져오기
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
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 = () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
closeMenu();
|
||||
logout();
|
||||
alert('로그아웃 되었습니다.');
|
||||
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 (
|
||||
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
|
||||
{isLoggedIn ? (
|
||||
<div
|
||||
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
|
||||
href="/write"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||
href="/admin"
|
||||
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} />
|
||||
<span>글쓰기</span>
|
||||
<span>새 글</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-600 text-sm font-medium rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-red-500 transition-colors"
|
||||
className="flex 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} />
|
||||
<span className="hidden sm:inline">로그아웃</span>
|
||||
@@ -53,21 +106,36 @@ export default function TopHeader() {
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 text-sm font-medium hover:text-blue-600 transition-colors"
|
||||
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} />
|
||||
<span>로그인</span>
|
||||
<span className="hidden sm:inline">로그인</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-blue-600 text-sm font-bold rounded-full border border-blue-100 shadow-sm hover:bg-blue-50 hover:shadow-md transition-all"
|
||||
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} />
|
||||
<span>회원가입</span>
|
||||
<span className="hidden sm:inline">회원가입</span>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
250
src/components/post/ArchiveExplorer.tsx
Normal file
250
src/components/post/ArchiveExplorer.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { clsx } from 'clsx';
|
||||
import { Calendar, ChevronRight, 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')}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
@@ -15,19 +16,14 @@ interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeSlug]}
|
||||
components={{
|
||||
const components: Components = {
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!inline && match) {
|
||||
if (match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
@@ -35,7 +31,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-red-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words"
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
@@ -46,7 +42,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-blue-500 bg-blue-50 pl-4 py-3 my-6 text-gray-700 rounded-r-lg italic shadow-sm">
|
||||
<blockquote className="my-7 rounded-r-lg border-l-4 border-[var(--color-accent)] bg-[var(--color-accent-soft)] py-3 pl-5 pr-4 text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
@@ -60,10 +56,10 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium underline-offset-4 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
||||
className="break-words font-semibold text-[var(--color-accent)] underline-offset-4 transition-colors [overflow-wrap:anywhere] hover:underline"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
{isExternal && <ExternalLink size={12} className="ml-0.5 inline-block align-text-bottom opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
@@ -71,51 +67,50 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm">
|
||||
<table className="w-full text-sm text-left text-gray-700 bg-white">
|
||||
<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 min-w-[520px] text-left text-sm text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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 }) {
|
||||
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 }) {
|
||||
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 }) {
|
||||
return (
|
||||
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬
|
||||
// items-center 추가: 가로축 중앙 정렬
|
||||
<span className="block my-8 flex flex-col items-center justify-center">
|
||||
<span className="my-8 flex flex-col items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="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"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
onError={(event) => {
|
||||
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>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
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 (
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
@@ -126,17 +121,24 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함)
|
||||
h1({ children, ...props }: any) {
|
||||
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900" {...props}>{children}</h1>;
|
||||
// 7. 헤딩 스타일
|
||||
h1({ children, ...props }) {
|
||||
return <h1 className="mt-12 mb-6 border-b border-[var(--color-line)] pb-4 text-3xl font-extrabold tracking-normal text-[var(--color-text)]" {...props}>{children}</h1>;
|
||||
},
|
||||
h2({ children, ...props }: any) {
|
||||
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
|
||||
h2({ children, ...props }) {
|
||||
return <h2 className="mt-11 mb-5 text-2xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h2>;
|
||||
},
|
||||
h3({ children, ...props }: any) {
|
||||
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1" {...props}>{children}</h3>;
|
||||
h3({ children, ...props }) {
|
||||
return <h3 className="mt-9 mb-4 border-l-4 border-[var(--color-accent)] pl-3 text-xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h3>;
|
||||
},
|
||||
}}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeSlug]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
@@ -155,18 +157,18 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[#2d2d2d] border-b border-gray-700 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="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 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 min-w-0 items-center gap-2">
|
||||
<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-[#FFBD2E] border border-[#DEA123]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27C93F] border border-[#1AAB29]" />
|
||||
</div>
|
||||
{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} />
|
||||
<span className="uppercase tracking-wider">{language}</span>
|
||||
<span className="truncate uppercase tracking-wider">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,7 +176,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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
|
||||
? "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"
|
||||
@@ -182,21 +184,23 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
title="코드 복사"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
|
||||
<span>{isCopied ? '복사됨' : '복사'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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
|
||||
style={vscDarkPlus}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
showLineNumbers={true}
|
||||
wrapLongLines={false}
|
||||
lineNumberStyle={{ minWidth: '2.5em', paddingRight: '1em', color: '#6e7681', textAlign: 'right' }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1.5rem',
|
||||
background: 'transparent',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
|
||||
@@ -1,75 +1,67 @@
|
||||
import { Post } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
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 === '怨듭?' || categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
|
||||
function getSummary(content?: string) {
|
||||
if (!content) return '';
|
||||
|
||||
return content
|
||||
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자
|
||||
.replace(/\n/g, ' ') // 줄바꿈을 공백으로
|
||||
.substring(0, 120); // 120자까지만 자르기
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
// 요약문 생성
|
||||
const isNotice = isNoticePost(post);
|
||||
const summary = getSummary(post.content);
|
||||
|
||||
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group h-full">
|
||||
<article className={clsx(
|
||||
"flex flex-col h-full bg-white rounded-2xl p-6 transition-all duration-300 border",
|
||||
// 공지글이면 테두리에 살짝 붉은 기운을 줌
|
||||
isNotice
|
||||
? "border-red-100 shadow-[0_2px_8px_rgba(239,68,68,0.08)] hover:shadow-[0_8px_24px_rgba(239,68,68,0.12)]"
|
||||
: "border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:-translate-y-1"
|
||||
)}>
|
||||
|
||||
{/* 상단 카테고리 & 날짜 */}
|
||||
<div className="flex items-center justify-between text-xs mb-4">
|
||||
<span className={clsx(
|
||||
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||
isNotice
|
||||
? "bg-red-50 text-red-600 font-bold border border-red-100" // 🔴 공지 스타일
|
||||
: "bg-slate-100 text-slate-600" // 기본 스타일
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
<time className="text-gray-400 font-light">
|
||||
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface
|
||||
as="article"
|
||||
interactive
|
||||
className="flex h-full min-h-52 flex-col p-5 shadow-none"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h2 className={clsx(
|
||||
"text-xl font-bold mb-3 transition-colors line-clamp-2",
|
||||
isNotice ? "text-gray-900 group-hover:text-red-600" : "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
<h2 className="line-clamp-2 text-xl font-bold leading-snug tracking-normal text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{/* 요약글 */}
|
||||
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
||||
{summary && (
|
||||
<p className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{summary}
|
||||
</p>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium flex items-center gap-1",
|
||||
isNotice ? "text-red-500" : "text-blue-500"
|
||||
)}>
|
||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</Surface>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getPost, deletePost } from '@/api/posts';
|
||||
import { getProfile } from '@/api/profile';
|
||||
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 { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowLeft, Calendar, ChevronLeft, ChevronRight, Loader2, User } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
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 {
|
||||
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) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { role, _hasHydrated } = useAuthStore();
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
// 1. 게시글 상세 조회
|
||||
const { data: post, isLoading: isPostLoading, error } = useQuery({
|
||||
queryKey: ['post', slug],
|
||||
queryFn: () => getPost(slug),
|
||||
enabled: !!slug,
|
||||
// 🌟 핵심: 서버에서 가져온 데이터를 초기값으로 사용하여 즉시 렌더링 (Hydration)
|
||||
initialData: initialPost,
|
||||
});
|
||||
|
||||
@@ -37,131 +51,143 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
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) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 처리
|
||||
if (error || !post) {
|
||||
const errorStatus = (error as any)?.response?.status;
|
||||
const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.';
|
||||
const { status: errorStatus, message } = getPostErrorInfo(error);
|
||||
const errorMessage = message || '게시글을 찾을 수 없습니다.';
|
||||
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<AlertCircle className="text-gray-300" size={64} />
|
||||
<WindowSurface className="mx-auto max-w-[980px]" bodyClassName="px-4 py-20 text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
<h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
|
||||
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<p className="mb-6 text-[var(--color-text-muted)]">
|
||||
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors">메인으로</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/')}
|
||||
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 nextPost = post.nextPost;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
|
||||
deleteMutation.mutate(post.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/write?slug=${post.slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
|
||||
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||
<div className="mx-auto min-w-0 max-w-[1120px] px-0 py-3 md:py-6">
|
||||
<Link href="/" className="mb-5 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
|
||||
<ArrowLeft size={18} />
|
||||
<span className="text-sm font-medium">목록으로</span>
|
||||
<span>목록으로</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
|
||||
|
||||
<main className="min-w-0 xl:flex-1">
|
||||
<article>
|
||||
<header className="mb-10 border-b border-gray-100 pb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
|
||||
<Folder size={14} />
|
||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleEdit} className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"><Edit2 size={18} /></button>
|
||||
<button onClick={handleDelete} className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"><Trash2 size={18} /></button>
|
||||
<div 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">
|
||||
<Surface as="article" strong className="mx-auto w-full max-w-[820px] px-5 py-7 md:px-10 md:py-10">
|
||||
<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>
|
||||
)}
|
||||
<span className="min-w-0 truncate font-bold text-[var(--color-text)]">{profile?.name || 'Dev Park'}</span>
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
{profile?.imageUrl ? <img src={profile.imageUrl} alt="Author" className="w-8 h-8 rounded-full object-cover border border-gray-100 shadow-sm" /> : <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"><User size={16} /></div>}
|
||||
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar size={16} />
|
||||
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5"><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>
|
||||
</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 || ''} />
|
||||
</div>
|
||||
</article>
|
||||
</Surface>
|
||||
|
||||
<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 ? (
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex flex-col items-start gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span>
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex 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="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
|
||||
<ChevronLeft size={16} />
|
||||
이전 글
|
||||
</span>
|
||||
<span className="line-clamp-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>
|
||||
) : <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 ? (
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex 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="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
|
||||
다음 글
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
<span className="line-clamp-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>
|
||||
) : <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>
|
||||
</WindowSurface>
|
||||
|
||||
<WindowSurface title="Comments" showTrafficLights={false} className="mx-auto max-w-[820px]" bodyClassName="p-5 md:p-6">
|
||||
<CommentList postSlug={post.slug} />
|
||||
</WindowSurface>
|
||||
</main>
|
||||
|
||||
<aside className="hidden 2xl:block w-[220px] shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<aside className="hidden w-[220px] shrink-0 xl:block">
|
||||
<WindowSurface title="목차" showTrafficLights={false} bodyClassName="p-4" className="sticky top-24 shadow-[var(--shadow-card)]">
|
||||
<TOC content={post.content || ''} />
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,60 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import { format } from 'date-fns';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { ChevronRight, Eye } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import { Post } from '@/types';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
showViews?: boolean;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post }: PostListItemProps) {
|
||||
// 📢 공지 카테고리 여부 확인
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNoticePost = (post: Post) => {
|
||||
const categoryName = post.categoryName || '';
|
||||
return categoryName === '공지' || 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 (
|
||||
<Link href={`/posts/${post.slug}`} className="block group">
|
||||
<div className={clsx(
|
||||
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors",
|
||||
isNotice
|
||||
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게
|
||||
: "border-gray-100 hover:bg-gray-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* 카테고리 라벨 */}
|
||||
<span className={clsx(
|
||||
"hidden sm:inline-block px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap",
|
||||
isNotice
|
||||
? "bg-red-100 text-red-600 font-bold" // 🔴 공지 스타일 강조
|
||||
: "bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className={clsx(
|
||||
"text-base font-medium truncate transition-colors",
|
||||
isNotice
|
||||
? "text-gray-900 group-hover:text-red-600 font-semibold"
|
||||
: "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
<Link href={`/posts/${post.slug}`} className="group block">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
|
||||
isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-[var(--card-bg)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap">
|
||||
{/* 조회수 */}
|
||||
<div className="hidden sm:flex items-center gap-1.5" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">{post.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* 날짜 */}
|
||||
<time className="font-light tabular-nums text-xs sm:text-sm">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
<time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -48,17 +48,18 @@ export default function PostSearch({
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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
|
||||
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}
|
||||
onClick={handleSearch}
|
||||
/>
|
||||
{keyword && (
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
|
||||
// npm install github-slugger 실행 필요
|
||||
import GithubSlugger from 'github-slugger';
|
||||
|
||||
interface TOCProps {
|
||||
@@ -18,42 +16,37 @@ interface HeadingItem {
|
||||
|
||||
export default function TOC({ content }: TOCProps) {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const [headings, setHeadings] = useState<HeadingItem[]>([]);
|
||||
|
||||
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
|
||||
useEffect(() => {
|
||||
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
|
||||
const headings = useMemo(() => {
|
||||
const slugger = new GithubSlugger();
|
||||
const lines = content.split('\n');
|
||||
|
||||
// 코드 블럭 내의 #은 무시
|
||||
let inCodeBlock = false;
|
||||
|
||||
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
|
||||
// 코드 블럭 진입/이탈 체크 (```)
|
||||
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
return acc;
|
||||
return {
|
||||
...acc,
|
||||
inCodeBlock: !acc.inCodeBlock,
|
||||
};
|
||||
}
|
||||
if (inCodeBlock) return acc;
|
||||
if (acc.inCodeBlock) return acc;
|
||||
|
||||
// 헤딩 매칭 (# 1~3개)
|
||||
const match = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
const text = match[2].replace(/(\*\*|__)/g, '').trim(); // 볼드체 마크다운 제거
|
||||
|
||||
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
|
||||
const text = match[2].replace(/(\*\*|__)/g, '').trim();
|
||||
const slug = slugger.slug(text);
|
||||
|
||||
acc.push({ text, level, slug });
|
||||
return {
|
||||
...acc,
|
||||
headings: [...acc.headings, { text, level, slug }],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, { headings: [], inCodeBlock: false });
|
||||
|
||||
setHeadings(matches);
|
||||
return result.headings;
|
||||
}, [content]);
|
||||
|
||||
// 2. 스크롤 감지 (IntersectionObserver)
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return;
|
||||
|
||||
@@ -65,7 +58,6 @@ export default function TOC({ content }: TOCProps) {
|
||||
}
|
||||
});
|
||||
},
|
||||
// 상단 헤더 공간 등을 고려하여 감지 영역 조정
|
||||
{ rootMargin: '-10% 0px -80% 0px' }
|
||||
);
|
||||
|
||||
@@ -77,13 +69,11 @@ export default function TOC({ content }: TOCProps) {
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
// 3. 클릭 핸들러
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(slug);
|
||||
|
||||
if (element) {
|
||||
// 상단 고정 헤더 높이 여유 공간
|
||||
const headerOffset = 100;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
@@ -93,7 +83,6 @@ export default function TOC({ content }: TOCProps) {
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// URL 해시 변경 및 상태 즉시 업데이트
|
||||
history.pushState(null, '', `#${slug}`);
|
||||
setActiveId(slug);
|
||||
}
|
||||
@@ -103,18 +92,18 @@ export default function TOC({ content }: TOCProps) {
|
||||
|
||||
return (
|
||||
<aside className="w-full">
|
||||
<div className="border-l-2 border-gray-100 pl-4 py-2">
|
||||
<h4 className="font-bold text-gray-900 mb-4 text-sm uppercase tracking-wider text-opacity-80">On this page</h4>
|
||||
<div className="border-l border-[var(--color-line)] pl-4">
|
||||
<h4 className="mb-4 text-xs font-semibold uppercase text-[var(--color-text-subtle)]">목차</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{headings.map((heading, index) => (
|
||||
<li
|
||||
key={`${heading.slug}-${index}`}
|
||||
className={clsx(
|
||||
"text-sm transition-all duration-200",
|
||||
heading.level === 3 ? "pl-4 text-xs" : "", // h3는 들여쓰기
|
||||
"text-sm transition-colors duration-150",
|
||||
heading.level === 3 ? "ml-3 text-xs" : "",
|
||||
activeId === heading.slug
|
||||
? "text-blue-600 font-bold translate-x-1"
|
||||
: "text-gray-500 hover:text-gray-900"
|
||||
? "font-semibold text-[var(--color-accent)]"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
)}
|
||||
>
|
||||
<a
|
||||
|
||||
125
src/components/theme/ThemeProvider.tsx
Normal file
125
src/components/theme/ThemeProvider.tsx
Normal 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;
|
||||
};
|
||||
52
src/components/theme/ThemeToggle.tsx
Normal file
52
src/components/theme/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/theme/theme.ts
Normal file
30
src/components/theme/theme.ts
Normal 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';
|
||||
}
|
||||
})();
|
||||
`;
|
||||
33
src/components/ui/EmptyState.tsx
Normal file
33
src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--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>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/MetricCard.tsx
Normal file
54
src/components/ui/MetricCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import Surface from './Surface';
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value?: number | string | null;
|
||||
icon?: ReactNode;
|
||||
helper?: string;
|
||||
changeRate?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatValue = (value?: number | string | null) => {
|
||||
if (value === null || value === undefined) return '통계 API 연결 전';
|
||||
if (typeof value === 'number') return value.toLocaleString();
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
helper,
|
||||
changeRate,
|
||||
disabled = false,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
const hasChange = typeof changeRate === 'number';
|
||||
const isPositive = hasChange && changeRate >= 0;
|
||||
|
||||
return (
|
||||
<Surface className={clsx('p-5', disabled && 'opacity-70', className)}>
|
||||
<div className="flex items-center justify-between gap-3 text-[var(--color-text-muted)]">
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p
|
||||
className={clsx(
|
||||
'mt-4 break-keep text-3xl font-bold tracking-normal text-[var(--color-text)]',
|
||||
disabled && 'text-base leading-7 text-[var(--color-text-muted)]',
|
||||
)}
|
||||
>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
{(helper || hasChange) && (
|
||||
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{hasChange ? `${isPositive ? '+' : ''}${changeRate.toFixed(1)}%` : helper}
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/SegmentedControl.tsx
Normal file
54
src/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
ariaLabel: string;
|
||||
options: 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>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/StatusBadge.tsx
Normal file
33
src/components/ui/StatusBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: BadgeTone;
|
||||
}
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
|
||||
info: 'border-blue-500/15 bg-[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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/components/ui/Surface.tsx
Normal file
32
src/components/ui/Surface.tsx
Normal 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-[18px]',
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
src/components/ui/WindowSurface.tsx
Normal file
70
src/components/ui/WindowSurface.tsx
Normal 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(
|
||||
'w-full overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-[28px]',
|
||||
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">
|
||||
<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
33
src/lib/site.ts
Normal 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
69
src/proxy.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
const HSTS_HEADER = 'max-age=31536000; includeSubDomains';
|
||||
|
||||
const getForwardedProtocol = (request: NextRequest) => {
|
||||
const forwardedProtocol = request.headers.get('x-forwarded-proto');
|
||||
if (forwardedProtocol) {
|
||||
return forwardedProtocol.split(',')[0]?.trim().toLowerCase();
|
||||
}
|
||||
|
||||
return request.nextUrl.protocol.replace(':', '').toLowerCase();
|
||||
};
|
||||
|
||||
const getRequestHost = (request: NextRequest) => {
|
||||
const forwardedHost = request.headers.get('x-forwarded-host');
|
||||
if (forwardedHost) {
|
||||
return forwardedHost.split(',')[0]?.trim();
|
||||
}
|
||||
|
||||
return request.headers.get('host') || request.nextUrl.host;
|
||||
};
|
||||
|
||||
const getHostname = (host: string) => {
|
||||
if (host.startsWith('[')) {
|
||||
return host.slice(1, host.indexOf(']'));
|
||||
}
|
||||
|
||||
return host.split(':')[0];
|
||||
};
|
||||
|
||||
const getRedirectHost = (host: string) => {
|
||||
if (host.startsWith('[')) {
|
||||
return host.slice(0, host.indexOf(']') + 1);
|
||||
}
|
||||
|
||||
return getHostname(host);
|
||||
};
|
||||
|
||||
const isLocalRequest = (hostname: string) => {
|
||||
return LOCAL_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost');
|
||||
};
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const protocol = getForwardedProtocol(request);
|
||||
const requestHost = getRequestHost(request);
|
||||
const hostname = getHostname(requestHost);
|
||||
|
||||
if (!isLocalRequest(hostname) && protocol === 'http') {
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.protocol = 'https:';
|
||||
redirectUrl.host = getRedirectHost(requestHost);
|
||||
redirectUrl.port = '';
|
||||
|
||||
return NextResponse.redirect(redirectUrl, 301);
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
|
||||
if (!isLocalRequest(hostname)) {
|
||||
response.headers.set('Strict-Transport-Security', HSTS_HEADER);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
@@ -21,7 +21,7 @@ interface JwtPayload {
|
||||
nickname?: string;
|
||||
name?: string;
|
||||
sub?: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -50,7 +50,7 @@ const parseToken = (token: string): { role: string; user: UserInfo | null } => {
|
||||
email: decoded.sub || '',
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// console.error('Token parsing error:', e);
|
||||
return { role: 'USER', user: null };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface Post {
|
||||
categoryName: string;
|
||||
viewCount: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string | null;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
|
||||
@@ -26,12 +27,37 @@ export interface Post {
|
||||
nextPost?: PostNeighbor | null;
|
||||
}
|
||||
|
||||
// 3. 게시글 목록 페이징 응답
|
||||
export interface PostListResponse {
|
||||
content: Post[];
|
||||
export interface PageMeta {
|
||||
totalPages: 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;
|
||||
}
|
||||
|
||||
// 4. 로그인 응답
|
||||
@@ -129,3 +155,84 @@ export interface CommentSaveRequest {
|
||||
export interface CommentDeleteRequest {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import typography from "@tailwindcss/typography";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
@@ -15,8 +16,6 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'), // 👈 아까 추가하려던 게 이겁니다!
|
||||
],
|
||||
plugins: [typography],
|
||||
};
|
||||
export default config;
|
||||
@@ -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"
|
||||
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:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user