.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m9s

This commit is contained in:
박원엽
2026-05-29 10:29:46 +09:00
parent 7f9bc1f83b
commit 7535fb6ac6
16 changed files with 385 additions and 1060 deletions

6
LOG.md
View File

@@ -5,6 +5,8 @@
- 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.
- 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: 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`. The in-app browser screenshot command timed out, so verification used DOM and computed-style checks.
- Recommended next task: review the remaining older Korean strings in admin/editor/sidebar flows and normalize any mojibake that predates this redesign.
- 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.

View File

@@ -1,814 +0,0 @@
# Apple 스타일 프론트엔드 리뉴얼 개발 문서
작성일: 2026-05-28
대상 저장소: `blog-frontend`
## 1. 목표
WYPark Blog의 공개 화면과 관리자 화면을 Apple의 macOS/iOS에서 느껴지는 정돈된 UI 방향으로 개선한다. 단순히 둥근 카드와 흐림 효과를 추가하는 것이 아니라, 콘텐츠를 읽는 화면은 조용하고 고급스럽게, 관리자 화면은 밀도 있는 운영 도구처럼 설계한다.
이번 문서는 프론트엔드를 먼저 개발할 수 있도록 백엔드 예정 엔드포인트까지 미리 정의한다. 백엔드 API가 아직 없더라도 프론트는 타입, API 래퍼, 로딩/빈 상태, 임시 fallback을 먼저 구현할 수 있어야 한다.
중요한 원칙:
- 공개 홈 화면에는 하루 조회수, 일주일 조회수, 한달 조회수 같은 운영 집계를 노출하지 않는다.
- 조회수 집계와 콘텐츠 성과 모니터링은 `/admin` 대시보드에서만 보여준다.
- 공개 화면은 방문자가 글을 찾고 읽는 경험에 집중한다.
- 관리자 화면은 운영자가 오늘 확인할 일과 콘텐츠 성과를 빠르게 보는 경험에 집중한다.
## 2. 현재 화면 진단
실제 배포 사이트 `https://blog.wypark.me` 기준으로 확인한 내용이다.
### 장점
- 왼쪽 고정 사이드바, 홈 카드 그리드, 글 상세 TOC, 아카이브 타임라인, 카테고리 카드/리스트 전환 등 기본 정보 구조가 이미 갖춰져 있다.
- 글 상세 페이지는 이미지, 목차, 이전/다음 글, 댓글까지 블로그 핵심 기능을 포함한다.
- 관리자 라우트가 `/admin`, `/admin/posts`, `/admin/comments`, `/admin/categories`, `/admin/profile`로 분리되어 있어 확장하기 좋다.
### 개선이 필요한 점
- 전체 UI가 매우 옅고 넓게 펼쳐져 콘텐츠의 중심감이 약하다.
- 홈 카드들이 비슷한 무게로 배치되어 첫 방문자가 무엇을 먼저 봐야 할지 흐릿하다.
- `Read more`, `views`, `ON THIS PAGE`, `Archives`, `CATEGORIES`처럼 영어 UI가 한국어 콘텐츠와 섞여 있다.
- 글 상세 본문 줄 길이가 길어 장문 읽기에서 시선 이동이 커진다.
- 관리자 화면은 기본 통계와 최근 목록은 있지만, 운영자가 매일 들어와 확인할 만한 지표와 할 일이 부족하다.
## 3. 디자인 방향
### 3.1 Apple 스타일 해석
이 프로젝트에서의 Apple 스타일은 다음 키워드로 해석한다.
- 선명한 위계: 제목, 메타, 본문, 보조 설명의 크기와 색을 명확히 구분한다.
- 부드러운 재질감: 흰색 또는 어두운 반투명 surface, 얇은 border, 절제된 shadow, `backdrop-blur`를 사용한다.
- 정교한 여백: 큰 빈 공간이 아니라 목적 있는 여백을 둔다.
- 조용한 색: 기본은 neutral 계열, 액션과 현재 상태에만 blue tint를 사용한다.
- 익숙한 컨트롤: segmented control, icon button, toolbar, list row, sidebar, sheet, toast 같은 OS 친화 패턴을 사용한다.
- 가벼운 모션: hover, pressed, page transition은 120-180ms 수준으로 짧게 처리한다.
과하게 피해야 할 것:
- 큰 마케팅형 hero
- 장식용 gradient blob
- 과한 glassmorphism
- 카드 안의 카드 중첩
- 모든 요소가 파란색으로 보이는 단색 UI
- 공개 블로그에 운영 통계 노출
### 3.2 디자인 토큰
Tailwind CSS 4를 유지하되, 반복되는 스타일은 작은 컴포넌트와 CSS 변수로 정리한다.
권장 전역 토큰:
```css
:root {
--color-page: #f5f5f7;
--color-surface: rgba(255, 255, 255, 0.78);
--color-surface-strong: #ffffff;
--color-line: rgba(0, 0, 0, 0.08);
--color-text: #1d1d1f;
--color-text-muted: #6e6e73;
--color-text-subtle: #86868b;
--color-accent: #007aff;
--color-accent-soft: rgba(0, 122, 255, 0.1);
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.08);
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.06);
}
```
다크 모드는 나중에 확장하더라도 토큰 구조는 미리 다크 대응이 가능하게 둔다.
```css
@media (prefers-color-scheme: dark) {
:root {
--color-page: #0b0b0c;
--color-surface: rgba(28, 28, 30, 0.72);
--color-surface-strong: #1c1c1e;
--color-line: rgba(255, 255, 255, 0.1);
--color-text: #f5f5f7;
--color-text-muted: #a1a1a6;
--color-text-subtle: #8e8e93;
--color-accent: #0a84ff;
--color-accent-soft: rgba(10, 132, 255, 0.16);
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.36);
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.28);
}
}
```
권장 Tailwind 패턴:
```tsx
const surfaceClass =
'border border-black/5 bg-white/75 shadow-[0_18px_50px_rgba(0,0,0,0.08)] backdrop-blur-xl';
const controlClass =
'inline-flex h-9 items-center justify-center rounded-full border border-black/10 bg-white/70 px-3 text-sm font-medium text-gray-700 transition hover:bg-white';
```
### 3.3 타이포그래피
폰트는 별도 웹폰트보다 시스템 폰트를 우선한다.
권장 font-family:
```css
font-family:
-apple-system,
BlinkMacSystemFont,
"SF Pro Display",
"SF Pro Text",
"Apple SD Gothic Neo",
"Noto Sans KR",
system-ui,
sans-serif;
```
본문 기준:
- 글 상세 본문 최대 폭: 760-820px
- 글 상세 `h1`: 40-48px desktop, 30-34px mobile
- 글 상세 본문: 16-17px, line-height 1.8 전후
- 카드 제목: 17-20px
- 메타 텍스트: 12-13px
- 버튼 텍스트: 13-14px, font-semibold
한국어 문장에서는 letter-spacing을 음수로 줄이지 않는다.
## 4. 공개 홈 화면 개선안
홈 화면은 풍성해져야 하지만 운영 통계는 숨긴다. 방문자에게 필요한 것은 블로그의 주제, 최신 글, 대표 글, 카테고리 탐색, 아카이브 진입이다.
### 4.1 홈 화면 목표
- 블로그 주인이 어떤 글을 쓰는지 첫 화면에서 알 수 있게 한다.
- 최신 글과 대표 글을 분명하게 구분한다.
- 공지는 작지만 놓치지 않게 만든다.
- 카테고리와 아카이브 탐색을 쉽게 한다.
- 조회수 집계, 운영 지표, 일간/주간/월간 트래픽은 공개 홈에 노출하지 않는다.
### 4.2 추천 레이아웃
```text
/
├─ Intro band
│ ├─ 프로필 요약
│ ├─ 블로그 한 줄 소개
│ └─ 검색 CTA
├─ Notice strip
│ └─ 최신 공지 1-3개
├─ Featured + Latest
│ ├─ 대표 글 1개
│ └─ 최신 글 compact list 5-7개
├─ Category shelves
│ ├─ 개발 공부
│ ├─ 잡담
│ └─ 공지
├─ Popular reading
│ └─ 인기 글 목록, 단 공개 화면에서는 누적 조회수 숫자 노출은 선택사항
└─ Archive preview
└─ 최근 월별 글 흐름과 아카이브 링크
```
### 4.3 홈 섹션 상세
#### Intro band
역할:
- 현재 사이드바 프로필만으로는 홈 중앙의 첫인상이 약하므로, 콘텐츠 영역 상단에도 블로그 정체성을 짧게 보여준다.
구성:
- `박원엽의 개발 기록`
- 짧은 소개 문장: `백엔드, 네트워크, 운영 경험을 정리하는 개인 기술 블로그`
- 검색 입력 또는 `전체 글 둘러보기` 버튼
- 최신 업데이트 날짜
주의:
- hero처럼 과하게 크게 만들지 않는다.
- 첫 viewport에 최신 글 일부가 보이도록 높이를 제한한다.
#### Notice strip
역할:
- 공지를 상단에 노출하되 홈 전체를 차지하지 않게 한다.
구성:
- `공지` badge
- 공지 제목
- 날짜
- 우측 chevron
스타일:
- 연한 red tint 또는 system pink tint
- 얇은 border
- hover 시 surface 강조
#### Featured post
역할:
- 최신 글 중 첫 번째 또는 운영자가 지정한 대표 글을 강조한다.
백엔드에 대표 글 개념이 아직 없다면 1차는 최신 글 첫 번째를 사용한다.
구성:
- 카테고리 badge
- 제목
- 2-3줄 요약
- 날짜
- `읽기` 버튼
향후 백엔드 확장:
- `featured` boolean 또는 `pinnedRank` 필드 추가 가능
- 이 문서의 MVP에서는 필수 아님
#### Latest list
역할:
- 현재 카드 3개보다 더 많은 최신 글을 빠르게 훑게 한다.
구성:
- 최신 글 6-8개
- 각 row: 제목, 카테고리, 날짜, 짧은 요약 또는 없음
스타일:
- macOS list row처럼 한 줄 또는 두 줄 중심
- 각 row는 얇은 divider
- hover 시 `bg-black/[0.03]` 또는 다크 대응 surface
#### Category shelves
역할:
- 카테고리 트리가 사이드바에만 있어 중앙 콘텐츠에서는 탐색 힌트가 약하다.
구성:
- 주요 카테고리 3-6개
- 카테고리명
- 최근 글 2-3개
- 전체 보기 링크
데이터:
- 1차는 `getCategories()`와 카테고리별 `getPostsByCategory(categoryName, 0, 3)` 사용
- 너무 많은 요청이 부담되면 상위 카테고리 3개만 요청
- 백엔드가 나중에 `GET /api/posts/grouped-by-category`를 제공하면 교체 가능
#### Archive preview
역할:
- 글이 100개 이상 있는 블로그의 자산을 보여준다.
구성:
- 최근 12개월의 월별 발행 수
- `아카이브에서 102개 글 보기`
데이터:
- 1차는 `/archive` 페이지로 유도만 한다.
- 월별 발행 수는 백엔드 대시보드 API가 아니라 공개 홈용 별도 API가 필요할 때만 추가한다.
- 공개 통계에는 조회수 집계를 포함하지 않는다.
### 4.4 공개 홈에서 사용 가능한 기존 API
```http
GET /api/posts?page=0&size=8&sort=createdAt,desc
GET /api/posts?page=0&size=5&sort=viewCount,desc
GET /api/posts?page=0&size=3&category=&sort=createdAt,desc
GET /api/posts?page=0&size=3&category={categoryName}&sort=createdAt,desc
GET /api/categories
GET /api/profile
```
주의:
- `sort=viewCount,desc`로 인기 글은 만들 수 있지만, 공개 홈에서는 `조회수 123`처럼 숫자를 강조하지 않는다.
- 공개 홈에 `todayViews`, `weekViews`, `monthViews`를 표시하지 않는다.
## 5. 관리자 대시보드 개선안
관리자 대시보드는 `/admin`에 위치한다. 공개 블로그와 달리 정보 밀도를 높이고 운영 행동을 바로 이어갈 수 있게 한다.
### 5.1 관리자 대시보드 목표
- 오늘 블로그가 어떻게 움직이는지 확인한다.
- 최근 7일, 30일 기준으로 트래픽 흐름을 본다.
- 어떤 글을 관리해야 하는지 알려준다.
- 댓글과 카테고리 상태를 빠르게 확인한다.
- 새 글 작성, 댓글 관리, 카테고리 정리로 바로 이동한다.
### 5.2 추천 레이아웃
```text
/admin
├─ Dashboard header
│ ├─ 오늘 날짜
│ ├─ 마지막 집계 시각
│ └─ 새 글 작성 / 게시글 관리 quick actions
├─ KPI cards
│ ├─ 오늘 조회수
│ ├─ 최근 7일 조회수
│ ├─ 최근 30일 조회수
│ └─ 총 게시글 / 총 댓글 / 카테고리
├─ Traffic trend
│ └─ 최근 30일 일별 조회수 chart
├─ Content performance
│ ├─ 최근 7일 인기 글
│ ├─ 상승 중인 글
│ └─ 오래 업데이트 안 된 인기 글
├─ Action center
│ ├─ 답변 필요한 댓글
│ ├─ 미분류 글
│ └─ 오래 방치된 글
├─ Recent activity
│ ├─ 최근 글
│ └─ 최근 댓글
└─ Category health
└─ 카테고리별 글 수, 조회수, 최근 발행일
```
### 5.3 KPI 카드
필수 KPI:
- 오늘 조회수
- 최근 7일 조회수
- 최근 30일 조회수
- 총 게시글
- 총 댓글
- 카테고리 수
표현:
- 큰 숫자
- 전 기간 대비 변화율
- 작은 sparkline 또는 방향 badge
예:
```text
오늘 조회수
311
어제보다 +12.4%
```
API가 준비되지 않았을 때:
- 오늘/7일/30일 조회수 카드는 skeleton 또는 `통계 API 연결 전` 상태를 보여준다.
- 총 게시글/댓글/카테고리는 기존 API로 fallback한다.
### 5.4 Traffic trend
차트는 신규 dependency 없이 시작한다.
권장:
- `svg` 또는 CSS grid bar chart
- 최근 30일 기본
- segmented control: `7일`, `30일`, `90일`
데이터:
```ts
type AdminDashboardTrafficPoint = {
date: string; // YYYY-MM-DD, Asia/Seoul 기준
views: number;
};
```
### 5.5 Content performance
위젯:
- 최근 7일 인기 글
- 최근 30일 인기 글
- 상승 중인 글
- 오래 업데이트 안 된 인기 글
`오래 업데이트 안 된 인기 글` 기준:
- 최근 30일 조회수가 일정 기준 이상
- `updatedAt` 또는 `createdAt`이 180일 이상 이전
백엔드가 `updatedAt`을 제공하지 않으면 `createdAt` 기준으로 1차 구현한다.
### 5.6 Action center
운영자가 해야 할 일을 모아 보여준다.
권장 항목:
- 답변 필요한 댓글
- 미분류 글
- 카테고리 없는 글
- 오래 방치된 인기 글
- 최근 댓글 중 삭제 검토가 필요한 글, 추후 신고 기능이 생길 경우
현재 댓글에 답변 상태가 없다면 1차 기준:
- 부모 댓글이고 작성자가 글 작성자가 아니며, 자식 댓글 중 관리자 댓글이 없는 댓글
백엔드가 이 기준을 계산해서 `actionItems.unansweredComments`로 내려준다.
### 5.7 Category health
표시 항목:
- 카테고리명
- 글 수
- 최근 30일 조회수
- 최근 발행일
- 하위 카테고리 수
목적:
- 비어 있는 카테고리 찾기
- 너무 많은 글이 몰린 카테고리 찾기
- 오랫동안 업데이트되지 않은 카테고리 찾기
## 6. 프론트 API 계약
백엔드가 나중에 구현할 관리자 대시보드 API를 프론트에서 먼저 타입으로 정의한다.
### 6.1 신규 파일
```text
src/api/dashboard.ts
src/components/admin/dashboard/AdminDashboardOverview.tsx
src/components/admin/dashboard/AdminDashboardTrafficChart.tsx
src/components/admin/dashboard/AdminDashboardActionCenter.tsx
src/components/admin/dashboard/AdminDashboardPostPerformance.tsx
src/components/admin/dashboard/AdminDashboardCategoryHealth.tsx
```
### 6.2 신규 타입
`src/types/index.ts`에 추가한다.
```ts
export type DashboardRange = '7d' | '30d' | '90d';
export interface DashboardMetric {
value: number;
previousValue?: number;
changeRate?: number;
}
export interface DashboardOverview {
todayViews: DashboardMetric;
weekViews: DashboardMetric;
monthViews: DashboardMetric;
totalPosts: number;
totalComments: number;
totalCategories: number;
lastPublishedAt?: string | null;
generatedAt: string;
}
export interface DashboardTrafficPoint {
date: string;
views: number;
}
export interface DashboardPostStat {
id: number;
title: string;
slug: string;
categoryName: string;
viewCount: number;
rangeViewCount: number;
commentCount?: number;
createdAt: string;
updatedAt?: string | null;
}
export interface DashboardCategoryStat {
id: number;
name: string;
parentId?: number | null;
postCount: number;
viewCount: number;
recentViewCount: number;
lastPublishedAt?: string | null;
childrenCount: number;
}
export interface DashboardActionItems {
unansweredComments: number;
uncategorizedPosts: number;
stalePopularPosts: number;
}
export interface AdminDashboardResponse {
overview: DashboardOverview;
traffic: DashboardTrafficPoint[];
topPosts: DashboardPostStat[];
risingPosts: DashboardPostStat[];
stalePopularPosts: DashboardPostStat[];
recentPosts: Post[];
recentComments: AdminComment[];
categoryStats: DashboardCategoryStat[];
actionItems: DashboardActionItems;
}
```
### 6.3 신규 API 래퍼
```ts
import { http } from './http';
import { AdminDashboardResponse, ApiResponse, DashboardRange } from '@/types';
export const getAdminDashboard = async (
range: DashboardRange = '30d',
timezone = 'Asia/Seoul',
) => {
const response = await http.get<ApiResponse<AdminDashboardResponse>>('/api/admin/dashboard', {
params: { range, timezone },
});
return response.data.data;
};
```
### 6.4 신규 관리자 엔드포인트
프론트에서 먼저 정의하고 백엔드가 이후 맞춘다.
```http
GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul
Authorization: Bearer {accessToken}
```
응답:
```json
{
"code": "SUCCESS",
"message": "OK",
"data": {
"overview": {
"todayViews": { "value": 311, "previousValue": 277, "changeRate": 12.27 },
"weekViews": { "value": 1830, "previousValue": 1504, "changeRate": 21.68 },
"monthViews": { "value": 7124, "previousValue": 6302, "changeRate": 13.04 },
"totalPosts": 102,
"totalComments": 18,
"totalCategories": 14,
"lastPublishedAt": "2026-01-02T12:04:00+09:00",
"generatedAt": "2026-05-28T20:00:00+09:00"
},
"traffic": [
{ "date": "2026-05-22", "views": 210 },
{ "date": "2026-05-23", "views": 245 }
],
"topPosts": [
{
"id": 1,
"title": "RTR (Refresh Token Rotation)",
"slug": "rtr-(refresh-token-rotation)",
"categoryName": "Network",
"viewCount": 126,
"rangeViewCount": 44,
"commentCount": 0,
"createdAt": "2026-01-02T12:04:00+09:00",
"updatedAt": null
}
],
"risingPosts": [],
"stalePopularPosts": [],
"recentPosts": [],
"recentComments": [],
"categoryStats": [],
"actionItems": {
"unansweredComments": 0,
"uncategorizedPosts": 0,
"stalePopularPosts": 0
}
}
}
```
### 6.5 백엔드 준비 전 fallback
프론트를 먼저 개발할 때는 다음 순서로 처리한다.
1. `getAdminDashboard()`를 호출한다.
2. 404, 403, 네트워크 오류가 발생하면 기존 API 기반 fallback을 사용한다.
3. fallback으로 채울 수 없는 통계는 `null` 또는 비활성 카드로 표시한다.
Fallback으로 사용 가능한 기존 API:
```http
GET /api/posts?page=0&size=5&sort=createdAt,desc
GET /api/posts?page=0&size=5&sort=viewCount,desc
GET /api/categories
GET /api/admin/comments?page=0&size=5
```
Fallback UI 문구:
- `통계 API 연결 전`
- `백엔드 집계가 준비되면 표시됩니다.`
프론트 구현 시 주의:
- fallback은 개발 편의용이며, 관리자 통계의 최종 데이터는 반드시 `/api/admin/dashboard`에서 받는다.
- 공개 홈에서는 이 fallback 통계도 사용하지 않는다.
## 7. 화면별 구현 계획
### 7.1 공통 UI 컴포넌트
새로 만들거나 정리할 컴포넌트:
```text
src/components/ui/Surface.tsx
src/components/ui/MetricCard.tsx
src/components/ui/SegmentedControl.tsx
src/components/ui/IconButton.tsx
src/components/ui/StatusBadge.tsx
src/components/ui/EmptyState.tsx
```
원칙:
- 기존 로컬 패턴을 먼저 따른다.
- 너무 범용적인 디자인 시스템으로 크게 만들지 않는다.
- 관리자 대시보드와 공개 화면에서 반복되는 surface, badge, segmented control 정도만 추출한다.
### 7.2 홈 화면 파일
대상:
```text
src/app/page.tsx
src/components/post/PostCard.tsx
src/components/post/PostListItem.tsx
src/components/layout/Sidebar.tsx
src/components/layout/TopHeader.tsx
```
작업:
- 홈 상단 intro band 추가
- 공지 strip 재정리
- 최신 글 대표 카드 1개 + compact list 구성
- 카테고리 shelf 추가
- 영어 UI를 한국어로 통일
- 공개 홈에서 운영 집계 제거
- 카드 border, shadow, radius를 Apple style token에 맞게 조정
### 7.3 글 상세 화면 파일
대상:
```text
src/app/posts/[slug]/page.tsx
src/components/post/PostDetailClient.tsx
src/components/post/MarkdownRenderer.tsx
src/components/post/TOC.tsx
```
작업:
- 본문 max-width를 760-820px로 제한
- TOC active 상태를 선명하게 표시
- `ON THIS PAGE``목차`로 변경
- `views``조회`로 변경
- 이미지 caption과 본문 이미지 간격 정리
- code block, blockquote, table을 Apple style surface에 맞게 조정
### 7.4 관리자 대시보드 파일
대상:
```text
src/app/admin/page.tsx
src/components/admin/AdminRouteShell.tsx
src/api/dashboard.ts
src/types/index.ts
```
작업:
- 기존 `/admin` 위젯을 새 dashboard layout으로 재구성
- KPI cards 추가
- traffic chart 추가
- action center 추가
- content performance 추가
- category health 추가
- `range` segmented control 추가
- `/api/admin/dashboard` 연결
- API 미구현 시 fallback 표시
### 7.5 관리자 shell
현재 `AdminRouteShell`은 상단 pill nav 형태다. 대시보드가 풍성해지면 다음처럼 조정한다.
- desktop: 상단 toolbar + compact nav
- mobile: horizontal scroll nav 유지
- active 상태를 `bg-gray-950 text-white`보다 Apple style selected pill로 변경
- 관리자 화면 전체 max-width를 1180-1280px로 확장
## 8. 단계별 개발 순서
### Phase 1: 디자인 토큰과 공개 UI 정리
1. `globals.css`에 color/token 기반 style 추가
2. 공통 `Surface`, `StatusBadge`, `SegmentedControl` 구현
3. 홈 영어 UI 한국어화
4. 홈 intro, notice, featured/latest 구조 구현
5. 글 상세 본문 폭과 TOC 스타일 개선
검증:
```bash
npm run lint
npm run build
```
가능하면 브라우저로 확인:
- `/`
- `/archive`
- `/category/잡담`
- `/posts/rtr-(refresh-token-rotation)`
### Phase 2: 관리자 대시보드 프론트 골격
1. `src/types/index.ts`에 dashboard 타입 추가
2. `src/api/dashboard.ts` 추가
3. `/admin`을 dashboard section 구조로 분리
4. fallback 데이터 연결
5. 통계 API 미구현 상태 UI 구현
검증:
```bash
npm run lint
npm run build
```
확인:
- 로그아웃 상태에서 `/admin``/login?redirect=/admin`으로 이동
- 관리자 상태에서 `/admin` dashboard가 보임
- `/api/admin/dashboard`가 404여도 화면이 깨지지 않음
### Phase 3: 백엔드 통계 API 연결
1. 백엔드에서 `/api/admin/dashboard` 구현
2. 프론트 fallback 대신 실제 데이터 표시
3. range segmented control이 `7d`, `30d`, `90d`를 전환
4. top/rising/stale posts 데이터 표시
확인:
- 오늘 조회수, 최근 7일, 최근 30일이 관리자에게만 보임
- 공개 홈과 글 상세에는 일간/주간/월간 집계가 보이지 않음
### Phase 4: 다듬기
1. 다크 모드 대비
2. reduced motion 대응
3. skeleton UI 정리
4. 모바일 레이아웃 확인
5. 기존 lint baseline 정리
## 9. 접근성과 UX 기준
- icon-only button에는 `aria-label`을 반드시 둔다.
- segmented control은 현재 선택 값을 스크린리더가 알 수 있게 한다.
- 색상만으로 상태를 전달하지 않는다.
- hover만 있는 UI는 mobile에서 접근 불가능하므로 click/tap 상태를 제공한다.
- 관리자 삭제/로그아웃/권한 변경 같은 위험 행동은 기존 확인 패턴을 유지한다.
- 운영 통계는 관리자 권한 확인 후 렌더링한다.
## 10. 완료 기준
프론트 리뉴얼 1차 완료 기준:
- 홈 화면이 공개 통계 없이도 풍성하게 보인다.
- 글 상세 본문 폭과 TOC가 읽기 좋은 형태로 정리된다.
- 영어 UI가 한국어로 통일된다.
- `/admin`에 오늘/7일/30일 조회수 카드가 들어갈 자리가 생긴다.
- `/api/admin/dashboard` 미구현 상태에서도 관리자 대시보드가 깨지지 않는다.
- 백엔드 구현 후 같은 타입으로 바로 연결할 수 있다.
- `npm run build`가 통과한다.

View File

@@ -57,7 +57,7 @@ export default function ArchivePage() {
}
return (
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<WindowSurface
title="Archive"
subtitle={`${totalPosts.toLocaleString()} posts`}

View File

@@ -5,17 +5,18 @@ import { useQuery } from '@tanstack/react-query';
import { clsx } from 'clsx';
import { ChevronLeft, ChevronRight, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
import { getPostsByCategory } from '@/api/posts';
import EmptyState from '@/components/ui/EmptyState';
import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem';
import PostSearch from '@/components/post/PostSearch';
import EmptyState from '@/components/ui/EmptyState';
import Surface from '@/components/ui/Surface';
import WindowSurface from '@/components/ui/WindowSurface';
const PAGE_SIZE = 10;
const isNoticeCategoryName = (categoryName: string) => {
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
const normalizedName = categoryName.toLowerCase();
return categoryName === '공지' || normalizedName === 'notice' || normalizedName === 'announcement';
};
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
@@ -62,10 +63,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
if (error) {
return (
<WindowSurface className="mx-auto max-w-3xl" bodyClassName="px-5 py-10 text-center 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-[var(--color-text-subtle)]"> .</span>
<span className="text-sm text-[var(--color-text-subtle)]"> .</span>
</WindowSurface>
);
}
@@ -84,10 +85,9 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
};
return (
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<WindowSurface
title="Finder"
subtitle={`${apiCategoryName} posts`}
title={apiCategoryName}
bodyClassName="p-5 md:p-6"
>
<div className="mb-8 flex flex-col justify-between gap-4 border-b border-[var(--color-line)] pb-5 md:flex-row md:items-center">
@@ -99,7 +99,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
<div className="flex w-full items-center gap-3 md:w-auto">
<PostSearch
onSearch={handleSearch}
placeholder={`${apiCategoryName} 검색`}
placeholder={`${apiCategoryName} 검색`}
className="w-full md:w-64"
/>
@@ -113,8 +113,8 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
? 'bg-[var(--window-bg-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
title="카드 보기"
aria-label="카드 보기"
title="카드 보기"
aria-label="카드 보기"
aria-pressed={activeViewMode === 'grid'}
>
<LayoutGrid size={18} />
@@ -128,8 +128,8 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
? 'bg-[var(--window-bg-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
title="리스트 보기"
aria-label="리스트 보기"
title="리스트 보기"
aria-label="리스트 보기"
aria-pressed={activeViewMode === 'list'}
>
<List size={18} />
@@ -155,7 +155,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
) : (
<>
{activeViewMode === 'grid' ? (
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}

View File

@@ -3,9 +3,7 @@ import localFont from 'next/font/local';
import Script from 'next/script';
import './globals.css';
import Providers from './providers';
import DesktopDock from '@/components/layout/DesktopDock';
import DesktopMenuBar from '@/components/layout/DesktopMenuBar';
import Sidebar from '@/components/layout/Sidebar';
import DesktopShell from '@/components/layout/DesktopShell';
import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
const pretendard = localFont({
@@ -50,17 +48,7 @@ export default function RootLayout({
</Script>
<Providers>
<div className="min-h-screen">
<Sidebar />
<DesktopMenuBar />
<DesktopDock />
<main className="relative w-full flex-1 transition-all duration-300 md:ml-72">
<div className="mx-auto w-full px-4 pb-28 pt-20 md:px-8 md:pb-32 md:pt-24">
{children}
</div>
</main>
</div>
<DesktopShell>{children}</DesktopShell>
</Providers>
</body>
</html>

View File

@@ -261,7 +261,7 @@ function HomeContent() {
if (keyword) {
return (
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<SearchResults keyword={keyword} data={searchData} isLoading={isSearchLoading} />
</main>
);
@@ -276,7 +276,7 @@ function HomeContent() {
}
return (
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
<main className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<h1 className="sr-only">WYPark Blog</h1>
<WindowSurface
title="WYPark Desktop"

View File

@@ -57,7 +57,7 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
}
return (
<div className="mx-auto max-w-7xl 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">
<WindowSurface title="Admin Console" subtitle="Operations toolbar" bodyClassName="p-2">
<nav
className="flex gap-2 overflow-x-auto rounded-lg bg-[var(--color-control)] p-1 backdrop-blur-xl"

View File

@@ -101,7 +101,7 @@ function PageHeader() {
function LoadingState() {
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
<main className="mx-auto flex w-full flex-col gap-6 px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<PageHeader />
<section className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">
<WindowSurface title="Board" bodyClassName="p-3 md:p-5">
@@ -129,7 +129,7 @@ function LoadingState() {
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
<main className="mx-auto flex w-full flex-col gap-6 px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<PageHeader />
<WindowSurface title="Puzzle" bodyClassName="flex min-h-80 flex-col items-center justify-center p-8 text-center">
<AlertCircle className="mb-3 text-red-500" size={30} />
@@ -263,7 +263,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
};
return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
<main className="mx-auto flex w-full flex-col gap-6 px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<PageHeader />
<section className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">

View File

@@ -1,37 +1,54 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { clsx } from 'clsx';
import { Archive, Crown, Home, LogIn, PenLine, Settings } from 'lucide-react';
import { Archive, Crown, Home, LogIn, LogOut, PenLine, Settings, UserPlus } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
interface DesktopDockProps {
isSidebarCollapsed: boolean;
}
type DockItem = {
href: string;
key: string;
href?: string;
label: string;
icon: typeof Home;
isActive: (pathname: string) => boolean;
isActive?: (pathname: string) => boolean;
onClick?: () => void;
};
export default function DesktopDock() {
export default function DesktopDock({ isSidebarCollapsed }: DesktopDockProps) {
const pathname = usePathname();
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
const router = useRouter();
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && isLoggedIn && role?.includes('ADMIN');
const handleLogout = () => {
if (confirm('로그아웃 하시겠습니까?')) {
logout();
router.push('/');
}
};
const items: DockItem[] = [
{
key: 'home',
href: '/',
label: '홈',
icon: Home,
isActive: (currentPath) => currentPath === '/',
},
{
key: 'archive',
href: '/archive',
label: '아카이브',
icon: Archive,
isActive: (currentPath) => currentPath.startsWith('/archive'),
},
{
key: 'chess',
href: '/play/chess',
label: '체스',
icon: Crown,
@@ -39,62 +56,114 @@ export default function DesktopDock() {
},
];
if (isAdmin) {
if (_hasHydrated && isAdmin) {
items.push(
{
key: 'admin',
href: '/admin',
label: '관리자',
icon: Settings,
isActive: (currentPath) => currentPath === '/admin',
},
{
key: 'write',
href: '/admin/posts/new',
label: '글쓰기',
icon: PenLine,
isActive: (currentPath) => currentPath.startsWith('/admin/posts/new'),
},
{
key: 'logout',
label: '로그아웃',
icon: LogOut,
onClick: handleLogout,
},
);
} else {
} else if (_hasHydrated && isLoggedIn) {
items.push({
key: 'logout',
label: '로그아웃',
icon: LogOut,
onClick: handleLogout,
});
} else if (_hasHydrated) {
items.push(
{
key: 'login',
href: '/login',
label: '로그인',
icon: LogIn,
isActive: (currentPath) => currentPath.startsWith('/login'),
});
},
{
key: 'signup',
href: '/signup',
label: '회원가입',
icon: UserPlus,
isActive: (currentPath) => currentPath.startsWith('/signup'),
},
);
}
return (
<nav
aria-label="빠른 실행 Dock"
className="fixed bottom-3 left-1/2 z-40 -translate-x-1/2 md:bottom-5 md:left-[calc(50%+9rem)]"
>
<div className="flex items-end gap-1.5 rounded-lg border border-[var(--window-border)] bg-[var(--dock-bg)] px-2 py-2 shadow-[var(--shadow-window)] backdrop-blur-2xl sm:gap-2">
{items.map((item) => {
const renderItem = (item: DockItem) => {
const Icon = item.icon;
const active = item.isActive(pathname);
return (
<Link
key={item.href}
href={item.href}
title={item.label}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
className={clsx(
'group relative flex h-11 w-11 items-center justify-center rounded-lg border border-white/20 bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition duration-150 hover:-translate-y-1 hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)] focus-visible:-translate-y-1 sm:h-12 sm:w-12',
const active = item.isActive?.(pathname) ?? false;
const itemClass = clsx(
'group relative flex h-10 w-10 items-center justify-center rounded-lg border border-white/20 bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition duration-150 hover:-translate-y-1 hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)] focus-visible:-translate-y-1 sm:h-12 sm:w-12',
active && 'bg-[var(--window-bg-strong)] text-[var(--color-accent)] ring-1 ring-[var(--color-accent-soft)]',
)}
>
<Icon size={21} strokeWidth={2.1} />
);
const content = (
<>
<Icon size={20} strokeWidth={2.1} />
<span className="pointer-events-none absolute -top-9 hidden whitespace-nowrap rounded-full border border-[var(--color-line)] bg-[var(--window-bg-strong)] px-2.5 py-1 text-xs font-semibold text-[var(--color-text)] opacity-0 shadow-[var(--shadow-control)] backdrop-blur-xl transition group-hover:opacity-100 sm:block">
{item.label}
</span>
{active && (
<span className="absolute -bottom-1 h-1 w-1 rounded-full bg-[var(--color-accent)]" />
)}
</>
);
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}
>
{content}
</Link>
);
})}
}
return (
<button
key={item.key}
type="button"
title={item.label}
aria-label={item.label}
onClick={item.onClick}
className={itemClass}
>
{content}
</button>
);
};
return (
<nav
aria-label="빠른 실행 Dock"
className={clsx(
'fixed bottom-3 left-1/2 z-40 -translate-x-1/2 transition-[left] duration-300 ease-out md:bottom-5',
isSidebarCollapsed ? 'md:left-[calc(50%+2.5rem)]' : 'md:left-[calc(50%+9rem)]',
)}
>
<div className="flex max-w-[calc(100vw-1rem)] items-end gap-1.5 overflow-x-auto rounded-lg border border-[var(--window-border)] bg-[var(--dock-bg)] px-2 py-2 shadow-[var(--shadow-window)] backdrop-blur-2xl sm:gap-2">
{items.map(renderItem)}
</div>
</nav>
);

View File

@@ -1,12 +1,27 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { clsx } from 'clsx';
import { LogOut, Menu, PenLine, Settings, User, UserPlus, X } from 'lucide-react';
import { Github, Mail } from 'lucide-react';
import { getProfile } from '@/api/profile';
import ThemeToggle from '@/components/theme/ThemeToggle';
import { useAuthStore } from '@/store/authStore';
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';
@@ -16,7 +31,7 @@ const getAppTitle = (pathname: string) => {
if (pathname.startsWith('/admin/profile')) return 'Profile';
if (pathname.startsWith('/admin')) return 'Dashboard';
if (pathname.startsWith('/archive')) return 'Archive';
if (pathname.startsWith('/category')) return 'Finder';
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';
@@ -24,138 +39,57 @@ const getAppTitle = (pathname: string) => {
return 'Desktop';
};
export default function DesktopMenuBar() {
const router = useRouter();
export default function DesktopMenuBar({ isSidebarCollapsed }: DesktopMenuBarProps) {
const pathname = usePathname();
const menuRef = useRef<HTMLDivElement>(null);
const { isLoggedIn, role, logout, _hasHydrated } = useAuthStore();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isAdmin = _hasHydrated && role?.includes('ADMIN');
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
setIsMenuOpen(false);
}
};
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: getProfile,
retry: 0,
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsMenuOpen(false);
};
const githubUrl = profile?.githubUrl || defaultProfile.githubUrl;
const email = profile?.email || defaultProfile.email;
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
const closeMenu = () => setIsMenuOpen(false);
const handleLogout = () => {
if (confirm('로그아웃 하시겠습니까?')) {
closeMenu();
logout();
alert('로그아웃 되었습니다.');
router.push('/');
}
};
const quietActionClass =
const linkClass =
'inline-flex h-8 shrink-0 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]';
const actionLinks = (
<>
<ThemeToggle />
{_hasHydrated && (
isLoggedIn ? (
<>
{isAdmin && (
<>
<Link href="/admin" onClick={closeMenu} className={quietActionClass}>
<Settings size={15} />
<span></span>
</Link>
<Link
href="/admin/posts/new"
onClick={closeMenu}
className="inline-flex h-8 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 hover:bg-[var(--color-accent-hover)]"
>
<PenLine size={15} />
<span></span>
</Link>
</>
)}
<button
type="button"
onClick={handleLogout}
className={clsx(quietActionClass, 'hover:text-red-500')}
>
<LogOut size={15} />
<span></span>
</button>
</>
) : (
<>
<Link href="/login" onClick={closeMenu} className={quietActionClass}>
<User size={15} />
<span></span>
</Link>
<Link href="/signup" onClick={closeMenu} className={quietActionClass}>
<UserPlus size={15} />
<span></span>
</Link>
</>
)
)}
</>
);
return (
<div
ref={menuRef}
className="fixed left-16 right-3 top-3 z-40 md:left-[19rem] md:right-6"
className={clsx(
'fixed left-16 right-3 top-3 z-40 transition-[left] duration-300 ease-out md:right-6',
isSidebarCollapsed ? 'md:left-24' : 'md:left-[19rem]',
)}
>
<div className="flex h-11 items-center justify-between gap-3 rounded-lg border border-[var(--window-border)] bg-[var(--menubar-bg)] px-3 shadow-[var(--shadow-control)] backdrop-blur-2xl">
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 items-center gap-3">
<Link href="/" className="shrink-0 text-sm font-bold text-[var(--color-text)]">
WYPark OS
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 text-xs font-medium text-[var(--color-text-subtle)] lg:flex">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div className="hidden items-center gap-2 md:flex">{actionLinks}</div>
<button
type="button"
aria-label={isMenuOpen ? '상단 메뉴 닫기' : '상단 메뉴 열기'}
aria-expanded={isMenuOpen}
onClick={() => setIsMenuOpen((previous) => !previous)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] text-[var(--color-text)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] md:hidden"
<div className="flex shrink-0 items-center gap-2">
<div className="hidden items-center gap-2 md:flex">
<a
href={githubUrl}
target="_blank"
rel="noreferrer"
className={linkClass}
>
{isMenuOpen ? <X size={17} /> : <Menu size={17} />}
</button>
<Github size={15} />
<span>GitHub</span>
</a>
<a href={`mailto:${email}`} className={linkClass}>
<Mail size={15} />
<span>Email</span>
</a>
</div>
<ThemeToggle />
</div>
<div
className={clsx(
'absolute right-0 top-[3.25rem] flex min-w-64 flex-col gap-2 rounded-lg border border-[var(--window-border)] bg-[var(--window-bg-strong)] p-3 shadow-[var(--shadow-window)] backdrop-blur-2xl transition md:hidden',
isMenuOpen ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-2 opacity-0',
)}
>
{actionLinks}
</div>
</div>
);

View File

@@ -0,0 +1,50 @@
'use client';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
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 [isSidebarCollapsed, setIsSidebarCollapsed] = 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);
}, []);
const handleSidebarCollapsedChange = (nextValue: boolean) => {
setIsSidebarCollapsed(nextValue);
window.localStorage.setItem('sidebar-collapsed', String(nextValue));
};
return (
<div className="min-h-screen">
<Sidebar
isDesktopCollapsed={isSidebarCollapsed}
onDesktopCollapsedChange={handleSidebarCollapsedChange}
/>
<DesktopMenuBar isSidebarCollapsed={isSidebarCollapsed} />
<DesktopDock isSidebarCollapsed={isSidebarCollapsed} />
<main
className={clsx(
'relative w-full flex-1 transition-[margin] duration-300 ease-out',
isSidebarCollapsed ? 'md:ml-20' : 'md:ml-72',
)}
>
<div className="mx-auto w-full px-4 pb-28 pt-20 md:px-6 md:pb-32 md:pt-24 lg:px-8">
{children}
</div>
</main>
</div>
);
}

View File

@@ -7,13 +7,15 @@ import Image from 'next/image';
import { useQuery } from '@tanstack/react-query';
import { clsx } from 'clsx';
import {
Archive,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Crown,
FileQuestion,
Folder,
FolderOpen,
Github,
Home,
Mail,
Menu,
X,
@@ -23,6 +25,11 @@ import { getProfile } from '@/api/profile';
import PostSearch from '@/components/post/PostSearch';
import { Category, Profile } from '@/types';
interface SidebarProps {
isDesktopCollapsed: boolean;
onDesktopCollapsedChange: (nextValue: boolean) => void;
}
interface CategoryItemProps {
category: Category;
depth: number;
@@ -32,7 +39,7 @@ interface CategoryItemProps {
const defaultProfile: Profile = {
name: 'Dev Park',
bio: '개발',
bio: '개발 기록과 실험을 모아두는 공간입니다.',
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
githubUrl: 'https://github.com',
email: 'user@example.com',
@@ -68,7 +75,7 @@ function CategoryItem({ category, depth, onNavigate, pathname }: CategoryItemPro
className={clsx(
'flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-all',
isActive
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
style={{ marginLeft: `${depth * 8}px` }}
@@ -118,7 +125,10 @@ function CategoryItem({ category, depth, onNavigate, pathname }: CategoryItemPro
);
}
function SidebarContent() {
function SidebarContent({
isDesktopCollapsed,
onDesktopCollapsedChange,
}: SidebarProps) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
@@ -140,6 +150,7 @@ function SidebarContent() {
});
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
const decodedPathname = decodeURIComponent(pathname);
const closeSidebar = () => setIsOpen(false);
@@ -155,6 +166,8 @@ function SidebarContent() {
closeSidebar();
};
const compactCategoryItems = sortedCategories?.slice(0, 8) ?? [];
return (
<>
<button
@@ -178,19 +191,34 @@ function SidebarContent() {
<aside
className={clsx(
'fixed left-0 top-0 z-40 flex h-screen w-72 flex-col overflow-hidden border-r border-[var(--window-border)] bg-[var(--sidebar-bg)] shadow-[var(--shadow-panel)] backdrop-blur-2xl transition-transform duration-300 ease-out',
isOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
'fixed left-0 top-0 z-40 flex h-screen w-72 flex-col overflow-hidden border-r border-[var(--window-border)] bg-[var(--sidebar-bg)] shadow-[var(--shadow-panel)] backdrop-blur-2xl transition-[transform,width] duration-300 ease-out md:translate-x-0',
isDesktopCollapsed ? 'md:w-20' : 'md:w-72',
isOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className={clsx('relative shrink-0 text-center', isDesktopCollapsed ? 'px-6 pb-5 pt-8 md:px-3 md:pb-4 md:pt-5' : 'px-6 pb-5 pt-8')}>
<button
type="button"
onClick={() => onDesktopCollapsedChange(!isDesktopCollapsed)}
className="absolute right-3 top-3 hidden h-8 w-8 items-center justify-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-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',
)}
>
<div className="shrink-0 px-6 pb-5 pt-8 text-center">
<Link href="/" onClick={closeSidebar} className="block transition-opacity hover:opacity-80">
<div className="relative mx-auto mb-4 h-20 w-20 overflow-hidden rounded-full bg-black/[0.04] shadow-inner ring-1 ring-[var(--color-line)] dark:bg-white/10">
{isProfileLoading ? (
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
) : (
<Image
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
alt="Profile"
alt="프로필"
fill
sizes="96px"
className="object-cover"
@@ -200,6 +228,7 @@ function SidebarContent() {
)}
</div>
<div className={clsx(isDesktopCollapsed && 'md:hidden')}>
{isProfileLoading ? (
<div className="flex flex-col items-center space-y-2">
<div className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
@@ -211,11 +240,12 @@ function SidebarContent() {
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
</>
)}
</div>
</Link>
</div>
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
<div className="flex h-full flex-col">
<div className={clsx('flex h-full flex-col', isDesktopCollapsed && 'md:hidden')}>
<div className="shrink-0 space-y-1">
<div id="blog-search" className="mb-5 mt-2 px-1">
<PostSearch
@@ -225,26 +255,10 @@ function SidebarContent() {
/>
</div>
<div className="mb-4">
<Link
href="/archive"
onClick={closeSidebar}
className={clsx(
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
pathname === '/archive'
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Archive size={16} />
<span></span>
</Link>
</div>
<div className="mb-4 border-t border-[var(--color-line)]" />
<div className="mb-3 flex h-8 items-center justify-between px-3">
<p className="text-xs font-semibold uppercase text-[var(--color-text-subtle)]"></p>
<p className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-subtle)]"></p>
</div>
</div>
@@ -288,7 +302,7 @@ function SidebarContent() {
<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 text-[var(--color-text-subtle)]"></p>
<p className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-subtle)]"></p>
</div>
<Link
@@ -306,16 +320,83 @@ function SidebarContent() {
</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(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]',
pathname === '/' && 'bg-[var(--window-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(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]',
isActive && 'bg-[var(--window-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(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]',
pathname === '/category/uncategorized' && 'bg-[var(--window-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(--color-line)] bg-[var(--color-control)] text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--window-bg-strong)] hover:text-[var(--color-text)]',
pathname === '/play/chess' && 'bg-[var(--window-bg-strong)] text-[var(--color-accent)]',
)}
>
<Crown size={18} />
</Link>
</div>
</nav>
<div className="shrink-0 border-t border-[var(--color-line)] bg-[var(--window-titlebar)] p-5">
<div className="flex justify-center gap-3">
<div
className={clsx(
'shrink-0 border-t border-[var(--color-line)] bg-[var(--window-titlebar)] p-5',
isDesktopCollapsed && 'md:px-3 md:py-4',
)}
>
<div className={clsx('flex justify-center gap-3', isDesktopCollapsed && 'md:flex-col md:items-center')}>
<a
href={displayProfile.githubUrl || '#'}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2.5 text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-all hover:bg-[var(--color-text)] hover:text-white dark:hover:bg-white dark:hover:text-black"
aria-label="GitHub"
title="GitHub"
>
<Github size={18} />
</a>
@@ -323,11 +404,12 @@ function SidebarContent() {
href={`mailto:${displayProfile.email}`}
className="rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2.5 text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-all hover:bg-[var(--color-accent)] hover:text-white"
aria-label="이메일"
title="이메일"
>
<Mail size={18} />
</a>
</div>
<p className="mt-4 text-center text-[10px] font-light text-[var(--color-text-subtle)]">
<p className={clsx('mt-4 text-center text-[10px] font-light text-[var(--color-text-subtle)]', isDesktopCollapsed && 'md:hidden')}>
© {new Date().getFullYear()} {displayProfile.name}
</p>
</div>
@@ -336,10 +418,19 @@ function SidebarContent() {
);
}
export default function Sidebar() {
export default function Sidebar(props: SidebarProps) {
return (
<Suspense fallback={<div className="h-screen w-72 border-r border-[var(--window-border)] bg-[var(--sidebar-bg)]" />}>
<SidebarContent />
<Suspense
fallback={(
<div
className={clsx(
'h-screen border-r border-[var(--window-border)] bg-[var(--sidebar-bg)]',
props.isDesktopCollapsed ? 'w-20' : 'w-72',
)}
/>
)}
>
<SidebarContent {...props} />
</Suspense>
);
}

View File

@@ -41,7 +41,7 @@ export default function PostCard({ post }: { post: Post }) {
<Surface
as="article"
interactive
className="flex h-full flex-col p-5 shadow-none"
className="flex h-full min-h-52 flex-col p-5 shadow-none"
>
<div className="mb-4 flex items-center justify-between gap-3">
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">

View File

@@ -65,7 +65,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
const isAuthError = errorStatus === 401 || errorStatus === 403;
return (
<WindowSurface className="mx-auto max-w-4xl" bodyClassName="px-4 py-20 text-center">
<WindowSurface className="mx-auto md:w-[70vw] md:max-w-[980px]" bodyClassName="px-4 py-20 text-center">
<div className="mb-4 flex justify-center">
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
</div>
@@ -90,13 +90,13 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
const nextPost = post.nextPost;
return (
<div className="mx-auto max-w-6xl px-1 py-4 md:px-3 md:py-6">
<div className="mx-auto w-full px-0 py-4 md:w-[78vw] md:max-w-[1280px] md:py-6">
<Link href="/" className="mb-6 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
<ArrowLeft size={18} />
<span></span>
</Link>
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,780px)_220px] xl:justify-center xl:gap-8">
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,900px)_250px] xl:justify-center xl:gap-8">
<main className="min-w-0 space-y-6">
<WindowSurface
as="article"

View File

@@ -28,7 +28,7 @@ export default function WindowSurface({
return (
<Component
className={clsx(
'overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-2xl',
'w-full overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-2xl',
className,
)}
{...props}

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"
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
chess.js@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz"
integrity sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==
client-only@0.0.1:
version "0.0.1"
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"