Compare commits

..

28 Commits

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

562
DESIGN.md Normal file
View File

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

100
LOG.md
View File

@@ -1,35 +1,69 @@
# LOG.md # Work Log
## 2026-05-28 ## 2026-06-06
- Created `AGENTS.md` with repository-specific coding-agent guidance for the Next.js blog frontend. - 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.
- Updated `AGENTS.md` to require recording completed work in `LOG.md` for each task. - 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: confirmed `AGENTS.md` content and checked repository status with a one-off safe-directory git option. - 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.
- Started the renewal plan by adding an `/admin` dashboard entry point with admin-only route guarding, post/category summary widgets, and quick access to writing. - 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.
- Updated `TopHeader` to show `관리자 설정` and `새 글` only after auth hydration confirms an admin role.
- Renamed the post detail loading file from `loding.tsx` to `loading.tsx`, gated React Query Devtools to development mode, and added `PostSaveRequest` for post create/update APIs. ## 2026-06-02
- Validation: ran `npm ci` successfully after rerunning outside the sandbox due npm cache permissions; targeted ESLint passed for the new admin/header/API/type files; `npm run build` passed; verified `/admin` redirects unauthenticated users to `/login` in the in-app browser on local port 3100. `npm run lint` still fails on existing baseline issues across the repo, mostly `any` usage, React Compiler `set-state-in-effect` findings, and a `require()` in `tailwind.config.ts`.
- Updated `AGENTS.md` so future task wrap-ups must include the recommended next task in `LOG.md`. - 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.
- Next task: proceed with renewal Phase 2 by removing profile/category admin editing controls from `Sidebar` and moving those flows into `/admin` tabs or dedicated admin components. - 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.
- Continued renewal Phase 2 by reducing `Sidebar` to public navigation only: profile display, search, archive, category links, and social links remain; profile edit modal and category create/delete/move controls were removed. - 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.
- Added `/admin` profile and category management panels with profile image upload, profile save, category creation, rename, parent move, and guarded delete confirmation flows using the existing API wrappers. - 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.
- Validation: targeted ESLint passed for the changed admin/sidebar files; `npm run build` passed, with the existing sitemap backend connection warning logged as `ECONNREFUSED`; `npm run lint` still fails on the known baseline issues outside this task; local dev server on port 3100 returned HTTP 200 for `/admin`. - Softened the Dock button palette to quieter pastel tones and made the light-mode Dock bar read as a cleaner white glass surface.
- Next task: proceed with renewal Phase 3 by adding an admin posts management view with searchable post rows, edit links, and a non-browser-confirm delete flow. - 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.
- Continued renewal Phase 3 by adding an `/admin` 게시글 관리 panel with keyword search, sort controls, pagination, view/edit actions, and an in-app delete confirmation dialog that invalidates post queries after deletion. - Reworked the Dock material away from cute pastel tiles into a more macOS-like neutral frosted glass texture with translucent white glass buttons.
- Added `PageMeta` and expanded `PostListResponse` so admin and dashboard counts can handle either top-level pagination fields or a nested `page` object from the backend. - 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.
- Validation: installed dependencies with `npm ci`; targeted ESLint passed for `AdminPostsPanel`, `/admin`, and shared types; `tsc --noEmit` passed; `next build --webpack` passed with the existing sitemap fetch `EPERM` warning. The default `npm run build` Turbopack run hung during optimization and was terminated; full `npm run lint` still fails on known baseline issues outside this task. Local dev server on port 3100 returned HTTP 200 for `/admin` and redirected unauthenticated browser access to `/login`. - Increased Dock button translucency contrast so the buttons remain glassy while sitting slightly more opaque than the Dock background.
- Next task: continue Phase 3 by tightening the write/edit flow under the admin experience, including replacing remaining browser `alert`/`confirm` usage in post detail/write flows with in-app dialogs or toast patterns. - 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 Next.js `src/proxy.ts` HTTPS enforcement layer that redirects non-local HTTP requests to HTTPS, respects `X-Forwarded-Proto`/`X-Forwarded-Host` to avoid reverse-proxy redirect loops, and adds HSTS on non-local HTTPS responses. - Added a client-side archive explorer with year, month, category, and compact/comfort density controls over the existing server-fetched post list.
- Validation: targeted ESLint passed for `src/proxy.ts` and the existing changed admin/type files; `tsc --noEmit` passed; `next build --webpack` passed with the existing sitemap fetch `EPERM` warning. Curl checks confirmed `Host: blog.wypark.me` over HTTP returns `301 Location: https://blog.wypark.me/archive`, `X-Forwarded-Proto: https` returns `200` with HSTS, and `127.0.0.1` local HTTP remains `200`. Full `npm run lint` still fails on the known baseline issues outside this task. - Rebuilt the chess page with safer viewport-aware board sizing, quieter puzzle panels, and enough layout clearance for the Dock.
- Next task: mirror the HTTPS redirect at the outer reverse proxy/load balancer as infrastructure defense-in-depth, then continue Phase 3 write/edit flow cleanup. - 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.
- Completed the admin function move by extracting the post editor into `AdminPostEditor`, adding `/admin/posts/new` and `/admin/posts/[slug]/edit`, turning legacy `/write` into a compatibility redirect, and updating header/admin/post links to the new admin routes. - 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.
- Removed direct post edit/delete controls from the public post detail page and moved admin comment deletion into a new `/admin` comments panel backed by typed `AdminComment` responses. - 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.
- Updated `npm run build` to use `next build --webpack` so Docker/CI uses the build mode that passes locally instead of the default Turbopack build that previously hung. - Validation: `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: targeted ESLint passed for changed admin/write/comment/post/header/API/type/proxy files with one existing `<img>` warning in `PostDetailClient`; `tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; local dev server on port 3100 returned HTTP 200 for `/admin/posts/new`, `/admin/posts/sample/edit`, and `/write`. Full `npm run lint` still fails on known baseline issues outside this task. - 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.
- Next task: continue with non-design quality work by cleaning the remaining lint baseline (`src/api/http.ts`, category/archive/home/login/signup/comment form/list, Markdown renderer, TOC, auth store, and Tailwind config). - 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.
- Split admin management into dedicated routes under a shared guarded admin shell: `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/admin/profile`; `/admin` is now a dashboard only. - 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.
- Added admin dashboard recent comments and total comments widgets, plus defensive `getAdminComments` normalization for possible `author`, `postSlug`, and `postTitle` response aliases. - 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.
- Enhanced admin post management with category filtering, direct page-number navigation, current-page row selection, and bulk delete via the existing single-delete API. - 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.
- Added login return handling with `/login?redirect=...`, kept `/write` as the chosen legacy compatibility redirect, and documented these decisions in `docs/renewal-plan.md`.
- Validation: unauthenticated live-server request to `https://blogserver.wypark.me/api/admin/comments?page=0&size=1` returned HTTP 403 with an empty body, so exact admin comment fields still require an authenticated admin token/session; targeted ESLint passed for the changed admin/login/comment API files; `npx tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; full `npm run lint` still fails on known baseline issues outside this task; local dev route checks returned HTTP 200 for `/admin`, `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/login?redirect=%2Fadmin%2Fcomments`; in-app browser verification confirmed `/admin/comments` redirects to `/login?redirect=%2Fadmin%2Fcomments` when logged out. ## 2026-05-29
- Next task: authenticate against the real admin API to capture the exact `getAdminComments` payload, then clean the existing lint baseline so full `npm run lint` can become a reliable gate.
- 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.

View File

@@ -1,621 +0,0 @@
# WYPark Blog Frontend Renewal Plan
작성일: 2026-05-28
## 1. 목표
이 문서는 현재 `blog-frontend` 코드를 기준으로 개인 블로그 프론트엔드를 대폭 리뉴얼하기 위한 계획이다. 백엔드 API와 데이터 구조는 유지하고, 프론트엔드 라우팅, 컴포넌트 구조, 관리자 UX, 마크다운 렌더링, 시각 디자인을 중심으로 개선한다.
이번 리뉴얼은 기존 기능을 보존하는 마이너 개선이 아니라, 필요하다면 프론트엔드 구조를 넓게 재배치하는 것을 전제로 한다. 개인 블로그이므로 기존 UI 코드는 과감히 갈아엎어도 된다. 단, 인증 토큰 처리, API 경로, 공개 URL, SEO 도메인, 배포 구조처럼 외부 계약에 가까운 부분은 검증 없이 임의 변경하지 않는다.
## 2. 현재 코드 요약
### 기술 스택
- Next.js 16 App Router, React 19, TypeScript
- Tailwind CSS 4, `@tailwindcss/typography`
- TanStack React Query, Zustand, Axios
- `react-markdown`, `rehype-sanitize`, `react-syntax-highlighter`
- 글 작성 에디터: `@uiw/react-md-editor`
### 주요 구조
- 전역 레이아웃: `src/app/layout.tsx`
- 홈: `src/app/page.tsx`
- 글 상세: `src/app/posts/[slug]/page.tsx`, `src/components/post/PostDetailClient.tsx`
- 글 작성/수정: `src/app/write/page.tsx`
- 카테고리 페이지: `src/app/category/[id]/page.tsx`
- 아카이브: `src/app/archive/page.tsx`
- 사이드바/상단 헤더: `src/components/layout/Sidebar.tsx`, `src/components/layout/TopHeader.tsx`
- API 래퍼: `src/api/*`
- 인증 상태: `src/store/authStore.ts`
### API 재사용 가능성
백엔드 수정 없이 다음 관리자 기능을 만들 수 있다.
- 게시글 작성/수정/삭제: `createPost`, `updatePost`, `deletePost`
- 게시글 목록 조회/검색/정렬: `getPosts`
- 카테고리 조회/생성/수정/삭제: `getCategories`, `createCategory`, `updateCategory`, `deleteCategory`
- 프로필 조회/수정: `getProfile`, `updateProfile`
- 이미지 업로드: `uploadImage`
- 댓글 조회/삭제: `getComments`, `getAdminComments`, `deleteAdminComment`
## 3. 현재 문제와 개선 방향
### 홈 대시보드가 비어 보임
현재 홈은 공지 3개, 최신 글 3개, 인기 글 3개 정도의 섹션으로 구성되어 있다. `src/app/page.tsx`에서 이미 `getPosts`를 여러 번 호출해 최신순, 조회수순, 공지 목록을 가져오고 있으므로 데이터 기반은 있다. 다만 한 화면에서 읽을 수 있는 정보 밀도가 낮고, 블로그의 현재 상태를 보여주는 대시보드 느낌이 약하다.
개선 방향:
- 최신 글을 6~8개까지 보여주는 리스트 중심 섹션 추가
- 인기 글, 공지, 최근 댓글 또는 카테고리 현황을 보조 패널로 배치
- 전체 글 수, 카테고리 수, 최근 업데이트일 같은 작은 통계 영역 추가
- 검색 결과 화면과 기본 대시보드 화면을 시각적으로 분리
- 공지 글은 별도 스트립 또는 상단 compact list로 처리
### 관리자 기능이 일반 페이지에 섞여 있음
현재 사이드바에서 관리자 권한이면 프로필 수정, 카테고리 생성/삭제/이동 기능이 바로 노출된다. 글 작성 버튼은 상단 헤더에 있고, 글 상세에서는 수정/삭제 버튼이 직접 보인다. 기능은 동작하지만 일반 방문자용 읽기 화면과 관리자 도구가 섞여 있어 UI가 복잡해지고, “블로그를 보는 경험”과 “블로그를 관리하는 경험”이 분리되지 않는다.
개선 방향:
- `/admin` 관리자 설정 페이지 신설
- 관리자 계정 로그인 후에만 `TopHeader` 또는 사이드바 하단에 “관리자 설정” 버튼 노출
- 일반 회원과 비로그인 사용자는 관리자 버튼을 볼 수 없게 처리
- 기존 사이드바의 카테고리 편집 버튼, 프로필 수정 모달 제거
- 카테고리/프로필/댓글/게시글 관리는 `/admin` 내부에서만 수행
- `/write`는 외부 링크와 기존 북마크 호환을 위해 legacy redirect로 유지하고, 실제 작성/수정 화면은 `/admin/posts/new`, `/admin/posts/[slug]/edit`로 편입
### 마크다운 렌더링 기능이 제한적임
현재 `MarkdownRenderer``remark-gfm`, `rehype-sanitize`, `rehype-slug`를 사용하고, 코드 블록/표/이미지/헤딩을 직접 커스텀한다. 그러나 `package.json`에는 이미 `remark-math`, `rehype-katex`, `remark-breaks`, `rehype-autolink-headings`, `katex`가 설치되어 있는데 실제 렌더러에서는 사용하지 않는다.
개선 방향:
- GFM, 줄바꿈, 수식, 코드, 표, 체크리스트, 자동 heading anchor를 일관되게 지원
- `rehype-sanitize`는 유지하되, 코드 언어 class, heading id, 링크 속성, KaTeX class를 허용하는 명시적 sanitize schema 구성
- 본문 타이포그래피를 전역 CSS와 Tailwind Typography 기반으로 정리
- 이미지 캡션, 이미지 확대 보기, 외부 링크 아이콘, anchor link 복사를 개선
- TOC는 렌더러의 heading slug 생성 방식과 완전히 같은 유틸리티를 공유
### 디자인이 컴포넌트마다 다소 분산되어 있음
대부분 Tailwind 클래스로 잘 구성되어 있지만, 파란색 중심의 색상, `rounded-2xl`, `shadow-xl`, 카드형 UI가 여러 곳에 반복된다. 블로그 읽기 화면은 미니멀해야 하는데, 카드와 그림자 중심의 요소가 많아 화면별 톤이 약간 다르게 느껴진다.
개선 방향:
- 색상, radius, border, shadow, spacing의 기준을 정하고 반복 컴포넌트화
- Apple 제품 UI에서 느껴지는 정제된 여백, 얇은 경계선, 부드러운 깊이감, 고급스러운 모션을 디자인 기준으로 채택
- 카드 남발을 줄이고, 글 목록/본문/관리자 화면별 밀도를 다르게 설계
- 본문 읽기 영역은 여백과 행간 중심으로 차분하게 구성
- 관리자 화면은 작업 효율 중심의 조밀한 테이블/패널 UI로 구성
- 모바일에서는 사이드바보다 상단/하단 내비게이션 접근성을 우선
### 타입 안정성과 운영 품질 개선 여지
`any`가 API, 페이지, 컴포넌트에 여러 곳 존재한다. 또한 `src/app/posts/[slug]/loding.tsx`는 Next.js가 인식하는 `loading.tsx`가 아니므로 로딩 UI 파일명이 잘못되어 있다. `ReactQueryDevtools`도 현재 `Providers`에서 항상 렌더링된다.
개선 방향:
- `PostSaveRequest`, `PagedResponse<T>`, `AdminComment` 등 누락 타입 추가
- 페이지 메타 구조가 `data.page` 또는 기존 `data` 양쪽에 대응하는 부분을 타입으로 흡수
- `loding.tsx``loading.tsx`로 수정
- React Query Devtools는 개발 환경에서만 렌더링
- 브라우저 기본 `alert`, `confirm`, `prompt`를 주요 관리자 플로우에서 커스텀 모달/토스트로 교체
## 4. 제안하는 정보 구조
### 공개 페이지
```text
/
/archive
/category/[id]
/posts/[slug]
/login
/signup
```
공개 페이지는 읽기 경험을 중심으로 유지한다. 관리자 전용 조작 버튼은 최소화하거나 관리자 페이지로 연결하는 정도로만 둔다.
### 관리자 페이지
```text
/admin
/admin/posts
/admin/posts/new
/admin/posts/[slug]/edit
/admin/categories
/admin/profile
/admin/comments
/admin/settings
```
현재 구현에서는 `/admin` 대시보드와 세부 관리 라우트를 분리한다. 기존 `/write`는 호환 redirect로만 유지한다.
권장 MVP:
- `/admin` 대시보드
- `/admin/posts` 게시글 관리 및 작성 버튼
- `/admin/categories` 카테고리 관리
- `/admin/profile` 프로필 관리
댓글 관리는 이후 단계로 분리해도 된다.
## 5. 홈 대시보드 리뉴얼 상세
### 현재 데이터 활용
기존 `getPosts`만으로 다음 데이터를 구성할 수 있다.
- 최신 글: `getPosts({ size: 8, sort: 'createdAt,desc' })`
- 인기 글: `getPosts({ size: 5, sort: 'viewCount,desc' })`
- 공지: `getPosts({ category: '공지', size: 3 })`
- 전체 글 수: 응답의 `totalElements`
- 카테고리: `getCategories()`
### 레이아웃 제안
```text
├─ 상단: 블로그 이름, 짧은 소개, 검색
├─ 공지 스트립: 최신 공지 1~3개
├─ 메인 컬럼: 최신 글 목록 6~8개
├─ 보조 컬럼: 인기 글, 카테고리 요약, 최근 업데이트
└─ 하단: 아카이브 진입, 태그/카테고리 탐색
```
디자인 방향:
- 최신 글은 카드보다 리스트형을 기본으로 하여 정보 밀도 확보
- 대표 글 1개만 살짝 강조하고 나머지는 compact list
- 인기 글은 조회수와 카테고리를 함께 표시
- 공지는 빨간색 강조보다 얇은 라인과 작은 배지로 처리
- 검색 결과 화면에서는 “검색어, 결과 수, 정렬, 목록”만 집중해서 표시
## 6. 관리자 페이지 설계
### 접근 제어
프론트에서는 `useAuthStore``_hasHydrated``role?.includes('ADMIN')`를 기준으로 관리자 버튼과 페이지 접근을 제어한다. 백엔드 admin API가 최종 권한 검사를 담당한다는 전제는 유지한다.
권장 동작:
- 비로그인: `/login?redirect=/admin...`로 이동하고 로그인 성공 후 원래 가려던 관리자 경로로 복귀
- 일반 회원: “관리자 권한이 필요합니다” 토스트 후 홈으로 이동
- 관리자: 관리자 메뉴 노출 및 `/admin` 접근 허용
### 상단 헤더 변경
현재 `TopHeader`는 관리자에게 글쓰기 버튼만 보여준다. 리뉴얼 후에는 다음처럼 바꾼다.
- 비로그인: 로그인, 회원가입
- 일반 회원: 로그아웃
- 관리자: 관리자 설정, 새 글, 로그아웃
“관리자 설정”은 명확히 `/admin`으로 이동한다. 일반 회원에게는 렌더링하지 않는다.
### 사이드바 변경
사이드바는 공개 탐색용으로만 둔다.
제거할 기능:
- 프로필 수정 모달
- 카테고리 편집 모드
- 카테고리 추가/삭제/이동 버튼
- 관리자 전용 hover 편집 버튼
유지할 기능:
- 프로필 표시
- 검색
- 아카이브 링크
- 카테고리 트리 탐색
- Github/Email 링크
### 관리자 대시보드
`/admin` 첫 화면은 운영 현황을 빠르게 보여준다.
추천 위젯:
- 총 게시글 수
- 카테고리 수
- 최근 작성 글 5개
- 인기 글 5개
- 최근 댓글 5개
- 빠른 작업: 새 글 작성, 카테고리 관리, 프로필 수정
### 게시글 관리
기존 `/write` 기능을 재사용하되 관리자 흐름으로 재배치한다.
필요 UI:
- 검색/필터/정렬 가능한 게시글 테이블
- 카테고리 필터
- 제목, 카테고리, 날짜, 조회수, 작업 버튼
- 페이지 번호 직접 이동
- 현재 페이지 선택 및 대량 삭제
- 새 글 작성 버튼
- 수정 버튼
- 삭제 전 확인 모달
백엔드 유지 조건에서는 공개 `getPosts`를 관리 목록에도 사용한다. 비공개 글 같은 개념이 없다면 별도 admin list API 없이 충분하다.
대량 삭제는 별도 bulk API가 없으므로 기존 단건 삭제 API를 선택 항목별로 호출한다.
### 카테고리 관리
사이드바에서 하던 기능을 `/admin/categories`로 이동한다.
필요 UI:
- 트리 형태 카테고리 목록
- 카테고리 생성
- 이름 변경
- 부모 변경 또는 드래그 이동
- 삭제 확인
- 변경 후 `['categories']`, `['posts']` 관련 쿼리 무효화
현재 API는 `updateCategory(id, { name, parentId })` 형식이므로 이름 변경과 이동 모두 가능하다.
### 프로필 관리
현재 사이드바 모달 기능을 `/admin/profile`로 이동한다.
필요 UI:
- 이름, 소개, 이미지, Github URL, Email 편집
- 이미지 업로드
- 미리보기
- 저장 후 프로필 쿼리 무효화
### 댓글 관리
이미 `getAdminComments``deleteAdminComment`가 존재하므로 관리자 화면에 편입할 수 있다.
필요 UI:
- 최근 댓글 목록
- 작성자, 게시글, 내용, 작성일
- 댓글 위치로 이동
- 관리자 삭제
공개 댓글 목록의 일반 사용자/비회원 댓글 삭제 UX는 공개 기능으로 유지한다. 관리자 전용 강제 삭제만 `/admin/comments`로 분리한다.
단, `getAdminComments` 실제 필드 검증에는 관리자 인증이 필요하다. 2026-05-28 비인증 실서버 요청은 HTTP 403과 빈 본문을 반환했으므로, 프론트에서는 `postSlug`, `postTitle`, `author` 외에 중첩 `post`, `postResponse`, `member`, `guest` 등 가능한 alias를 정규화해 방어적으로 대응한다.
## 7. 마크다운 렌더러 개선 상세
### 플러그인 구성
권장 파이프라인:
```ts
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkMath,
]}
rehypePlugins={[
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'append' }],
rehypeKatex,
[rehypeSanitize, customSchema],
]}
```
보안상 `rehype-sanitize`는 반드시 유지한다. 다만 기본 schema가 너무 좁거나 플러그인 class를 제거할 수 있으므로 `customSchema`를 명시한다.
### 지원할 문법
- 제목 anchor 및 TOC 연동
- GFM 표
- 체크리스트
- 취소선
- inline code/code block
- 수식: inline/block KaTeX
- 일반 줄바꿈
- 이미지 캡션
- 외부 링크 새 탭
- 코드 복사
### 본문 스타일
본문은 `MarkdownRenderer` 내부에 모든 스타일을 몰아넣기보다, `globals.css` 또는 전용 `markdown.css`에서 `.markdown-content`를 정리하는 편이 낫다.
권장 스타일:
- 본문 최대 너비: 720~820px
- 한글 행간: `leading-8` 수준
- 문단 간격: 과하지 않게 일정하게
- heading은 큰 장식보다 명확한 계층 중심
- blockquote는 callout처럼 보이되 색상 과다 사용 금지
- code block은 가로 스크롤과 긴 줄 처리를 안정화
- table은 모바일 overflow 대응
### TOC 개선
현재 TOC는 마크다운 원문을 정규식으로 다시 파싱한다. 렌더러와 TOC가 서로 다른 방식으로 heading을 만들 수 있으므로 slug 생성 유틸을 공유한다.
개선안:
- `src/utils/markdown.ts` 추가
- `extractHeadings(content)` 제공
- `MarkdownRenderer``TOC`가 같은 slug 생성 규칙 사용
- h1~h3 또는 h2~h3만 표시할지 정책 결정
## 8. 디자인 시스템 방향
### 디자인 레퍼런스
전체 디자인 방향은 Apple의 제품 UI와 웹사이트에서 느껴지는 미니멀하고 정제된 스타일을 참고한다. 단, Apple의 로고, 아이콘, 제품 이미지, 고유한 브랜드 표현을 직접 복제하지 않고, 다음 원칙을 블로그 맥락에 맞게 재해석한다.
- 넓고 명확한 여백
- 얇은 선과 낮은 대비의 경계
- 과장되지 않은 입체감
- 선명한 타이포그래피 위계
- 부드러운 전환과 즉각적인 피드백
- 불필요한 장식보다 콘텐츠 자체를 돋보이게 하는 구성
- 모바일과 데스크톱 모두에서 손에 익은 네이티브 앱 같은 조작감
### 전체 톤
목표는 “Apple-inspired 미니멀 블로그”다. 화면은 깨끗하고 조용하지만 비어 보이지 않아야 하며, 정보는 정돈된 레이어와 타이포그래피로 충분히 채운다.
키워드:
- Apple-inspired
- 조용한 대비
- 선명하고 큰 타이포그래피
- 적당한 정보 밀도
- 적은 장식
- 빠른 탐색
- 부드러운 물성
- 정제된 인터랙션
### 색상
현재 파란색이 대부분의 강조 색으로 쓰인다. Apple 스타일을 참고해 흰색, off-white, neutral gray를 넓게 사용하고, 파란색은 주요 액션과 링크에만 선명하게 사용한다. 전체 화면이 파란색 테마처럼 보이지 않도록 중립색을 기본값으로 둔다.
권장:
- 배경: white, near-white, neutral-50 계열
- 본문 텍스트: neutral/slate 계열의 높은 가독성 색상
- 보조 텍스트: neutral-400~500 계열
- 링크/주요 액션: Apple blue에 가까운 명확한 blue 계열
- 위험 액션: red 계열을 작고 단호하게 사용
- 성공/저장 완료: green 계열을 토스트/상태 표시에 제한적으로 사용
- 공지: red 대신 amber 또는 muted red를 작은 배지/라인으로 제한 사용
### Radius와 Shadow
읽기 화면은 큰 radius와 강한 shadow를 줄인다. Apple UI처럼 표면이 떠 있는 느낌은 주되, 그림자가 먼저 보이지 않게 한다. 깊이감은 `border`, 배경 레이어, 아주 약한 shadow, blur가 있는 overlay로 만든다.
권장:
- 일반 버튼/입력: `rounded-lg` 또는 pill 형태
- 목록 아이템: `rounded-md` 또는 border-only
- 모달/드롭다운: `rounded-xl`
- 반복 카드: shadow보다 border와 hover background 중심
- floating panel: 얇은 border + 약한 shadow + 흰색/반투명 배경
- destructive dialog: 시각적으로 단호하되 과한 빨간 배경은 피함
### Typography
Apple 스타일의 핵심은 장식보다 타이포그래피의 밀도와 리듬이다. 블로그에서는 글 제목, 섹션 제목, 본문, 보조 정보의 위계를 선명히 나누고, 한글 가독성을 우선한다.
권장:
- 큰 제목은 과감하게 쓰되 한 화면에 너무 많은 hero 텍스트를 두지 않음
- 본문은 16~18px, 넉넉한 행간, 적절한 문단 간격 유지
- 날짜/조회수/카테고리 같은 메타 정보는 작고 차분하게 처리
- 버튼 라벨은 짧게 유지하고 아이콘과 함께 사용
- letter spacing은 기본값에 가깝게 두고, 한글에 과한 자간을 적용하지 않음
### Motion
인터랙션은 빠르고 조용해야 한다. Apple UI처럼 화면 전환, hover, active, modal open/close에 미세한 움직임을 주되, 블로그 읽기를 방해하는 큰 애니메이션은 피한다.
권장:
- hover: 배경색 변화, 살짝 올라감, 아주 약한 shadow
- active: 짧은 scale down 또는 색상 변화
- modal/sheet: fade + subtle scale/slide
- sidebar/mobile menu: 부드러운 slide
- skeleton/loading: 과하게 번쩍이지 않는 pulse
- motion duration: 150~250ms 중심
### 레이아웃
데스크톱:
- 좌측 사이드바 유지
- 본문은 route별 최대 너비 분리
- 홈은 2-column 대시보드
- 글 상세는 본문 + 우측 TOC
- 관리자 화면은 macOS 설정 앱처럼 좌측 섹션 내비게이션 + 우측 상세 패널 구조 우선 검토
모바일:
- 사이드바 토글을 유지하되 탐색 경험 단순화
- 관리자 페이지는 탭/섹션을 세로 스택으로
- 글 목록은 리스트형 우선
- 버튼 라벨이 줄바꿈되어도 깨지지 않게 고정 폭/반응형 처리
- 주요 액션은 터치 타겟을 충분히 확보하고, 하단 sheet/compact menu 패턴을 적극 사용
## 9. 코드 구조 리팩터링 제안
### 새 디렉터리
```text
src/components/admin/
src/components/common/
src/components/markdown/
src/components/post/list/
src/lib/
src/utils/
```
### 주요 분리 대상
- `Sidebar.tsx`: 공개 사이드바와 관리자 기능 분리
- `TopHeader.tsx`: auth action과 admin entry 분리
- `write/page.tsx`: 에디터 로직, 임시저장, 이미지 업로드, 카테고리 선택을 컴포넌트로 분리
- `MarkdownRenderer.tsx`: 렌더러, 코드 블록, 이미지, 링크, sanitize schema 분리
- `PostCard`/`PostListItem`: 목록 UI 변형을 통합 또는 명확히 분리
### 타입 보강
추가 권장 타입:
```ts
interface PageMeta {
totalPages: number;
totalElements: number;
number?: number;
last?: boolean;
}
interface PostSaveRequest {
title: string;
content: string;
categoryId: number;
tags: string[];
}
interface AdminComment {
id: number;
content: string;
author: string;
postSlug?: string;
postTitle?: string;
createdAt: string;
}
```
## 10. 단계별 실행 계획
### Phase 0. 기준선 정리
- `npm ci`로 의존성 설치
- `npm run lint`, `npm run build` 현재 상태 확인
- `src/app/posts/[slug]/loding.tsx` 파일명 오류 확인 후 수정 계획 반영
- `ReactQueryDevtools` 개발 환경 조건부 렌더링 계획 반영
- 현재 화면 스크린샷 기록
### Phase 1. 관리자 진입점과 라우트 신설
- `/admin` 라우트 추가
- 관리자 가드 컴포넌트 추가
- `TopHeader`에 관리자 설정 버튼 추가
- 일반 회원/비로그인에서는 관리자 버튼 비노출
- `robots.ts``/admin` 차단 유지
### Phase 2. 공개 사이드바 정리
- `Sidebar`에서 관리자 편집 상태 제거
- 카테고리 편집/프로필 수정 로직을 관리자 컴포넌트로 이동
- 사이드바는 탐색, 검색, 프로필 표시만 담당
- 모바일 사이드바 열림 기본값과 overlay UX 점검
### Phase 3. 관리자 기능 구현
- 관리자 대시보드 카드/리스트 구현
- 게시글 관리 목록 구현
- 기존 글쓰기 화면을 관리자 작성/수정 흐름으로 연결
- 카테고리 관리 화면 구현
- 프로필 관리 화면 구현
- 댓글 관리 화면은 응답 타입 확인 후 구현
### Phase 4. 홈 대시보드 리뉴얼
- 최신 글 중심 레이아웃으로 개편
- 공지/인기글/카테고리 요약 패널 추가
- 검색 결과 모드 재정리
- 빈 상태, 로딩 상태, 오류 상태 디자인 통일
### Phase 5. 마크다운 렌더러 개선
- markdown 관련 컴포넌트와 유틸 분리
- `remark-breaks`, `remark-math`, `rehype-katex`, `rehype-autolink-headings` 적용
- sanitize schema 확장
- 본문 CSS 정리
- TOC slug 공유
- 코드/표/이미지/수식/체크리스트 테스트 글로 검증
### Phase 6. 디자인 폴리싱
- 버튼, 입력, 모달, 리스트, 배지 공통 스타일 정리
- 카드 그림자와 radius 정리
- 모바일 레이아웃 검증
- 한국어 문구 톤 통일
- 접근성: label, aria-label, focus ring, keyboard navigation 점검
### Phase 7. 검증과 배포 전 점검
- `npm run lint`
- `npm run build`
- 로컬 dev 서버에서 핵심 플로우 확인
- 관리자 계정/일반 계정/비로그인 상태별 UI 확인
- 백엔드 미연결 상태에서 graceful loading/error 확인
- 배포 환경 `NEXT_PUBLIC_API_URL` 확인
## 11. 검증 체크리스트
### 인증/권한
- 비로그인 사용자는 관리자 버튼을 볼 수 없다.
- 일반 회원은 관리자 버튼을 볼 수 없다.
- 관리자만 `/admin`에 접근할 수 있다.
- 관리자 API 실패 시 토큰이나 민감 정보가 노출되지 않는다.
### 공개 화면
- 홈이 비어 보이지 않고 최신 글 목록이 충분히 보인다.
- 검색 결과가 명확하게 분리되어 보인다.
- 카테고리 페이지의 grid/list 전환이 유지된다.
- 글 상세의 본문 너비와 TOC 위치가 안정적이다.
- 모바일에서 사이드바와 상단 버튼이 겹치지 않는다.
### 관리자 화면
- 게시글 작성/수정/삭제 후 관련 쿼리가 갱신된다.
- 카테고리 생성/수정/삭제/이동 후 사이드바가 갱신된다.
- 프로필 저장 후 공개 사이드바가 갱신된다.
- 삭제 액션은 확인 모달을 거친다.
- 로딩/비활성/오류/성공 상태가 모두 보인다.
### 마크다운
- heading anchor와 TOC가 같은 위치로 이동한다.
- 코드 블록 언어 표시와 복사가 동작한다.
- 표가 모바일에서 깨지지 않는다.
- 이미지가 본문 폭을 넘지 않는다.
- 수식이 렌더링된다.
- 악성 HTML/script는 sanitize된다.
## 12. 우선순위
가장 먼저 할 일:
1. `/admin` 라우트와 관리자 버튼 추가
2. 사이드바 관리자 기능 제거 및 관리자 페이지로 이동
3. 홈 대시보드 정보 밀도 개선
4. 마크다운 렌더러 개선
5. 디자인 토큰/공통 컴포넌트 정리
리뉴얼 체감이 가장 큰 조합은 “관리자 기능 분리 + 홈 대시보드 개편 + 본문 타이포그래피 개선”이다. 이 세 가지를 먼저 끝내면 블로그가 보는 화면과 관리하는 화면 모두에서 확실히 달라진다.
## 13. 확인된 현재 검증 상태
현재 로컬에 `node_modules`가 없어 `npm run lint`는 실행되지 않았다. 오류는 다음과 같다.
```text
'eslint' is not recognized as an internal or external command
```
리뉴얼 작업을 시작하기 전 `npm ci`로 의존성을 설치한 뒤 lint/build 기준선을 먼저 잡는 것이 좋다.

7
package-lock.json generated
View File

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

View File

@@ -16,6 +16,7 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"chess.js": "^1.4.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",

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

13
src/api/dashboard.ts Normal file
View File

@@ -0,0 +1,13 @@
import { http } from './http';
import { AdminDashboardResponse, ApiResponse, DashboardRange } from '@/types';
export const getAdminDashboard = async (
range: DashboardRange = '30d',
timezone = 'Asia/Seoul',
) => {
const response = await http.get<ApiResponse<AdminDashboardResponse>>('/api/admin/dashboard', {
params: { range, timezone },
});
return response.data.data;
};

View File

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

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

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

View File

@@ -1,22 +1,37 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
ArrowRight, ArrowRight,
Eye, CalendarClock,
FileText, FileText,
FolderTree,
Loader2, Loader2,
MessageSquareText,
PenLine, PenLine,
Settings, Settings,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { getAdminDashboard } from '@/api/dashboard';
import { getCategories } from '@/api/category'; import { getCategories } from '@/api/category';
import { getAdminComments } from '@/api/comments'; import { getAdminComments } from '@/api/comments';
import { getPosts } from '@/api/posts'; import { getPosts } from '@/api/posts';
import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types'; import AdminDashboardActionCenter from '@/components/admin/dashboard/AdminDashboardActionCenter';
import AdminDashboardCategoryHealth from '@/components/admin/dashboard/AdminDashboardCategoryHealth';
import AdminDashboardOverview from '@/components/admin/dashboard/AdminDashboardOverview';
import AdminDashboardPostPerformance from '@/components/admin/dashboard/AdminDashboardPostPerformance';
import AdminDashboardTrafficChart from '@/components/admin/dashboard/AdminDashboardTrafficChart';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import {
AdminComment,
AdminCommentListResponse,
Category,
DashboardPostStat,
DashboardRange,
PageMeta,
Post,
} from '@/types';
const countCategories = (categories: Category[] = []): number => { const countCategories = (categories: Category[] = []): number => {
return categories.reduce((count, category) => { return categories.reduce((count, category) => {
@@ -31,10 +46,10 @@ const emptyPageMeta: PageMeta = {
last: true, last: true,
}; };
const formatDate = (value?: string, withTime = false) => { const formatDate = (value?: string | Date, withTime = false) => {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '-'; if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('ko-KR', { return new Intl.DateTimeFormat('ko-KR', {
@@ -56,40 +71,51 @@ const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
}; };
}; };
const getPostTotal = (data?: { page?: PageMeta; totalElements?: number }) => {
return data?.page?.totalElements ?? data?.totalElements ?? 0;
};
const getAuthorName = (comment: AdminComment) => { const getAuthorName = (comment: AdminComment) => {
return comment.author || comment.memberNickname || comment.guestNickname || '익명'; return comment.author || comment.memberNickname || comment.guestNickname || '익명';
}; };
function PostRow({ post }: { post: Post }) { const toPostStat = (post: Post): DashboardPostStat => ({
id: post.id,
title: post.title,
slug: post.slug,
categoryName: post.categoryName,
viewCount: post.viewCount,
rangeViewCount: post.viewCount,
createdAt: post.createdAt,
});
function RecentPostRow({ post }: { post: Post }) {
return ( return (
<Link <Link
href={`/posts/${post.slug}`} href={`/posts/${post.slug}`}
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50" className="group flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{post.title}</p> <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-gray-500"> <p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{post.categoryName || '미분류'} · {formatDate(post.createdAt)} {post.categoryName || '미분류'} · {formatDate(post.createdAt)}
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-1 text-xs text-gray-400"> <ArrowRight className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" size={15} />
<Eye size={14} />
<span>{post.viewCount.toLocaleString()}</span>
</div>
</Link> </Link>
); );
} }
function CommentRow({ comment }: { comment: AdminComment }) { function RecentCommentRow({ comment }: { comment: AdminComment }) {
const content = ( const content = (
<> <>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p> <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-gray-400"> <p className="mt-1 truncate text-xs text-[var(--color-text-subtle)]">
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)} {getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
</p> </p>
</div> </div>
<ArrowRight className="shrink-0 text-gray-300" size={15} /> <ArrowRight className="shrink-0 text-[var(--color-text-subtle)]" size={15} />
</> </>
); );
@@ -97,7 +123,7 @@ function CommentRow({ comment }: { comment: AdminComment }) {
return ( return (
<Link <Link
href={`/posts/${comment.postSlug}`} href={`/posts/${comment.postSlug}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50" className="flex items-center gap-3 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
> >
{content} {content}
</Link> </Link>
@@ -107,7 +133,86 @@ function CommentRow({ comment }: { comment: AdminComment }) {
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>; 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() { 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({ const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'admin', 'latest'], queryKey: ['posts', 'admin', 'latest'],
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }), queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
@@ -132,42 +237,52 @@ export default function AdminPage() {
retry: 0, retry: 0,
}); });
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
const latestPosts = latestData?.content ?? []; const latestPosts = latestData?.content ?? [];
const popularPosts = popularData?.content ?? []; const recentComments = dashboard?.recentComments ?? commentsData?.content ?? [];
const recentComments = commentsData?.content ?? []; const recentPosts = dashboard?.recentPosts ?? latestPosts;
const totalPosts = latestData?.page?.totalElements ?? latestData?.totalElements ?? 0; const totalPosts = dashboard?.overview.totalPosts ?? getPostTotal(latestData);
const totalCategories = countCategories(categories); const totalCategories = dashboard?.overview.totalCategories ?? countCategories(categories);
const totalComments = getCommentListMeta(commentsData).totalElements; const totalComments = dashboard?.overview.totalComments ?? getCommentListMeta(commentsData).totalElements;
const lastUpdatedAt = latestPosts[0]?.createdAt; const isFallback = !dashboard;
const isFallbackLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
const topPosts = useMemo(
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
[dashboard?.topPosts, popularData?.content],
);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<header className="flex flex-col justify-between gap-5 border-b border-gray-200 pb-8 md:flex-row md:items-end"> <header className="flex flex-col justify-between gap-5 border-b border-[var(--color-line)] pb-8 md:flex-row md:items-end">
<div> <div>
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700"> <StatusBadge tone="info" className="mb-3">
<Settings size={14} /> <Settings size={14} />
Admin
</p> </StatusBadge>
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl"> <h1 className="text-3xl font-bold tracking-normal text-[var(--color-text)] md:text-4xl">
</h1> </h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500"> <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--color-text-muted)]">
. , , , . . API가 .
</p>
<p className="mt-3 inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
<CalendarClock size={14} />
{formatDate(new Date())}
{isDashboardLoading && ' · 통계 API 확인 중'}
{isFallback && !isDashboardLoading && ' · fallback 데이터 사용 중'}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link <Link
href="/admin/posts" href="/admin/posts"
className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50" className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-[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} /> <FileText size={17} />
</Link> </Link>
<Link <Link
href="/admin/posts/new" href="/admin/posts/new"
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800" className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-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} /> <PenLine size={17} />
@@ -175,126 +290,46 @@ export default function AdminPage() {
</div> </div>
</header> </header>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <AdminDashboardOverview
<div className="rounded-lg border border-gray-200 bg-white p-5"> overview={dashboard?.overview}
<div className="flex items-center justify-between text-gray-500"> fallbackTotals={{
<span className="text-sm font-medium"> </span> totalPosts,
<FileText size={18} /> totalComments,
</div> totalCategories,
<p className="mt-4 text-3xl font-bold text-gray-950">{totalPosts.toLocaleString()}</p> lastPublishedAt: latestPosts[0]?.createdAt,
</div> }}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5"> <AdminDashboardTrafficChart
<div className="flex items-center justify-between text-gray-500"> points={dashboard?.traffic ?? []}
<span className="text-sm font-medium"></span> range={range}
<FolderTree size={18} /> onRangeChange={setRange}
</div> isFallback={isFallback}
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p> />
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5"> <div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
<div className="flex items-center justify-between text-gray-500"> <AdminDashboardPostPerformance
<span className="text-sm font-medium"> </span> topPosts={topPosts}
<MessageSquareText size={18} /> risingPosts={dashboard?.risingPosts ?? []}
</div> stalePopularPosts={dashboard?.stalePopularPosts ?? []}
<p className="mt-4 text-3xl font-bold text-gray-950">{totalComments.toLocaleString()}</p> isFallback={isFallback}
</div> />
<AdminDashboardActionCenter
actionItems={dashboard?.actionItems}
isFallback={isFallback}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5"> <RecentActivity
<div className="flex items-center justify-between text-gray-500"> posts={recentPosts}
<span className="text-sm font-medium"> </span> comments={recentComments}
<Sparkles size={18} /> isLoading={isFallbackLoading}
</div> />
<p className="mt-4 text-2xl font-bold text-gray-950">{formatDate(lastUpdatedAt)}</p>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-3"> <AdminDashboardCategoryHealth
<div className="rounded-lg border border-gray-200 bg-white p-5"> categoryStats={dashboard?.categoryStats ?? []}
<div className="mb-4 flex items-center justify-between gap-3"> isFallback={isFallback}
<div> />
<h2 className="text-lg font-bold text-gray-950"> </h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
<Link
href="/admin/posts"
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
>
<ArrowRight size={15} />
</Link>
</div>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : latestPosts.length > 0 ? (
<div className="divide-y divide-gray-100">
{latestPosts.map((post) => (
<PostRow key={post.id} post={post} />
))}
</div>
) : (
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="text-lg font-bold text-gray-950"> </h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : popularPosts.length > 0 ? (
<div className="mt-4 divide-y divide-gray-100">
{popularPosts.map((post) => (
<PostRow key={post.id} post={post} />
))}
</div>
) : (
<div className="mt-4 rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-gray-950"> 5</h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
<Link
href="/admin/comments"
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
>
<ArrowRight size={15} />
</Link>
</div>
{isCommentsLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : recentComments.length > 0 ? (
<div className="divide-y divide-gray-100">
{recentComments.map((comment) => (
<CommentRow key={comment.id} comment={comment} />
))}
</div>
) : (
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
</section>
</div> </div>
); );
} }

View File

@@ -1,152 +1,55 @@
'use client'; import type { Metadata } from 'next';
import { Archive } from 'lucide-react';
import { fetchPublicPosts } from '@/api/publicPosts';
import ArchiveExplorer from '@/components/post/ArchiveExplorer';
import WindowSurface from '@/components/ui/WindowSurface';
import { SITE_NAME } from '@/lib/site';
import { PostListResponse } from '@/types';
import { useQuery } from '@tanstack/react-query'; export const dynamic = 'force-dynamic';
import { getPosts } from '@/api/posts'; export const revalidate = 300;
import { Post } from '@/types';
import { Loader2, Calendar, Archive, FileText, ChevronRight } from 'lucide-react';
import { useMemo } from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { clsx } from 'clsx';
export default function ArchivePage() { export const metadata: Metadata = {
// 1. 전체 게시글 조회 (최대 1000개) title: 'Archive',
const { data, isLoading } = useQuery({ alternates: {
queryKey: ['posts', 'all'], canonical: '/archive',
queryFn: () => getPosts({ page: 0, size: 1000 }), },
staleTime: 1000 * 60 * 5, // 5분 캐시 openGraph: {
}); title: `Archive | ${SITE_NAME}`,
url: '/archive',
siteName: SITE_NAME,
type: 'website',
},
};
// 2. 게시글 그룹화 (연도 -> 월) const getTotalElements = (data?: PostListResponse | null) => {
const archiveGroups = useMemo(() => { return data?.page?.totalElements ?? data?.totalElements ?? 0;
if (!data?.content) return {}; };
const groups: { [year: string]: { [month: string]: Post[] } } = {}; export default async function ArchivePage() {
const data = await fetchPublicPosts({ page: 0, size: 1000, sort: 'createdAt,desc' });
data.content.forEach((post) => { const posts = data?.content || [];
const date = new Date(post.createdAt); const totalPosts = getTotalElements(data);
const year = date.getFullYear().toString();
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // '01', '02' 형식
if (!groups[year]) groups[year] = {};
if (!groups[year][month]) groups[year][month] = [];
groups[year][month].push(post);
});
return groups;
}, [data]);
// 3. 연도 내림차순 정렬
const sortedYears = useMemo(() => {
return Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a));
}, [archiveGroups]);
// 🛠️ 총 게시글 수 수정 (백엔드 PagedModel 대응)
// page 정보가 data 안에 직접 있거나, page 객체 안에 있을 수 있음
const meta = (data as any)?.page || data;
const totalPosts = meta?.totalElements || 0;
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[50vh]">
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}
return ( return (
<div className="max-w-4xl mx-auto py-10 px-4"> <main className="mx-auto min-w-0 max-w-[1180px] px-0 py-3 md:py-6">
{/* 헤더 섹션 */} <WindowSurface
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8"> title="Archive"
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3"> subtitle={`${totalPosts.toLocaleString()} posts`}
<Archive className="text-blue-600" size={32} /> bodyClassName="p-4 md:p-7"
<span>Archives</span> >
</h1> <div className="mb-7 min-w-0 border-b border-[var(--color-line)] pb-6 text-center md:text-left">
<p className="text-gray-500"> <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">
<span className="text-blue-600 font-bold">{totalPosts}</span> . <Archive className="shrink-0 text-[var(--color-accent)]" size={32} />
</p> <span className="min-w-0 break-words"></span>
</div> </h1>
<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> .
{sortedYears.length > 0 ? ( </p>
<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>
) : (
<div className="text-center py-20 bg-gray-50 rounded-xl border border-dashed border-gray-200"> <ArchiveExplorer posts={posts} />
<FileText className="mx-auto text-gray-300 mb-3" size={48} /> </WindowSurface>
<p className="text-gray-500"> .</p> </main>
</div>
)}
</div>
); );
} }

View File

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

Binary file not shown.

View File

@@ -1,37 +1,524 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
:root { :root {
--background: #ffffff; --font-apple-sans:
--foreground: #171717; var(--font-pretendard),
"Pretendard Variable",
Pretendard,
"Apple SD Gothic Neo",
-apple-system,
BlinkMacSystemFont,
"SF Pro Display",
"SF Pro Text",
"Helvetica Neue",
"Noto Sans KR",
system-ui,
sans-serif;
--font-apple-mono:
"SF Mono",
ui-monospace,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
--color-page: #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 { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-apple-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-apple-mono);
} }
@media (prefers-color-scheme: dark) { html[data-theme="dark"] {
:root { --color-page: #11131d;
--background: #0a0a0a; --desktop-bg:
--foreground: #ededed; 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 { body {
background: var(--background); min-height: 100vh;
overflow-x: clip;
background: var(--desktop-bg);
background-attachment: fixed;
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-apple-sans);
font-feature-settings: "kern", "ss05";
font-size: 16px;
font-weight: 450;
letter-spacing: 0;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
pre,
code,
table {
max-width: 100%;
}
::selection {
background: var(--color-accent-soft);
color: var(--color-text);
}
code,
kbd,
pre,
samp,
.tabular-nums {
font-family: var(--font-apple-mono);
}
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.bg-white {
background-color: var(--window-bg-strong);
}
.bg-gray-50,
.bg-gray-100 {
background-color: rgba(255, 255, 255, 0.38);
}
.bg-blue-50,
.bg-blue-100 {
background-color: var(--color-accent-soft);
}
.bg-red-50,
.bg-red-100 {
background-color: var(--color-danger-soft);
}
.bg-green-100 {
background-color: rgba(52, 199, 89, 0.14);
}
.bg-blue-600 {
background-color: var(--color-accent);
}
.hover\:bg-blue-700:hover {
background-color: var(--color-accent-hover);
}
.border-gray-100,
.border-gray-200,
.border-gray-300 {
border-color: var(--color-line);
}
.border-blue-100 {
border-color: rgba(0, 102, 204, 0.22);
}
.border-red-100 {
border-color: rgba(255, 59, 48, 0.22);
}
.text-gray-950,
.text-gray-900,
.text-gray-800,
.text-gray-700 {
color: var(--color-text);
}
.text-gray-600,
.text-gray-500 {
color: var(--color-text-muted);
}
.text-gray-400,
.text-gray-300 {
color: var(--color-text-subtle);
}
.text-blue-700,
.text-blue-600 {
color: var(--color-accent);
}
.text-red-950,
.text-red-700,
.text-red-600 {
color: #d9362e;
}
.text-green-600 {
color: #22863a;
}
.divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
border-color: var(--color-line);
}
.hover\:bg-white:hover,
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-gray-200:hover,
.focus\:bg-white:focus {
background-color: var(--card-bg-strong);
}
.hover\:bg-blue-50:hover {
background-color: rgba(0, 102, 204, 0.12);
}
.hover\:bg-red-50:hover {
background-color: rgba(255, 59, 48, 0.12);
}
.hover\:text-gray-600:hover,
.hover\:text-gray-700:hover,
.hover\:text-gray-800:hover {
color: var(--color-text);
}
.placeholder\:text-gray-300::placeholder,
.placeholder\:text-gray-400::placeholder {
color: var(--color-text-subtle);
}
.disabled\:bg-gray-50:disabled,
.disabled\:bg-gray-300:disabled,
.disabled\:bg-gray-400:disabled {
background-color: rgba(255, 255, 255, 0.32);
color: var(--color-text-subtle);
}
.shadow-xl,
.shadow-md,
.shadow-sm {
box-shadow: var(--shadow-card);
}
.rounded-xl {
border-radius: 0.5rem;
}
input:not([type="checkbox"]):not([type="radio"]),
textarea,
select {
background-color: var(--color-control);
border-color: var(--control-border);
color: var(--color-text);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.42) inset;
backdrop-filter: blur(18px);
}
input:not([type="checkbox"]):not([type="radio"]):focus,
textarea:focus,
select:focus {
background-color: var(--card-bg-strong);
border-color: var(--color-accent);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-subtle);
}
option {
background-color: var(--color-surface-strong);
color: var(--color-text);
}
html[data-theme="dark"] .bg-gray-50,
html[data-theme="dark"] .bg-gray-100 {
background-color: rgba(255, 255, 255, 0.08);
}
html[data-theme="dark"] .bg-blue-50,
html[data-theme="dark"] .bg-blue-100 {
background-color: rgba(10, 132, 255, 0.14);
}
html[data-theme="dark"] .bg-red-50,
html[data-theme="dark"] .bg-red-100 {
background-color: rgba(255, 69, 58, 0.13);
}
html[data-theme="dark"] .border-blue-100 {
border-color: rgba(10, 132, 255, 0.28);
}
html[data-theme="dark"] .border-red-100 {
border-color: rgba(255, 105, 97, 0.28);
}
html[data-theme="dark"] .text-blue-700,
html[data-theme="dark"] .text-blue-600 {
color: #8ecbff;
}
html[data-theme="dark"] .text-red-950,
html[data-theme="dark"] .text-red-700,
html[data-theme="dark"] .text-red-600 {
color: #ffb4ad;
}
html[data-theme="dark"] .text-green-600 {
color: #8ff0a4;
}
html[data-theme="dark"] .hover\:bg-white:hover,
html[data-theme="dark"] .hover\:bg-gray-50:hover,
html[data-theme="dark"] .hover\:bg-gray-100:hover,
html[data-theme="dark"] .hover\:bg-gray-200:hover,
html[data-theme="dark"] .focus\:bg-white:focus {
background-color: var(--card-bg-strong);
}
html[data-theme="dark"] .hover\:bg-blue-50:hover {
background-color: rgba(10, 132, 255, 0.16);
}
html[data-theme="dark"] .hover\:bg-red-50:hover {
background-color: rgba(255, 69, 58, 0.16);
}
html[data-theme="dark"] .disabled\:bg-gray-50:disabled,
html[data-theme="dark"] .disabled\:bg-gray-300:disabled,
html[data-theme="dark"] .disabled\:bg-gray-400:disabled {
background-color: rgba(255, 255, 255, 0.1);
color: var(--color-text-subtle);
}
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]),
html[data-theme="dark"] textarea,
html[data-theme="dark"] select {
background-color: var(--color-control);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset;
}
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):focus,
html[data-theme="dark"] textarea:focus,
html[data-theme="dark"] select:focus {
background-color: var(--card-bg-strong);
} }
.w-md-editor { .w-md-editor {
border-radius: 0.75rem !important; /* 둥근 모서리 */ border-radius: 0.5rem !important;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); border-color: var(--card-border) !important;
border-color: #e5e7eb !important; background-color: var(--card-bg-strong) !important;
box-shadow: var(--shadow-card);
backdrop-filter: blur(20px);
} }
.w-md-editor-toolbar { .w-md-editor-toolbar {
border-radius: 0.75rem 0.75rem 0 0 !important; border-radius: 0.5rem 0.5rem 0 0 !important;
background-color: #f9fafb !important; border-color: var(--window-titlebar-border) !important;
} background-color: var(--window-titlebar) !important;
}
html[data-theme="dark"] .w-md-editor {
background-color: var(--card-bg-strong) !important;
border-color: var(--card-border) !important;
}
html[data-theme="dark"] .w-md-editor-toolbar {
background-color: var(--window-titlebar) !important;
border-color: var(--window-titlebar-border) !important;
}
.wy-editor-shell {
--color-canvas-default: transparent;
--color-canvas-subtle: transparent;
--color-border-default: var(--card-border);
--color-border-muted: var(--card-border);
--color-fg-default: var(--color-text);
--color-fg-muted: var(--color-text-muted);
--color-accent-fg: var(--color-accent);
color: var(--color-text);
}
.wy-editor-shell .w-md-editor {
min-height: 520px;
overflow: hidden;
border-radius: 0.5rem !important;
border: 1px solid var(--card-border) !important;
background-color: var(--card-bg-strong) !important;
color: var(--color-text) !important;
box-shadow: var(--shadow-card) !important;
backdrop-filter: blur(20px);
}
.wy-editor-shell .w-md-editor-toolbar {
border-color: var(--window-titlebar-border) !important;
background-color: var(--window-titlebar) !important;
}
.wy-editor-shell .w-md-editor-toolbar button,
.wy-editor-shell .w-md-editor-toolbar li,
.wy-editor-shell .w-md-editor-toolbar svg {
color: var(--color-text-muted) !important;
}
.wy-editor-shell .w-md-editor-toolbar button:hover {
background-color: var(--color-control) !important;
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-content,
.wy-editor-shell .w-md-editor-area,
.wy-editor-shell .w-md-editor-text,
.wy-editor-shell .w-md-editor-text-pre,
.wy-editor-shell .w-md-editor-text-input,
.wy-editor-shell .w-md-editor-preview,
.wy-editor-shell .wmde-markdown {
background: transparent !important;
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-text {
color: var(--color-text) !important;
}
.wy-editor-shell .w-md-editor-text-pre,
.wy-editor-shell .w-md-editor-text-pre *,
.wy-editor-shell .w-md-editor-text-pre > code,
.wy-editor-shell .w-md-editor-text-pre code,
.wy-editor-shell .w-md-editor-text-pre .token,
.wy-editor-shell .w-md-editor-text-pre .punctuation {
color: transparent !important;
-webkit-text-fill-color: transparent !important;
}
.wy-editor-shell .w-md-editor-text-input {
opacity: 1 !important;
background: transparent !important;
color: var(--color-text) !important;
-webkit-text-fill-color: var(--color-text) !important;
caret-color: var(--color-accent);
text-shadow: none !important;
}
.wy-editor-shell .w-md-editor-text-input::placeholder {
color: var(--color-text-subtle) !important;
-webkit-text-fill-color: var(--color-text-subtle) !important;
}
.wy-editor-shell .wmde-markdown,
.wy-editor-shell .wmde-markdown p,
.wy-editor-shell .wmde-markdown li,
.wy-editor-shell .wmde-markdown h1,
.wy-editor-shell .wmde-markdown h2,
.wy-editor-shell .wmde-markdown h3,
.wy-editor-shell .wmde-markdown h4,
.wy-editor-shell .wmde-markdown h5,
.wy-editor-shell .wmde-markdown h6 {
color: var(--color-text) !important;
}
.wy-editor-shell .wmde-markdown a {
color: var(--color-accent) !important;
}
.wy-editor-shell .wmde-markdown blockquote {
color: var(--color-text-muted) !important;
border-left-color: var(--color-line) !important;
}
.wy-editor-shell .wmde-markdown pre,
.wy-editor-shell .wmde-markdown code {
background-color: rgba(30, 30, 30, 0.9) !important;
color: #f4f4f5 !important;
}

View File

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

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { login } from '@/api/auth'; import { login } from '@/api/auth';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
const getSafeRedirectPath = (value: string | null) => { const getSafeRedirectPath = (value: string | null) => {
@@ -58,33 +59,38 @@ function LoginForm() {
}; };
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4"> <div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-gray-100 bg-white p-8 shadow-xl"> <WindowSurface
title="Login"
subtitle="WYPark OS"
className="w-full max-w-md"
bodyClassName="p-8"
>
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-3xl font-bold text-gray-900"></h1> <h1 className="text-3xl font-bold text-[var(--color-text)]"></h1>
<p className="mt-2 text-gray-500"> .</p> <p className="mt-2 text-[var(--color-text-muted)]"> .</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(event) => setFormData({ ...formData, email: event.target.value })} onChange={(event) => setFormData({ ...formData, email: event.target.value })}
className="w-full rounded-lg border border-gray-200 px-4 py-2 outline-none transition-all focus:ring-2 focus:ring-blue-500" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
placeholder="example@email.com" placeholder="example@email.com"
required required
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"></label> <label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input <input
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(event) => setFormData({ ...formData, password: event.target.value })} onChange={(event) => setFormData({ ...formData, password: event.target.value })}
className="w-full rounded-lg border border-gray-200 px-4 py-2 outline-none transition-all focus:ring-2 focus:ring-blue-500" className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
placeholder="••••••••" placeholder="••••••••"
required required
/> />
@@ -95,20 +101,20 @@ function LoginForm() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-3 font-bold text-white transition-colors hover:bg-blue-700 disabled:bg-blue-400" className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
> >
{loading && <Loader2 className="animate-spin" size={20} />} {loading && <Loader2 className="animate-spin" size={20} />}
</button> </button>
</form> </form>
<div className="mt-6 text-center text-sm text-gray-500"> <div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
?{' '} ?{' '}
<Link href="/signup" className="font-medium text-blue-600 hover:underline"> <Link href="/signup" className="font-medium text-[var(--color-accent)] hover:underline">
</Link> </Link>
</div> </div>
</div> </WindowSurface>
</div> </div>
); );
} }
@@ -117,8 +123,8 @@ export default function LoginPage() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={36} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div> </div>
} }
> >

View File

@@ -1,184 +1,320 @@
'use client'; import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { useState, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { getPosts } from '@/api/posts';
import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem';
// PostSearch 컴포넌트 제거 (사이드바로 이동)
import { Post } from '@/types';
import { Loader2, Megaphone, Flame, Clock, ChevronRight, Search as SearchIcon } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {
Archive,
ChevronRight,
Clock,
Search,
TrendingUp,
} from 'lucide-react';
import { fetchPublicPosts } from '@/api/publicPosts';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
import { DEFAULT_DESCRIPTION, SITE_NAME } from '@/lib/site';
import { Post, PostListResponse } from '@/types';
function HomeContent() { export const dynamic = 'force-dynamic';
const router = useRouter(); export const revalidate = 300;
const searchParams = useSearchParams();
// URL 쿼리 스트링에서 검색어 가져오기
const keyword = searchParams.get('keyword') || '';
// 1. 공지사항 조회 export const metadata: Metadata = {
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({ title: {
queryKey: ['posts', 'notices'], absolute: SITE_NAME,
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }), },
}); description: DEFAULT_DESCRIPTION,
alternates: {
canonical: '/',
},
openGraph: {
title: SITE_NAME,
description: DEFAULT_DESCRIPTION,
url: '/',
siteName: SITE_NAME,
type: 'website',
},
};
// 2. 최신 게시글 조회 type HomePageProps = {
const { data: latestData, isLoading: isLatestLoading } = useQuery({ searchParams?: Promise<Record<string, string | string[] | undefined>>;
queryKey: ['posts', 'latest'], };
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
});
// 3. 인기 게시글 조회 const isNoticePost = (post: Post) => {
const { data: popularData, isLoading: isPopularLoading } = useQuery({ const categoryName = post.categoryName || '';
queryKey: ['posts', 'popular'], const normalizedName = categoryName.toLowerCase();
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
});
// 4. 검색 결과 조회 return categoryName === '공지' || normalizedName === 'notice' || normalizedName === 'announcement';
const { data: searchData, isLoading: isSearchLoading } = useQuery({ };
queryKey: ['posts', 'search', keyword],
queryFn: () => getPosts({ keyword, size: 20 }),
enabled: !!keyword,
});
if (isNoticesLoading || isLatestLoading || isPopularLoading) { const formatDate = (value?: string) => {
return ( if (!value) return '';
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}
const filterPosts = (posts: Post[] | undefined, limit: number) => { const date = new Date(value);
if (!posts) return []; if (Number.isNaN(date.getTime())) return '';
return posts
.filter((post) => post.categoryName !== '공지' && post.categoryName.toLowerCase() !== 'notice')
.slice(0, limit);
};
const notices = noticesData?.content || []; return new Intl.DateTimeFormat('ko-KR', {
const latestPosts = filterPosts(latestData?.content, 3); year: 'numeric',
const popularPosts = filterPosts(popularData?.content, 3); month: 'short',
day: 'numeric',
const searchResults = searchData?.content || []; }).format(date);
const searchMeta = (searchData as any)?.page || searchData; };
const searchTotalElements = searchMeta?.totalElements ?? 0;
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);
return ( return (
<main className="max-w-4xl mx-auto px-4 py-8 space-y-12"> <WindowSurface
title="Search"
{/* 🅰️ 검색 모드: 검색어가 있을 때 표시 */} subtitle={`"${keyword}"`}
{keyword ? ( bodyClassName="p-4 md:p-6"
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300"> 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} /> <div className="mb-6 flex min-w-0 flex-col gap-2 border-b border-[var(--color-line)] pb-5">
<h2 className="text-xl font-bold text-gray-800"> <div className="flex min-w-0 items-center gap-2 text-[var(--color-accent)]">
"{keyword}" <span className="text-blue-600 text-lg ml-1">{searchTotalElements}</span> <Search size={22} className="shrink-0" />
</h2> <h1 className="min-w-0 break-words text-2xl font-bold tracking-normal text-[var(--color-text)]">
</div>
</h1>
{isSearchLoading ? (
<div className="py-20 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>
) : searchResults.length > 0 ? (
<div className="flex flex-col gap-0 border-t border-gray-100">
{searchResults.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</div>
) : (
<div className="text-center py-20 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
) : (
/* 🅱️ 대시보드 모드: 검색어가 없을 때 기존 화면 표시 */
<div className="space-y-16 animate-in fade-in duration-500">
{/* 공지사항 섹션 */}
{notices.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
<Megaphone className="text-red-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"></h2>
</div>
<div className="flex flex-col gap-2">
{notices.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</div>
</section>
)}
{/* 최신 포스트 섹션 */}
<section>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Clock className="text-blue-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
<Link href="/archive" className="text-sm text-gray-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<ChevronRight size={14} />
</Link>
</div>
{latestPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{latestPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 인기 포스트 섹션 */}
<section>
<div className="flex items-center gap-2 mb-6">
<Flame className="text-orange-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
{popularPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{popularPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 하단 아카이브 링크 */}
{/* <div className="pt-8 pb-4 text-center">
<Link
href="/archive"
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200 transition-colors font-medium text-sm"
>
모든 글 보러가기 <ChevronRight size={16} />
</Link>
</div> */}
</div> </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="다른 키워드로 다시 찾아보세요." />
)} )}
</main> </WindowSurface>
); );
} }
export default function Home() { function NoticeStrip({ notices }: { notices: Post[] }) {
if (notices.length === 0) return null;
return ( return (
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" size={40} /></div>}> <section className="space-y-2" aria-label="공지">
<HomeContent /> {notices.slice(0, 3).map((notice) => (
</Suspense> <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 [noticesData, latestData, popularData] = await Promise.all([
fetchPublicPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
fetchPublicPosts({ size: 10, sort: 'createdAt,desc' }),
fetchPublicPosts({ size: 8, sort: 'viewCount,desc' }),
]);
const notices = noticesData?.content || [];
const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 7);
const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5);
return (
<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 h-8 min-w-0 items-center gap-2 rounded-full bg-[var(--color-text)] px-3 text-xs font-semibold text-[var(--color-page)] shadow-[var(--shadow-control)] transition hover:opacity-90 dark:bg-white dark:text-black"
>
<span className="hidden sm:inline"> </span>
<Archive size={14} />
</Link>
)}
bodyClassName="space-y-6 p-4 md:p-6"
>
<NoticeStrip notices={notices} />
<section className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)]">
<PostListPanel
title="최신 글"
icon={<Clock size={18} className="shrink-0 text-[var(--color-accent)]" />}
posts={latestList}
featured
/>
<PostListPanel
title="인기 글"
icon={<TrendingUp size={18} className="shrink-0 text-[var(--color-accent)]" />}
posts={popularList}
isPopular
/>
</section>
</WindowSurface>
</main>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,14 +15,14 @@ function LegacyWriteRedirect() {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={36} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div> </div>
); );
} }
export default function WritePage() { export default function WritePage() {
return ( return (
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-blue-500" /></div>}> <Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>}>
<LegacyWriteRedirect /> <LegacyWriteRedirect />
</Suspense> </Suspense>
); );

View File

@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Folder, FolderOpen, FolderPlus, FolderTree, Loader2, Save, Trash2 } from 'lucide-react'; import { Folder, FolderOpen, FolderPlus, FolderTree, Loader2, Save, Trash2 } from 'lucide-react';
import { createCategory, deleteCategory, getCategories, updateCategory } from '@/api/category'; import { createCategory, deleteCategory, getCategories, updateCategory } from '@/api/category';
import WindowSurface from '@/components/ui/WindowSurface';
import { Category, CategoryUpdateRequest } from '@/types'; import { Category, CategoryUpdateRequest } from '@/types';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
@@ -342,7 +343,7 @@ export default function AdminCategoryPanel() {
}; };
return ( return (
<section className="rounded-lg border border-gray-200 bg-white p-5"> <WindowSurface title="Categories" subtitle="Finder tree" bodyClassName="p-5">
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div> <div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950"> <h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
@@ -426,6 +427,6 @@ export default function AdminCategoryPanel() {
)} )}
</div> </div>
</div> </div>
</section> </WindowSurface>
); );
} }

View File

@@ -14,6 +14,7 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { deleteAdminComment, getAdminComments } from '@/api/comments'; import { deleteAdminComment, getAdminComments } from '@/api/comments';
import WindowSurface from '@/components/ui/WindowSurface';
import { AdminComment, AdminCommentListResponse, PageMeta } from '@/types'; import { AdminComment, AdminCommentListResponse, PageMeta } from '@/types';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -168,7 +169,7 @@ export default function AdminCommentsPanel() {
}; };
return ( return (
<section className="rounded-lg border border-gray-200 bg-white p-5"> <WindowSurface title="Comments" subtitle="Moderation" bodyClassName="p-5">
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div> <div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950"> <h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
@@ -283,6 +284,6 @@ export default function AdminCommentsPanel() {
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
/> />
)} )}
</section> </WindowSurface>
); );
} }

View File

@@ -3,24 +3,28 @@
import axios from 'axios'; import axios from 'axios';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { import {
ArrowLeft, ArrowLeft,
CheckCircle2,
Clock, Clock,
FileText, FileText,
Folder, Folder,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
Save, Save,
Tags,
Trash2, Trash2,
UploadCloud, UploadCloud,
X,
} from 'lucide-react'; } from 'lucide-react';
import { getCategories } from '@/api/category'; import { getCategories } from '@/api/category';
import { uploadImage } from '@/api/image'; import { uploadImage } from '@/api/image';
import { createPost, getPost, updatePost } from '@/api/posts'; 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 { useAuthStore } from '@/store/authStore';
import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types'; import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types';
@@ -62,6 +66,17 @@ const findCategoryByName = (categories: Category[], name: string): Category | nu
return null; 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) => { const isTokenExpired = (token: string) => {
try { try {
const base64Url = token.split('.')[1]; const base64Url = token.split('.')[1];
@@ -92,25 +107,22 @@ function CategoryOptions({
depth?: number; depth?: number;
}) { }) {
return ( return (
<> <div className={depth === 0 ? 'space-y-1' : 'mt-1 space-y-1'}>
{categories.map((category) => ( {categories.map((category) => (
<div key={category.id}> <div key={category.id}>
<label className="flex cursor-pointer items-center gap-2 rounded p-2 transition-colors hover:bg-gray-50"> <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 <input
type="radio" type="radio"
name="category" name="category"
value={category.id} value={category.id}
checked={selectedId === category.id} checked={selectedId === category.id}
onChange={(event) => onSelect(Number(event.target.value))} onChange={(event) => onSelect(Number(event.target.value))}
className="text-blue-600 focus:ring-blue-500" className="h-4 w-4 accent-[var(--color-accent)]"
/> />
<span <span className={depth === 0 ? 'font-semibold' : ''}>{category.name}</span>
style={{ marginLeft: `${depth * 10}px` }}
className={depth === 0 ? 'font-medium' : 'text-gray-600'}
>
{depth > 0 && '- '}
{category.name}
</span>
</label> </label>
{category.children?.length > 0 && ( {category.children?.length > 0 && (
<CategoryOptions <CategoryOptions
@@ -122,7 +134,7 @@ function CategoryOptions({
)} )}
</div> </div>
))} ))}
</> </div>
); );
} }
@@ -136,39 +148,38 @@ function DraftLoadDialog({
onConfirm: () => void; onConfirm: () => void;
}) { }) {
return ( 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="fixed inset-0 z-50 flex items-end justify-center bg-black/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"> <WindowSurface
<div className="border-b border-gray-100 p-5"> title="임시저장 불러오기"
<h3 className="text-lg font-bold text-gray-950"> </h3> className="w-full max-w-md"
<p className="mt-1 text-sm leading-6 text-gray-500"> bodyClassName="p-5"
. >
</p> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
</div> .
</p>
<div className="p-5"> <Surface strong className="mt-4 p-4 shadow-none">
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3"> <p className="line-clamp-2 text-sm font-semibold text-[var(--color-text)]">{draft.title}</p>
<p className="line-clamp-2 text-sm font-semibold text-gray-950">{draft.title}</p> <p className="mt-1 text-xs text-[var(--color-text-subtle)]">{draft.savedAt}</p>
<p className="mt-1 text-xs text-gray-500">{draft.savedAt}</p> </Surface>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
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" 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>
<button <button
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
className="inline-flex items-center justify-center rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700" 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> </button>
</div>
</div> </div>
</div> </WindowSurface>
</div> </div>
); );
} }
@@ -177,12 +188,13 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { resolvedTheme } = useTheme();
const { isLoggedIn, role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); const { isLoggedIn, role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN'); const isAdmin = _hasHydrated && role?.includes('ADMIN');
const isEditMode = Boolean(editSlug); const isEditMode = Boolean(editSlug);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [content, setContent] = useState('**Hello world!**'); const [content, setContent] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>(''); const [categoryId, setCategoryId] = useState<number | ''>('');
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@@ -246,6 +258,17 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
} }
}, [existingPost, categories]); }, [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[]) => { const saveDrafts = (nextDrafts: DraftPost[]) => {
setDrafts(nextDrafts); setDrafts(nextDrafts);
localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts)); localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts));
@@ -253,12 +276,12 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const handleTempSave = () => { const handleTempSave = () => {
if (!title.trim() && !content.trim()) { if (!title.trim() && !content.trim()) {
toast.error('제목이나 내용을 입력해주세요.'); toast.error('제목이나 내용을 입력해 주세요.');
return; return;
} }
if (drafts.length >= 10) { if (drafts.length >= 10) {
toast.error('임시저장은 최대 10개까지 가능합니다.\n기존 저장분을 삭제해주세요.'); toast.error('임시저장은 최대 10개까지 가능합니다. 기존 저장분을 삭제해 주세요.');
setShowDraftList(true); setShowDraftList(true);
return; return;
} }
@@ -272,7 +295,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
}, },
...drafts, ...drafts,
]); ]);
toast.success('임시저장 되었습니다.'); toast.success('임시저장습니다.');
}; };
const handleLoadDraft = (draft: DraftPost) => { const handleLoadDraft = (draft: DraftPost) => {
@@ -286,12 +309,12 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
setContent(draftToLoad.content); setContent(draftToLoad.content);
setShowDraftList(false); setShowDraftList(false);
setDraftToLoad(null); setDraftToLoad(null);
toast.success('불러오기 완료'); toast.success('임시저장을 불러왔습니다.');
}; };
const handleDeleteDraft = (id: number) => { const handleDeleteDraft = (id: number) => {
saveDrafts(drafts.filter((draft) => draft.id !== id)); saveDrafts(drafts.filter((draft) => draft.id !== id));
toast.success('삭제되었습니다.'); toast.success('삭제습니다.');
}; };
const mutation = useMutation({ const mutation = useMutation({
@@ -316,7 +339,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
queryClient.removeQueries({ queryKey: ['post', newSlug] }); queryClient.removeQueries({ queryKey: ['post', newSlug] });
} }
toast.success(isEditMode ? '게시글 수정되었습니다.' : '게시글 발행되었습니다.'); toast.success(isEditMode ? '게시글 수정습니다.' : '게시글 발행습니다.');
router.push(newSlug ? `/posts/${newSlug}` : '/admin'); router.push(newSlug ? `/posts/${newSlug}` : '/admin');
}, },
onError: (error) => { onError: (error) => {
@@ -350,13 +373,13 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
return false; return false;
} catch { } catch {
toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요.', { duration: 5000 }); toast.error('세션이 만료되었습니다. 작성 중인 글을 복사해 두고 다시 로그인해 주세요.', { duration: 5000 });
return false; return false;
} }
}; };
const appendImageMarkdown = (imageUrl: string) => { const appendImageMarkdown = (imageUrl: string) => {
setContent((previous) => `${previous}\n![image](${imageUrl})`); setContent((previous) => `${previous}${previous ? '\n\n' : ''}![image](${imageUrl})`);
}; };
const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => { const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => {
@@ -374,11 +397,11 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
toast.error('제목과 내용을 입력해주세요.'); toast.error('제목과 내용을 입력해 주세요.');
return; return;
} }
if (categoryId === '') { if (categoryId === '') {
toast.error('카테고리를 선택해주세요.'); toast.error('카테고리를 선택해 주세요.');
return; return;
} }
@@ -406,7 +429,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const uploadToast = toast.loading('이미지 업로드 중...'); const uploadToast = toast.loading('이미지 업로드 중...');
try { try {
await uploadEditorImage(file, '이미지가 업로드되었습니다.', uploadToast); await uploadEditorImage(file, '이미지를 추가했습니다.', uploadToast);
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally { } finally {
@@ -430,7 +453,7 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
const uploadToast = toast.loading('이미지 업로드 중...'); const uploadToast = toast.loading('이미지 업로드 중...');
try { try {
await uploadEditorImage(file, '이미지 붙여넣기 완료.', uploadToast); await uploadEditorImage(file, '붙여넣은 이미지를 추가했습니다.', uploadToast);
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast });
} finally { } finally {
@@ -442,175 +465,226 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) { if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={40} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8" onPaste={handlePaste}> <main className="mx-auto w-full px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6" onPaste={handlePaste}>
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center"> <WindowSurface
<div className="flex items-center gap-3"> title={isEditMode ? '글 수정' : '새 글 작성'}
subtitle="Markdown Studio"
controls={(
<button <button
type="button" type="button"
onClick={() => router.push('/admin/posts')} onClick={() => router.push('/admin/posts')}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100" 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)]"
aria-label="게시글 관리로 돌아가기"
> >
<ArrowLeft size={20} /> <ArrowLeft size={14} />
</button> </button>
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1> )}
</div> bodyClassName="p-4 md:p-6"
>
<div className="flex flex-wrap items-center gap-3"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="relative"> <section className="min-w-0 space-y-4">
<div className="flex items-center rounded-lg border border-gray-300 bg-white shadow-sm"> <Surface strong className="p-4 shadow-none md:p-5">
<button <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">
type="button" <div>
onClick={handleTempSave} <p className="text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">Document</p>
className="flex items-center gap-2 rounded-l-lg border-r border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50" <h1 className="mt-1 text-xl font-bold text-[var(--color-text)]">
title="현재 내용 임시저장" {isEditMode ? '게시글 수정' : '게시글 작성'}
> </h1>
<FileText size={16} className="text-gray-500" />
<span className="hidden sm:inline"></span>
</button>
<button
type="button"
onClick={() => setShowDraftList(!showDraftList)}
className="flex items-center gap-1 rounded-r-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
title="임시저장 목록 보기"
>
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs font-bold text-gray-600">
{drafts.length}
</span>
</button>
</div>
{showDraftList && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowDraftList(false)} />
<div className="absolute right-0 top-full z-20 mt-2 w-80 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-gray-100 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-bold text-gray-700"> ({drafts.length}/10)</h3>
<button type="button" onClick={() => setShowDraftList(false)} aria-label="임시저장 목록 닫기">
<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-sm text-gray-400"> .</div>
) : (
<ul className="divide-y divide-gray-100">
{drafts.map((draft) => (
<li key={draft.id} className="group p-3 transition-colors hover:bg-blue-50">
<div className="flex items-start justify-between gap-2">
<button type="button" onClick={() => handleLoadDraft(draft)} className="flex-1 text-left">
<p className="mb-1 line-clamp-1 text-sm font-medium text-gray-800 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
type="button"
onClick={() => handleDeleteDraft(draft.id)}
className="rounded p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500"
title="삭제"
>
<Trash2 size={14} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div> </div>
</>
)}
</div>
<button <div className="flex flex-wrap items-center gap-2">
type="button" <button
onClick={handleSubmit} type="button"
disabled={isSubmitting || isUploading} onClick={handleTempSave}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 font-bold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 disabled:bg-gray-400" 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)]"
> >
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />} <FileText size={16} />
{isEditMode ? '수정하기' : '작성하기'}
</button> </button>
</div> <button
</div> type="button"
onClick={handleSubmit}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4"> disabled={isSubmitting || isUploading || !canSubmit}
<div className="space-y-4 lg:col-span-3"> 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"
<input >
type="text" {isSubmitting ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
value={title} {isEditMode ? '수정하기' : '발행하기'}
onChange={(event) => setTitle(event.target.value)} </button>
placeholder="제목을 입력하세요" </div>
className="w-full border-none bg-transparent py-2 text-3xl font-bold outline-none placeholder:text-gray-300"
/>
<div data-color-mode="light">
<MDEditor
value={content}
onChange={(value) => setContent(value || '')}
height={600}
preview="edit"
className="rounded-lg border border-gray-200 shadow-sm !font-sans"
/>
</div>
</div>
<div className="space-y-6">
<div className="sticky top-6 rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 flex items-center gap-2 font-bold text-gray-700">
<Folder size={18} />
</h3>
<div className="max-h-60 space-y-1 overflow-y-auto border-t border-gray-100 pt-2 text-sm">
{categories.length > 0 ? (
<CategoryOptions categories={categories} selectedId={categoryId} onSelect={setCategoryId} />
) : (
<p className="text-sm text-gray-400"> ...</p>
)}
</div>
</div>
<div className="sticky top-[280px] rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 flex items-center gap-2 font-bold text-gray-700">
<ImageIcon size={18} />
</h3>
<p className="mb-3 text-xs leading-relaxed text-gray-500">
<br />
<strong> & (Ctrl+V)</strong>
<br /> .
</p>
<label
className={`flex h-24 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 transition-all duration-200 hover:border-blue-400 hover:bg-gray-50 ${isUploading ? 'cursor-wait opacity-50' : ''}`}
>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<UploadCloud className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-xs font-medium text-gray-500">
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
</p>
</div> </div>
<label className="mb-2 block text-xs font-bold uppercase tracking-normal text-[var(--color-text-subtle)]">
</label>
<input <input
type="file" type="text"
className="hidden" value={title}
accept="image/*" onChange={(event) => setTitle(event.target.value)}
onChange={handleFileChange} placeholder="제목을 입력하세요"
disabled={isUploading} 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"
/> />
</label>
</div> <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> </div>
</div> </WindowSurface>
{draftToLoad && ( {draftToLoad && (
<DraftLoadDialog <DraftLoadDialog
@@ -619,6 +693,6 @@ export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) {
onConfirm={confirmLoadDraft} onConfirm={confirmLoadDraft}
/> />
)} )}
</div> </main>
); );
} }

View File

@@ -18,6 +18,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { getCategories } from '@/api/category'; import { getCategories } from '@/api/category';
import { deletePost, getPosts } from '@/api/posts'; import { deletePost, getPosts } from '@/api/posts';
import WindowSurface from '@/components/ui/WindowSurface';
import { Category, PageMeta, Post, PostListResponse } from '@/types'; import { Category, PageMeta, Post, PostListResponse } from '@/types';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -399,7 +400,7 @@ export default function AdminPostsPanel() {
}; };
return ( return (
<section className="rounded-lg border border-gray-200 bg-white p-5"> <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 className="mb-5 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div> <div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950"> <h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
@@ -665,6 +666,6 @@ export default function AdminPostsPanel() {
onConfirm={handleConfirmBulkDelete} onConfirm={handleConfirmBulkDelete}
/> />
)} )}
</section> </WindowSurface>
); );
} }

View File

@@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
import { Camera, Loader2, Save, UserRound } from 'lucide-react'; import { Camera, Loader2, Save, UserRound } from 'lucide-react';
import { getProfile, updateProfile } from '@/api/profile'; import { getProfile, updateProfile } from '@/api/profile';
import { uploadImage } from '@/api/image'; import { uploadImage } from '@/api/image';
import WindowSurface from '@/components/ui/WindowSurface';
import { Profile, ProfileUpdateRequest } from '@/types'; import { Profile, ProfileUpdateRequest } from '@/types';
const defaultProfile: Profile = { const defaultProfile: Profile = {
@@ -206,7 +207,7 @@ export default function AdminProfilePanel() {
].join('|'); ].join('|');
return ( return (
<section className="rounded-lg border border-gray-200 bg-white p-5"> <WindowSurface title="Profile" subtitle="Public identity" bodyClassName="p-5">
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div> <div>
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950"> <h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
@@ -224,6 +225,6 @@ export default function AdminProfilePanel() {
) : ( ) : (
<ProfileForm key={formKey} profile={displayProfile} /> <ProfileForm key={formKey} profile={displayProfile} />
)} )}
</section> </WindowSurface>
); );
} }

View File

@@ -6,6 +6,7 @@ import { useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { BarChart3, FileText, FolderTree, Loader2, MessageSquareText, UserRound } from 'lucide-react'; import { BarChart3, FileText, FolderTree, Loader2, MessageSquareText, UserRound } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import WindowSurface from '@/components/ui/WindowSurface';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
const navItems = [ const navItems = [
@@ -50,35 +51,40 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
if (!_hasHydrated || !isAdmin) { if (!_hasHydrated || !isAdmin) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={36} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-6xl space-y-6 px-1 py-4 md:px-4"> <div className="mx-auto w-full space-y-6 px-0 py-4 md:w-[80vw] md:max-w-[1400px] md:py-6">
<nav className="flex gap-2 overflow-x-auto border-b border-gray-200 pb-3" aria-label="관리자 메뉴"> <WindowSurface title="Admin Console" subtitle="Operations toolbar" bodyClassName="p-2">
{navItems.map((item) => { <nav
const Icon = item.icon; 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]"
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`); aria-label="관리자 메뉴"
>
{navItems.map((item) => {
const Icon = item.icon;
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={clsx( className={clsx(
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition', 'inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-4 text-sm font-semibold transition',
isActive isActive
? 'bg-gray-950 text-white' ? 'bg-[var(--card-bg-strong)] text-[var(--color-text)] shadow-sm'
: 'border border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-950', : 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)]',
)} )}
> >
<Icon size={16} /> <Icon size={16} />
{item.label} {item.label}
</Link> </Link>
); );
})} })}
</nav> </nav>
</WindowSurface>
{children} {children}
</div> </div>

View File

@@ -0,0 +1,75 @@
import Link from 'next/link';
import { AlertCircle, ArrowRight, MessageSquareText, RefreshCcw, Tags } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import Surface from '@/components/ui/Surface';
import { DashboardActionItems } from '@/types';
interface AdminDashboardActionCenterProps {
actionItems?: DashboardActionItems;
isFallback: boolean;
}
export default function AdminDashboardActionCenter({
actionItems,
isFallback,
}: AdminDashboardActionCenterProps) {
const items = [
{
label: '답변 필요한 댓글',
value: actionItems?.unansweredComments,
href: '/admin/comments',
icon: MessageSquareText,
},
{
label: '미분류 글',
value: actionItems?.uncategorizedPosts,
href: '/admin/posts',
icon: Tags,
},
{
label: '오래 방치된 인기 글',
value: actionItems?.stalePopularPosts,
href: '/admin/posts',
icon: RefreshCcw,
},
];
return (
<Surface as="section" className="p-5">
<div className="mb-5 flex items-center gap-2">
<AlertCircle size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
{actionItems ? (
<div className="space-y-2">
{items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.label}
href={item.href}
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
>
<span className="flex items-center gap-3 text-sm font-semibold text-[var(--color-text-muted)]">
<Icon size={16} />
{item.label}
</span>
<span className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
{(item.value ?? 0).toLocaleString()}
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
</span>
</Link>
);
})}
</div>
) : (
<EmptyState
title="액션 집계 연결 전"
description={isFallback ? '관리자 대시보드 API가 준비되면 처리할 일을 모아 보여줍니다.' : '처리할 항목이 없습니다.'}
/>
)}
</Surface>
);
}

View File

@@ -0,0 +1,69 @@
import Link from 'next/link';
import { ArrowRight, FolderTree } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import Surface from '@/components/ui/Surface';
import { DashboardCategoryStat } from '@/types';
interface AdminDashboardCategoryHealthProps {
categoryStats: DashboardCategoryStat[];
isFallback: boolean;
}
const formatDate = (value?: string | null) => {
if (!value) return '발행 기록 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '발행 기록 없음';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
};
export default function AdminDashboardCategoryHealth({
categoryStats,
isFallback,
}: AdminDashboardCategoryHealthProps) {
return (
<Surface as="section" className="p-5">
<div className="mb-5 flex items-center gap-2">
<FolderTree size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
{categoryStats.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-[var(--shadow-card)]">
<div className="min-w-[680px]">
<div className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 bg-[var(--window-titlebar)] px-4 py-3 text-xs font-bold text-[var(--color-text-subtle)]">
<span></span>
<span></span>
<span> </span>
<span> </span>
<span />
</div>
{categoryStats.slice(0, 6).map((category) => (
<Link
key={category.id}
href={`/category/${category.name}`}
className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 border-t border-[var(--card-border)] px-4 py-3 text-sm transition hover:bg-[var(--card-bg-strong)]"
>
<span className="truncate font-semibold text-[var(--color-text)]">{category.name}</span>
<span className="text-[var(--color-text-muted)]">{category.postCount.toLocaleString()}</span>
<span className="text-[var(--color-text-muted)]">{category.recentViewCount.toLocaleString()}</span>
<span className="truncate text-[var(--color-text-subtle)]">{formatDate(category.lastPublishedAt)}</span>
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
</Link>
))}
</div>
</div>
) : (
<EmptyState
title={isFallback ? '카테고리 통계 API 연결 전' : '카테고리 통계가 없습니다.'}
description={isFallback ? '글 수, 최근 조회수, 최근 발행일은 dashboard API 연결 후 표시됩니다.' : undefined}
/>
)}
</Surface>
);
}

View File

@@ -0,0 +1,82 @@
import { CalendarClock, Eye, FileText, FolderTree, MessageSquareText } from 'lucide-react';
import MetricCard from '@/components/ui/MetricCard';
import { DashboardOverview } from '@/types';
interface AdminDashboardOverviewProps {
overview?: DashboardOverview;
fallbackTotals: {
totalPosts: number;
totalComments: number;
totalCategories: number;
lastPublishedAt?: string;
};
}
const formatDateTime = (value?: string | null) => {
if (!value) return '최근 발행 정보 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '최근 발행 정보 없음';
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
export default function AdminDashboardOverview({
overview,
fallbackTotals,
}: AdminDashboardOverviewProps) {
const totalPosts = overview?.totalPosts ?? fallbackTotals.totalPosts;
const totalComments = overview?.totalComments ?? fallbackTotals.totalComments;
const totalCategories = overview?.totalCategories ?? fallbackTotals.totalCategories;
const lastPublishedAt = overview?.lastPublishedAt ?? fallbackTotals.lastPublishedAt;
return (
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard
label="오늘 조회수"
value={overview?.todayViews.value ?? null}
changeRate={overview?.todayViews.changeRate}
helper="백엔드 집계가 준비되면 표시됩니다."
disabled={!overview}
icon={<Eye size={18} />}
/>
<MetricCard
label="최근 7일 조회수"
value={overview?.weekViews.value ?? null}
changeRate={overview?.weekViews.changeRate}
helper="백엔드 집계가 준비되면 표시됩니다."
disabled={!overview}
icon={<Eye size={18} />}
/>
<MetricCard
label="최근 30일 조회수"
value={overview?.monthViews.value ?? null}
changeRate={overview?.monthViews.changeRate}
helper="백엔드 집계가 준비되면 표시됩니다."
disabled={!overview}
icon={<Eye size={18} />}
/>
<MetricCard label="총 게시글" value={totalPosts} icon={<FileText size={18} />} />
<MetricCard label="총 댓글" value={totalComments} icon={<MessageSquareText size={18} />} />
<MetricCard
label="카테고리"
value={totalCategories}
helper={formatDateTime(lastPublishedAt)}
icon={<FolderTree size={18} />}
/>
{overview?.generatedAt && (
<div className="sm:col-span-2 xl:col-span-3">
<p className="inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
<CalendarClock size={14} />
{formatDateTime(overview.generatedAt)}
</p>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,91 @@
import Link from 'next/link';
import { ArrowRight, Flame, TrendingUp } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import { DashboardPostStat } from '@/types';
interface AdminDashboardPostPerformanceProps {
topPosts: DashboardPostStat[];
risingPosts: DashboardPostStat[];
stalePopularPosts: DashboardPostStat[];
isFallback: boolean;
}
function PostStatRow({ post, isFallback }: { post: DashboardPostStat; isFallback: boolean }) {
return (
<Link
href={`/posts/${post.slug}`}
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2">
<StatusBadge>{post.categoryName || '미분류'}</StatusBadge>
<span className="text-xs text-[var(--color-text-subtle)]">
{isFallback ? '누적 조회' : '기간 조회'} {(isFallback ? post.viewCount : post.rangeViewCount).toLocaleString()}
</span>
</div>
<p className="line-clamp-1 text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{post.title}
</p>
</div>
<ArrowRight size={15} className="mt-5 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" />
</Link>
);
}
export default function AdminDashboardPostPerformance({
topPosts,
risingPosts,
stalePopularPosts,
isFallback,
}: AdminDashboardPostPerformanceProps) {
const secondaryPosts = risingPosts.length > 0 ? risingPosts : stalePopularPosts;
return (
<Surface as="section" className="p-5">
<div className="mb-5 flex items-center gap-2">
<Flame size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Flame size={16} />
</div>
{topPosts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{topPosts.slice(0, 5).map((post) => (
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
))}
</div>
) : (
<EmptyState title="인기 글 데이터가 없습니다." className="min-h-40" />
)}
</div>
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<TrendingUp size={16} />
{risingPosts.length > 0 ? '상승 중인 글' : '관리 후보 글'}
</div>
{secondaryPosts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{secondaryPosts.slice(0, 5).map((post) => (
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
))}
</div>
) : (
<EmptyState
title={isFallback ? '통계 API 연결 전' : '관리 후보가 없습니다.'}
description={isFallback ? '상승/방치 기준은 dashboard API 연결 후 표시됩니다.' : undefined}
className="min-h-40"
/>
)}
</div>
</div>
</Surface>
);
}

View File

@@ -0,0 +1,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>
);
}

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

View File

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

View File

@@ -84,17 +84,19 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div <div
className={clsx( className={clsx(
"relative p-4 rounded-xl transition-colors group", "relative p-4 rounded-xl transition-colors group",
comment.isPostAuthor ? "bg-blue-50/50 border border-blue-100" : "bg-white border border-gray-100" comment.isPostAuthor
? "border border-blue-500/15 bg-[var(--color-accent-soft)]"
: "border border-[var(--color-line)] bg-[var(--color-surface)]"
)} )}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={clsx( className={clsx(
"p-1.5 rounded-full flex items-center justify-center", "flex items-center justify-center rounded-full p-1.5",
comment.isPostAuthor ? "bg-blue-100 text-blue-600" : comment.isPostAuthor ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
!isGuestComment ? "bg-green-100 text-green-600" : !isGuestComment ? "bg-green-500/15 text-green-600 dark:text-green-300" :
"bg-gray-100 text-gray-500" "bg-black/[0.05] text-[var(--color-text-muted)] dark:bg-white/10"
)} )}
> >
{comment.isPostAuthor ? <CheckCircle2 size={14} /> : {comment.isPostAuthor ? <CheckCircle2 size={14} /> :
@@ -103,11 +105,11 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
<span className={clsx("text-sm font-bold flex items-center gap-1", comment.isPostAuthor ? "text-blue-700" : "text-gray-700")}> <span className={clsx("flex items-center gap-1 text-sm font-bold", comment.isPostAuthor ? "text-[var(--color-accent)]" : "text-[var(--color-text)]")}>
{comment.author} {comment.author}
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium"></span>} {comment.isPostAuthor && <span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]"></span>}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-[var(--color-text-subtle)]">
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')} {format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
</span> </span>
</div> </div>
@@ -116,7 +118,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => setIsReplying(!isReplying)} onClick={() => setIsReplying(!isReplying)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" className="rounded p-1.5 text-[var(--color-text-subtle)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)]"
title="답글 달기" title="답글 달기"
> >
<MessageSquare size={14} /> <MessageSquare size={14} />
@@ -127,8 +129,8 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
onClick={handleDeleteClick} onClick={handleDeleteClick}
className={clsx( className={clsx(
"p-1.5 rounded transition-colors", "p-1.5 rounded transition-colors",
isDeleting ? "bg-red-50 text-red-600" : isDeleting ? "bg-red-500/10 text-red-600" :
"text-gray-400 hover:text-red-600 hover:bg-red-50" "text-[var(--color-text-subtle)] hover:bg-red-500/10 hover:text-red-600"
)} )}
title="삭제" title="삭제"
> >
@@ -138,19 +140,19 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div> </div>
</div> </div>
<p className="text-gray-800 text-sm whitespace-pre-wrap leading-relaxed pl-1"> <p className="pl-1 text-sm leading-relaxed text-[var(--color-text)] whitespace-pre-wrap">
{comment.content} {comment.content}
</p> </p>
{/* 🎨 비회원 비밀번호 입력창 (인라인) */} {/* 🎨 비회원 비밀번호 입력창 (인라인) */}
{isDeleting && isGuestComment && ( {isDeleting && isGuestComment && (
<div className="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg animate-in fade-in slide-in-from-top-1 duration-200"> <div className="mt-3 flex animate-in items-center gap-2 rounded-lg bg-black/[0.04] p-2 duration-200 fade-in slide-in-from-top-1 dark:bg-white/10">
<input <input
type="password" type="password"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
value={guestPassword} value={guestPassword}
onChange={(e) => setGuestPassword(e.target.value)} onChange={(e) => setGuestPassword(e.target.value)}
className="text-xs px-2 py-1.5 border border-gray-200 rounded focus:outline-none focus:border-blue-500 bg-white" className="rounded border border-[var(--color-line)] bg-[var(--color-control)] px-2 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoFocus autoFocus
/> />
<button <button
@@ -161,7 +163,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</button> </button>
<button <button
onClick={() => { setIsDeleting(false); setGuestPassword(''); }} onClick={() => { setIsDeleting(false); setGuestPassword(''); }}
className="p-1 text-gray-400 hover:bg-gray-200 rounded-full" className="rounded-full p-1 text-[var(--color-text-subtle)] hover:bg-[var(--card-bg)]"
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -170,7 +172,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div> </div>
{isReplying && ( {isReplying && (
<div className="mt-2 pl-4 border-l-2 border-gray-200 ml-4 animate-in fade-in slide-in-from-top-2"> <div className="mt-2 ml-4 animate-in border-l-2 border-[var(--color-line)] pl-4 fade-in slide-in-from-top-2">
<CommentForm <CommentForm
postSlug={postSlug} postSlug={postSlug}
parentId={comment.id} parentId={comment.id}
@@ -181,7 +183,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
)} )}
{comment.children && comment.children.length > 0 && ( {comment.children && comment.children.length > 0 && (
<div className="mt-2 pl-4 md:pl-8 border-l-2 border-gray-100 ml-2 md:ml-4 space-y-3"> <div className="mt-2 ml-2 space-y-3 border-l-2 border-[var(--color-line)] pl-4 md:ml-4 md:pl-8">
{comment.children.map((child) => ( {comment.children.map((child) => (
<CommentItem <CommentItem
key={child.id} key={child.id}

View File

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

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

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

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

View File

@@ -7,30 +7,37 @@ import Image from 'next/image';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { import {
Archive,
ChevronRight, ChevronRight,
ChevronsLeft,
ChevronsRight,
Crown,
FileQuestion, FileQuestion,
Folder, Folder,
FolderOpen, FolderOpen,
Github, Home,
Mail,
Menu,
X,
} from 'lucide-react'; } from 'lucide-react';
import { getCategories } from '@/api/category'; import { getCategories } from '@/api/category';
import { getProfile } from '@/api/profile'; import { getProfile } from '@/api/profile';
import PostSearch from '@/components/post/PostSearch'; import PostSearch from '@/components/post/PostSearch';
import { Category, Profile } from '@/types'; import { Category, Profile } from '@/types';
interface SidebarProps {
isDesktopCollapsed: boolean;
isMobileOpen: boolean;
onDesktopCollapsedChange: (nextValue: boolean) => void;
onMobileOpenChange: (nextValue: boolean) => void;
}
interface CategoryItemProps { interface CategoryItemProps {
category: Category; category: Category;
depth: number; depth: number;
onNavigate: () => void;
pathname: string; pathname: string;
} }
const defaultProfile: Profile = { const defaultProfile: Profile = {
name: 'Dev Park', name: 'Dev Park',
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."', bio: '개발 기록과 실험을 모아두는 공간입니다.',
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix', imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
githubUrl: 'https://github.com', githubUrl: 'https://github.com',
email: 'user@example.com', email: 'user@example.com',
@@ -40,7 +47,7 @@ const sortCategories = (categories: Category[] = []) => {
return [...categories].sort((a, b) => a.id - b.id); return [...categories].sort((a, b) => a.id - b.id);
}; };
function CategoryItem({ category, depth, pathname }: CategoryItemProps) { function CategoryItem({ category, depth, onNavigate, pathname }: CategoryItemProps) {
const sortedChildren = useMemo(() => sortCategories(category.children), [category.children]); const sortedChildren = useMemo(() => sortCategories(category.children), [category.children]);
const hasChildren = sortedChildren.length > 0; const hasChildren = sortedChildren.length > 0;
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`; const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
@@ -64,12 +71,18 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
<div className="mb-1"> <div className="mb-1">
<div <div
className={clsx( className={clsx(
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all', 'flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-all',
isActive ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50', isActive
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)} )}
style={{ marginLeft: `${depth * 10}px` }} style={{ marginLeft: `${depth * 8}px` }}
> >
<Link href={`/category/${category.name}`} className="flex min-w-0 flex-1 items-center gap-2.5"> <Link
href={`/category/${category.name}`}
onClick={onNavigate}
className="flex min-w-0 flex-1 items-center gap-2.5"
>
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />} {isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
<span className="truncate">{category.name}</span> <span className="truncate">{category.name}</span>
</Link> </Link>
@@ -82,24 +95,25 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
event.stopPropagation(); event.stopPropagation();
setIsExpanded((previous) => !previous); setIsExpanded((previous) => !previous);
}} }}
className="ml-1 rounded-full p-1 transition-colors hover:bg-gray-200/50" className="ml-1 rounded-full p-1 transition-colors hover:bg-[var(--card-bg)]"
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`} aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
> >
<ChevronRight <ChevronRight
size={14} size={14}
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')} className={clsx('text-[var(--color-text-subtle)] transition-transform duration-200', isChildrenVisible && 'rotate-90')}
/> />
</button> </button>
)} )}
</div> </div>
{isChildrenVisible && hasChildren && ( {isChildrenVisible && hasChildren && (
<div className="ml-4 border-l-2 border-gray-100"> <div className="ml-4 border-l border-[var(--color-line)]">
{sortedChildren.map((child) => ( {sortedChildren.map((child) => (
<CategoryItem <CategoryItem
key={child.id} key={child.id}
category={child} category={child}
depth={depth + 1} depth={depth + 1}
onNavigate={onNavigate}
pathname={pathname} pathname={pathname}
/> />
))} ))}
@@ -109,9 +123,12 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
); );
} }
function SidebarContent() { function SidebarContent({
const [isOpen, setIsOpen] = useState(true); isDesktopCollapsed,
isMobileOpen,
onDesktopCollapsedChange,
onMobileOpenChange,
}: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -131,43 +148,66 @@ function SidebarContent() {
}); });
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile; const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
const decodedPathname = decodeURIComponent(pathname);
const closeSidebar = () => onMobileOpenChange(false);
const handleSearch = (newKeyword: string) => { const handleSearch = (newKeyword: string) => {
const trimmedKeyword = newKeyword.trim(); const trimmedKeyword = newKeyword.trim();
if (trimmedKeyword) { if (trimmedKeyword) {
router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`); router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
closeSidebar();
return; return;
} }
router.push('/'); router.push('/');
closeSidebar();
}; };
const compactCategoryItems = sortedCategories?.slice(0, 8) ?? [];
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => setIsOpen((previous) => !previous)} aria-label="사이드바 닫기"
className="fixed left-4 top-4 z-50 rounded-full bg-white p-2 shadow-md transition-colors hover:bg-gray-100 md:hidden" onClick={closeSidebar}
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'} className={clsx(
> 'fixed inset-0 z-50 bg-black/35 backdrop-blur-sm transition-opacity md:hidden',
{isOpen ? <X size={20} /> : <Menu size={20} />} isMobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
</button> )}
/>
<aside <aside
className={clsx( className={clsx(
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-gray-100 bg-white transition-all duration-300 ease-in-out', 'fixed left-0 top-0 z-[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',
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0', isDesktopCollapsed ? 'md:w-20' : 'md:w-72',
isMobileOpen ? 'translate-x-0' : '-translate-x-full',
)} )}
> >
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}> <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')}>
<Link href="/" className="block transition-opacity hover:opacity-80"> <button
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-gray-200 shadow-inner ring-4 ring-gray-50"> 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 ? ( {isProfileLoading ? (
<div className="h-full w-full animate-pulse bg-gray-200" /> <div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
) : ( ) : (
<Image <Image
src={displayProfile.imageUrl || defaultProfile.imageUrl!} src={displayProfile.imageUrl || defaultProfile.imageUrl!}
alt="Profile" alt="프로필"
fill fill
sizes="96px" sizes="96px"
className="object-cover" className="object-cover"
@@ -177,28 +217,26 @@ function SidebarContent() {
)} )}
</div> </div>
{isProfileLoading ? ( <div className={clsx(isDesktopCollapsed && 'md:hidden')}>
<div className="flex flex-col items-center space-y-2"> {isProfileLoading ? (
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" /> <div className="flex flex-col items-center space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" /> <div className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
</div> <div className="h-4 w-32 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
) : ( </div>
<> ) : (
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2> <>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-gray-500">{displayProfile.bio}</p> <h2 className="text-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> </Link>
</div> </div>
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2"> <nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}> <div className={clsx('flex h-full flex-col', isDesktopCollapsed && 'md:hidden')}>
<Folder size={24} className="text-gray-400" />
</div>
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
<div className="shrink-0 space-y-1"> <div className="shrink-0 space-y-1">
<div className="mb-6 mt-2 px-1"> <div id="blog-search" className="mb-5 mt-2 px-1">
<PostSearch <PostSearch
onSearch={handleSearch} onSearch={handleSearch}
placeholder="검색..." placeholder="검색..."
@@ -206,25 +244,10 @@ function SidebarContent() {
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4 border-t border-[var(--color-line)]" />
<Link
href="/archive"
className={clsx(
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
pathname === '/archive'
? 'bg-blue-50 font-medium text-blue-600'
: 'text-gray-600 hover:bg-gray-50',
)}
>
<Archive size={16} />
<span>Archives</span>
</Link>
</div>
<div className="mb-4 border-t border-gray-100" /> <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 className="mb-3 flex h-8 items-center justify-between px-4">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Categories</p>
</div> </div>
</div> </div>
@@ -232,7 +255,7 @@ function SidebarContent() {
{!categories && ( {!categories && (
<div className="space-y-2 px-4"> <div className="space-y-2 px-4">
{[1, 2, 3].map((item) => ( {[1, 2, 3].map((item) => (
<div key={item} className="h-8 animate-pulse rounded bg-gray-100" /> <div key={item} className="h-8 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
))} ))}
</div> </div>
)} )}
@@ -243,6 +266,7 @@ function SidebarContent() {
key={category.id} key={category.id}
category={category} category={category}
depth={0} depth={0}
onNavigate={closeSidebar}
pathname={pathname} pathname={pathname}
/> />
))} ))}
@@ -250,11 +274,12 @@ function SidebarContent() {
<div className="mb-1 mt-2"> <div className="mb-1 mt-2">
<Link <Link
href="/category/uncategorized" href="/category/uncategorized"
onClick={closeSidebar}
className={clsx( className={clsx(
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all', 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
pathname === '/category/uncategorized' pathname === '/category/uncategorized'
? 'bg-blue-50 font-medium text-blue-600' ? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-gray-600 hover:bg-gray-50', : 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)} )}
> >
<FileQuestion size={16} /> <FileQuestion size={16} />
@@ -263,30 +288,98 @@ function SidebarContent() {
</div> </div>
</div> </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> </div>
</nav> </nav>
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}> <div
<div className="flex justify-center gap-3"> className={clsx(
<a 'shrink-0 border-t border-[var(--color-line)] bg-[var(--window-titlebar)] p-5',
href={displayProfile.githubUrl || '#'} isDesktopCollapsed && 'md:hidden',
target="_blank" )}
rel="noreferrer" >
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-gray-800 hover:text-white" <p className="text-center text-[10px] font-light text-[var(--color-text-subtle)]">
aria-label="Github" © {new Date().getFullYear()} {displayProfile.name}
>
<Github size={18} />
</a>
<a
href={`mailto:${displayProfile.email}`}
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-blue-500 hover:text-white"
aria-label="Email"
>
<Mail size={18} />
</a>
</div>
<p className="mt-4 text-center text-[10px] font-light text-gray-300">
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
</p> </p>
</div> </div>
</aside> </aside>
@@ -294,10 +387,19 @@ function SidebarContent() {
); );
} }
export default function Sidebar() { export default function Sidebar(props: SidebarProps) {
return ( return (
<Suspense fallback={<div className="h-screen w-72 border-r border-gray-100 bg-white" />}> <Suspense
<SidebarContent /> fallback={(
<div
className={clsx(
'h-screen border-r border-[var(--sidebar-border)] bg-[var(--sidebar-bg)]',
props.isDesktopCollapsed ? 'w-20' : 'w-72',
)}
/>
)}
>
<SidebarContent {...props} />
</Suspense> </Suspense>
); );
} }

View File

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

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

View File

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

View File

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

View File

@@ -1,19 +1,23 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; 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 { useRouter } from 'next/navigation';
import { getPost } from '@/api/posts'; import { getPost } from '@/api/posts';
import { getProfile } from '@/api/profile'; import { getProfile } from '@/api/profile';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
import CommentList from '@/components/comment/CommentList'; import CommentList from '@/components/comment/CommentList';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
import TOC from '@/components/post/TOC'; import TOC from '@/components/post/TOC';
import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import StatusBadge from '@/components/ui/StatusBadge';
import { useRouter } from 'next/navigation'; import Surface from '@/components/ui/Surface';
import Link from 'next/link'; import WindowSurface from '@/components/ui/WindowSurface';
import { Post } from '@/types'; // 타입 임포트 import { Post } from '@/types';
interface PostDetailClientProps { interface PostDetailClientProps {
slug: string; slug: string;
initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수) initialPost: Post;
} }
const getPostErrorInfo = (error: unknown) => { const getPostErrorInfo = (error: unknown) => {
@@ -35,12 +39,10 @@ const getPostErrorInfo = (error: unknown) => {
export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) { export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) {
const router = useRouter(); const router = useRouter();
// 1. 게시글 상세 조회
const { data: post, isLoading: isPostLoading, error } = useQuery({ const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug], queryKey: ['post', slug],
queryFn: () => getPost(slug), queryFn: () => getPost(slug),
enabled: !!slug, enabled: !!slug,
// 🌟 핵심: 서버에서 가져온 데이터를 초기값으로 사용하여 즉시 렌더링 (Hydration)
initialData: initialPost, initialData: initialPost,
}); });
@@ -49,103 +51,143 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
queryFn: getProfile, queryFn: getProfile,
}); });
// 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다.
// initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다.
if (isPostLoading) { if (isPostLoading) {
return ( return (
<div className="flex justify-center items-center h-screen"> <div className="flex h-screen items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={40} /> <Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div> </div>
); );
} }
// 에러 처리
if (error || !post) { if (error || !post) {
const { status: errorStatus, message } = getPostErrorInfo(error); const { status: errorStatus, message } = getPostErrorInfo(error);
const errorMessage = message || '게시글을 찾을 수 없습니다.'; const errorMessage = message || '게시글을 찾을 수 없습니다.';
const isAuthError = errorStatus === 401 || errorStatus === 403; const isAuthError = errorStatus === 401 || errorStatus === 403;
return ( return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center"> <WindowSurface className="mx-auto max-w-[980px]" bodyClassName="px-4 py-20 text-center">
<div className="flex justify-center mb-4"> <div className="mb-4 flex justify-center">
<AlertCircle className="text-gray-300" size={64} /> <AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
</div> </div>
<h2 className="text-2xl font-bold text-gray-800 mb-2"> <h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'} {isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
</h2> </h2>
<p className="text-gray-500 mb-6"> <p className="mb-6 text-[var(--color-text-muted)]">
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage} {isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
</p> </p>
<div className="flex justify-center gap-3"> <button
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors"></button> type="button"
</div> onClick={() => router.push('/')}
</div> className="rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-[var(--card-bg-strong)]"
>
</button>
</WindowSurface>
); );
} }
// 백엔드 데이터 사용 (이전글/다음글)
const prevPost = post.prevPost; const prevPost = post.prevPost;
const nextPost = post.nextPost; const nextPost = post.nextPost;
return ( return (
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12"> <div className="mx-auto min-w-0 max-w-[1120px] px-0 py-3 md:py-6">
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors"> <Link href="/" className="mb-5 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
<ArrowLeft size={18} /> <ArrowLeft size={18} />
<span className="text-sm font-medium"></span> <span></span>
</Link> </Link>
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative"> <div className="relative grid min-w-0 gap-6 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-8">
<main className="min-w-0 space-y-6">
<main className="min-w-0 xl:flex-1"> <Surface as="article" strong className="mx-auto w-full max-w-[820px] px-5 py-7 md:px-10 md:py-10">
<article> <header className="mb-9 border-b border-[var(--color-line)] pb-7">
<header className="mb-10 border-b border-gray-100 pb-8"> <StatusBadge tone="neutral" className="mb-5">
<div className="flex justify-between items-start mb-4"> {post.categoryName || '미분류'}
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full"> </StatusBadge>
<Folder size={14} /> <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]">
<span>{post.categoryName || 'Uncategorized'}</span> {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> </div>
</div> <div className="flex items-center gap-1.5">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1> <Calendar size={16} />
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500"> {new Date(post.createdAt).toLocaleDateString('ko-KR')}
<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> </div>
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString()}</div>
<div className="flex items-center gap-1.5"><Eye size={16} />{post.viewCount} views</div>
</div> </div>
</header> </header>
<div className="prose prose-lg max-w-none prose-headings:font-bold prose-a:text-blue-600 prose-img:rounded-2xl prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 mb-20"> <div className="prose prose-base min-w-0 max-w-none break-words prose-headings:font-bold prose-headings:tracking-normal prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-p:leading-8 prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-li:leading-8 prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 md:prose-lg [overflow-wrap:anywhere]">
<MarkdownRenderer content={post.content || ''} /> <MarkdownRenderer content={post.content || ''} />
</div> </div>
</article> </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">
{prevPost ? ( <nav className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
<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"> {prevPost ? (
<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> <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="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span> <span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
</Link> <ChevronLeft size={16} />
) : <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>}
</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>
) : (
<Surface className="hidden w-full cursor-not-allowed p-5 opacity-60 md:block">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">
<ChevronLeft size={16} />
</span>
</Surface>
)}
{nextPost ? ( {nextPost ? (
<Link href={`/posts/${nextPost.slug}`} className="group flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100"> <Link href={`/posts/${nextPost.slug}`} className="group flex min-w-0 flex-col items-end gap-1 rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-4 shadow-[var(--shadow-card)] transition-colors hover:bg-[var(--card-bg-strong)] md:p-5">
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"> <ChevronRight size={16} /></span> <span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
</Link> <ChevronRight size={16} />
) : <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>} </span>
</nav> <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>
) : (
<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>
<CommentList postSlug={post.slug} /> <WindowSurface title="Comments" showTrafficLights={false} className="mx-auto max-w-[820px]" bodyClassName="p-5 md:p-6">
<CommentList postSlug={post.slug} />
</WindowSurface>
</main> </main>
<aside className="hidden 2xl:block w-[220px] shrink-0"> <aside className="hidden w-[220px] shrink-0 xl:block">
<div className="sticky top-24"> <WindowSurface title="목차" showTrafficLights={false} bodyClassName="p-4" className="sticky top-24 shadow-[var(--shadow-card)]">
<TOC content={post.content || ''} /> <TOC content={post.content || ''} />
</div> </WindowSurface>
</aside> </aside>
</div> </div>
</div> </div>
); );

View File

@@ -1,60 +1,65 @@
import Link from 'next/link'; import Link from 'next/link';
import { Post } from '@/types'; import { ChevronRight, Eye } from 'lucide-react';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import StatusBadge from '@/components/ui/StatusBadge';
import { Post } from '@/types';
interface PostListItemProps { interface PostListItemProps {
post: Post; post: Post;
showViews?: boolean;
} }
export default function PostListItem({ post }: PostListItemProps) { const isNoticePost = (post: Post) => {
// 📢 공지 카테고리 여부 확인 const categoryName = post.categoryName || '';
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice'; return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
};
const formatDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
};
export default function PostListItem({ post, showViews = false }: PostListItemProps) {
const isNotice = isNoticePost(post);
return ( return (
<Link href={`/posts/${post.slug}`} className="block group"> <Link href={`/posts/${post.slug}`} className="group block">
<div className={clsx( <div
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors", className={clsx(
isNotice 'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게 isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-[var(--card-bg)]',
: "border-gray-100 hover:bg-gray-50" )}
)}> >
<div className="flex items-center gap-4 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-3">
{/* 카테고리 라벨 */} <StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
<span className={clsx( {post.categoryName || '미분류'}
"hidden sm:inline-block px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap", </StatusBadge>
isNotice <div className="min-w-0">
? "bg-red-100 text-red-600 font-bold" // 🔴 공지 스타일 강조 <h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
: "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"
)}>
{post.title} {post.title}
</h3> </h3>
<time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time>
</div>
</div> </div>
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap"> <div className="ml-3 flex shrink-0 items-center gap-4 text-sm text-[var(--color-text-subtle)]">
{/* 조회수 */} {showViews && (
<div className="hidden sm:flex items-center gap-1.5" title="조회수"> <div className="hidden items-center gap-1.5 sm:flex" title="조회수">
<Eye size={14} /> <Eye size={14} />
<span className="text-xs">{post.viewCount}</span> <span className="text-xs"> {post.viewCount.toLocaleString()}</span>
</div> </div>
)}
{/* 날짜 */} <ChevronRight size={18} className="transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
<time className="font-light tabular-nums text-xs sm:text-sm">
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
</time>
</div> </div>
</div> </div>
</Link> </Link>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx';
interface EmptyStateProps {
title: string;
description?: string;
icon?: ReactNode;
className?: string;
}
export default function EmptyState({
title,
description,
icon,
className,
}: EmptyStateProps) {
return (
<div
className={clsx(
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--card-border)] bg-[var(--card-bg)] px-5 py-10 text-center shadow-[var(--shadow-card)] backdrop-blur-[18px]',
className,
)}
>
{icon && <div className="mb-3 text-[var(--color-text-subtle)]">{icon}</div>}
<p className="text-sm font-semibold text-[var(--color-text-muted)]">{title}</p>
{description && (
<p className="mt-1 max-w-sm text-xs leading-5 text-[var(--color-text-subtle)]">
{description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx';
import Surface from './Surface';
interface MetricCardProps {
label: string;
value?: number | string | null;
icon?: ReactNode;
helper?: string;
changeRate?: number | null;
disabled?: boolean;
className?: string;
}
const formatValue = (value?: number | string | null) => {
if (value === null || value === undefined) return '통계 API 연결 전';
if (typeof value === 'number') return value.toLocaleString();
return value;
};
export default function MetricCard({
label,
value,
icon,
helper,
changeRate,
disabled = false,
className,
}: MetricCardProps) {
const hasChange = typeof changeRate === 'number';
const isPositive = hasChange && changeRate >= 0;
return (
<Surface className={clsx('p-5', disabled && 'opacity-70', className)}>
<div className="flex items-center justify-between gap-3 text-[var(--color-text-muted)]">
<span className="text-sm font-semibold">{label}</span>
{icon}
</div>
<p
className={clsx(
'mt-4 break-keep text-3xl font-bold tracking-normal text-[var(--color-text)]',
disabled && 'text-base leading-7 text-[var(--color-text-muted)]',
)}
>
{formatValue(value)}
</p>
{(helper || hasChange) && (
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
{hasChange ? `${isPositive ? '+' : ''}${changeRate.toFixed(1)}%` : helper}
</p>
)}
</Surface>
);
}

View File

@@ -0,0 +1,54 @@
import { clsx } from 'clsx';
export interface SegmentedControlOption<T extends string> {
label: string;
value: T;
}
interface SegmentedControlProps<T extends string> {
ariaLabel: string;
options: readonly SegmentedControlOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export default function SegmentedControl<T extends string>({
ariaLabel,
options,
value,
onChange,
className,
}: SegmentedControlProps<T>) {
return (
<div
className={clsx(
'inline-flex h-9 rounded-full border border-[var(--control-border)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-[18px]',
className,
)}
role="group"
aria-label={ariaLabel}
>
{options.map((option) => {
const isSelected = option.value === value;
return (
<button
key={option.value}
type="button"
aria-pressed={isSelected}
onClick={() => onChange(option.value)}
className={clsx(
'min-w-14 rounded-full px-3 text-sm font-semibold transition duration-150',
isSelected
? 'bg-[var(--card-bg-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-[var(--card-bg)] hover:text-[var(--color-text)]',
)}
>
{option.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { clsx } from 'clsx';
import { HTMLAttributes } from 'react';
type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: BadgeTone;
}
const toneClass: Record<BadgeTone, string> = {
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
info: 'border-blue-500/15 bg-[var(--color-accent-soft)] text-[var(--color-accent)]',
success: 'border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
danger: 'border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300',
};
export default function StatusBadge({
tone = 'neutral',
className,
...props
}: StatusBadgeProps) {
return (
<span
className={clsx(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold leading-none',
toneClass[tone],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,32 @@
import { clsx } from 'clsx';
import { HTMLAttributes } from 'react';
type SurfaceElement = 'div' | 'section' | 'article' | 'aside';
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
as?: SurfaceElement;
strong?: boolean;
interactive?: boolean;
}
export default function Surface({
as: Component = 'div',
strong = false,
interactive = false,
className,
...props
}: SurfaceProps) {
return (
<Component
className={clsx(
'rounded-lg border border-[var(--card-border)] backdrop-blur-[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}
/>
);
}

View File

@@ -0,0 +1,70 @@
import { clsx } from 'clsx';
import { HTMLAttributes, ReactNode } from 'react';
type WindowSurfaceElement = 'div' | 'section' | 'article' | 'aside' | 'main';
interface WindowSurfaceProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
as?: WindowSurfaceElement;
title?: ReactNode;
subtitle?: ReactNode;
controls?: ReactNode;
showTrafficLights?: boolean;
bodyClassName?: string;
}
export default function WindowSurface({
as: Component = 'section',
title,
subtitle,
controls,
showTrafficLights = true,
className,
bodyClassName,
children,
...props
}: WindowSurfaceProps) {
const hasTitlebar = Boolean(title || subtitle || controls || showTrafficLights);
return (
<Component
className={clsx(
'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
View File

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

View File

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

View File

@@ -19,6 +19,7 @@ export interface Post {
categoryName: string; categoryName: string;
viewCount: number; viewCount: number;
createdAt: string; createdAt: string;
updatedAt?: string | null;
content?: string; content?: string;
tags: string[]; tags: string[];
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가 // 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
@@ -46,6 +47,19 @@ export interface PostSaveRequest {
tags: string[]; 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. 로그인 응답 // 4. 로그인 응답
export interface AuthResponse { export interface AuthResponse {
grantType: string; grantType: string;
@@ -157,3 +171,68 @@ export interface AdminCommentListResponse extends PageMeta {
content: AdminComment[]; content: AdminComment[];
page?: PageMeta; page?: PageMeta;
} }
export type DashboardRange = '7d' | '30d' | '90d';
export interface DashboardMetric {
value: number;
previousValue?: number;
changeRate?: number;
}
export interface DashboardOverview {
todayViews: DashboardMetric;
weekViews: DashboardMetric;
monthViews: DashboardMetric;
totalPosts: number;
totalComments: number;
totalCategories: number;
lastPublishedAt?: string | null;
generatedAt: string;
}
export interface DashboardTrafficPoint {
date: string;
views: number;
}
export interface DashboardPostStat {
id: number;
title: string;
slug: string;
categoryName: string;
viewCount: number;
rangeViewCount: number;
commentCount?: number;
createdAt: string;
updatedAt?: string | null;
}
export interface DashboardCategoryStat {
id: number;
name: string;
parentId?: number | null;
postCount: number;
viewCount: number;
recentViewCount: number;
lastPublishedAt?: string | null;
childrenCount: number;
}
export interface DashboardActionItems {
unansweredComments: number;
uncategorizedPosts: number;
stalePopularPosts: number;
}
export interface AdminDashboardResponse {
overview: DashboardOverview;
traffic: DashboardTrafficPoint[];
topPosts: DashboardPostStat[];
risingPosts: DashboardPostStat[];
stalePopularPosts: DashboardPostStat[];
recentPosts: Post[];
recentComments: AdminComment[];
categoryStats: DashboardCategoryStat[];
actionItems: DashboardActionItems;
}

View File

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

View File

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