Redesign blog as desktop OS
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
This commit is contained in:
107
LOG.md
107
LOG.md
@@ -1,103 +1,10 @@
|
||||
# LOG.md
|
||||
# Work Log
|
||||
|
||||
## 2026-05-29
|
||||
|
||||
- 체스 퍼즐을 프론트 하드코딩 배열에서 백엔드 `GET /api/chess-puzzles/today?timezone=Asia/Seoul` 조회 방식으로 전환했다.
|
||||
- `../blog-backend`에 `chess_puzzle` Flyway migration, Lichess CC0 mate-in-1 seed 30개, 공개 오늘의 퍼즐 API, 날짜 자동 순환 로직, 통합 테스트를 추가했다.
|
||||
- 검증: 백엔드 `JAVA_HOME=/Users/wypark/Library/Java/JavaVirtualMachines/temurin-21.0.11/Contents/Home ./gradlew test` 통과, 프론트 `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다.
|
||||
- 다음 추천 작업: 배포 환경 DB에 Flyway migration이 정상 적용된 뒤 `https://blog.wypark.me/play/chess`에서 실제 API 응답과 CORS를 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- `/play/chess` 체스 퍼즐 페이지를 추가하고 `chess.js`로 합법수/체크메이트 판정을 처리하는 한 수 메이트 퍼즐 3개를 구현했다.
|
||||
- 왼쪽 사이드바 카테고리 영역 아래에 구분선과 `기타` 섹션을 추가하고, 그 안에 `체스` 메뉴를 연결했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser로 `/play/chess`에서 사이드바 `기타 > 체스` 노출, 보드 렌더링, 첫 퍼즐 `Qxf7#` 정답 판정을 확인했다.
|
||||
- 다음 추천 작업: 체스 퍼즐 기록을 localStorage나 로그인 사용자 서버 저장으로 확장해 완료한 퍼즐/연속 정답 수를 유지한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 상단 메뉴의 관리자 `새 글` 버튼이 라이트/다크 모드에서 흑백 반전처럼 보이지 않도록 accent 색상 기반 primary 버튼으로 고정했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다.
|
||||
- 다음 추천 작업: 실제 관리자 로그인 상태에서 상단 메뉴를 열어 `새 글` 버튼 대비가 라이트/다크 모두 자연스러운지 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 홈 상단 히어로의 `박원엽의 개발 기록` 제목과 소개 문구를 제거하고, `전체 글` CTA는 유지했다.
|
||||
- 접근성을 위해 시각적으로 숨긴 홈 제목만 유지했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser로 홈에서 제목/소개 문구가 보이지 않는 것을 확인했다.
|
||||
- 다음 추천 작업: 배포 후 실데이터가 들어온 홈 첫 화면에서 상단 여백이 과하지 않은지 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 홈 대표 글 영역을 제거하고 조회수 내림차순 `viewCount,desc` 기준 인기 글 5개 패널로 대체했다.
|
||||
- 인기 글과 최신 글을 같은 `lg:grid-cols-2` 반반 레이아웃으로 맞추고, 인기 글에는 순위와 조회수를 작은 보조 메타로 표시했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser에서 홈의 인기 글/최신 글 반반 레이아웃을 확인했다. 로컬 `localhost:3000`에서 배포 API 호출은 CORS 정책상 실데이터가 비어 보여 production Origin 배포 후 실데이터 렌더링 확인이 필요하다.
|
||||
- 다음 추천 작업: 배포 후 `https://blog.wypark.me` 홈에서 인기 글 5개가 조회수 순으로 보이는지 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 상단 우측의 테마 토글, 로그인/회원가입, 관리자/새 글/로그아웃 액션을 작은 메뉴 버튼 뒤로 숨기고, 클릭 시 왼쪽으로 펼쳐지는 패널 애니메이션으로 노출되도록 수정했다.
|
||||
- 메뉴 바깥 클릭과 Escape 키로 닫히게 하고, 링크 이동/로그아웃 시 메뉴가 닫히도록 정리했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser로 데스크톱/모바일 홈에서 닫힘 상태와 펼침 상태를 확인했다.
|
||||
- 다음 추천 작업: 실제 로그인/관리자 상태에서 펼침 패널의 버튼 수가 늘어났을 때 모바일 폭에서 자연스럽게 보이는지 production Origin에서 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- Pretendard Variable WOFF2를 `src/app/fonts`에 self-host로 추가하고 `next/font/local`로 전역 적용했다.
|
||||
- 홈을 Apple editorial 톤으로 정리해 히어로, 공지, 대표 글, 최신 글 중심으로 줄이고 최근 업데이트/카테고리 선반/인기 글/하단 아카이브 CTA를 제거했다.
|
||||
- TopHeader와 ThemeToggle을 아이콘 중심의 조용한 컨트롤로 낮추고, Sidebar는 모바일 기본 닫힘 + 오버레이 drawer로 바꿨다.
|
||||
- 카드/Surface 그림자와 `읽기` 같은 보조 문구를 줄이고, 글 상세의 조회수 메타와 TOC 카드감을 덜어 본문 집중도를 높였다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 로컬 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. Browser로 `http://localhost:3000/`, 모바일 홈/drawer, `/?keyword=network`, `/archive`, production API 환경의 `/posts/rtr-(refresh-token-rotation)`을 확인했고 Pretendard computed font 적용을 확인했다. 로컬 기본 API 상태에서는 posts/categories 데이터가 비어 보일 수 있어 production API 환경으로 글 상세를 추가 확인했다.
|
||||
- 다음 추천 작업: 배포 후 실제 Origin에서 홈 실데이터 카드 밀도와 모바일 상단 컨트롤 간격을 한 번 더 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 홈의 `전체 글 둘러보기` CTA가 라이트/다크에서 반전 색상처럼 보이지 않도록 `color-text`/`dark:bg-white` 조합을 제거하고 Apple action blue 토큰으로 통일했다.
|
||||
- 검증: `npm run lint` 통과.
|
||||
- 다음 추천 작업: 배포 후 홈 첫 화면에서 primary/secondary CTA 대비가 라이트/다크 모두 자연스러운지 최종 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- `npx getdesign@latest add apple`로 Apple 참고용 `DESIGN.md`를 추가했다.
|
||||
- `ThemeProvider`/`ThemeToggle`을 추가해 `wyp-theme-mode` 기반 `라이트/시스템/다크` 3단 토글을 TopHeader 우측에 배치했다.
|
||||
- `html[data-theme]`, `html[data-theme-mode]` DOM 계약과 beforeInteractive 초기화 스크립트를 추가해 강제 라이트/다크와 시스템 설정 따르기를 분리했다.
|
||||
- 전역 폰트를 Apple 우선 sans/mono stack으로 정리하고, Tailwind `dark:` variant를 `data-theme="dark"` 기준으로 전환했다.
|
||||
- 홈, 아카이브, 카테고리, 로그인/회원가입, 글 상세, 댓글, 관리자 shell/dashboard 일부 surface를 Apple/macOS식 토큰과 control 색상으로 맞췄다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 sandbox 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me npm run dev` 후 Browser로 `/`, `/archive`, `/category/잡담`, `/posts/rtr-(refresh-token-rotation)`, `/login`, 로그아웃 상태 `/admin` 리다이렉트와 테마 전환/새로고침 유지 동작을 확인했다. localhost에서 배포 API를 직접 호출하는 카테고리 데이터는 백엔드 접근 제한으로 에러 상태 UI를 확인했다.
|
||||
- 다음 추천 작업: 실제 배포 Origin에서 테마 토글 저장값과 관리자 로그인 상태의 하위 관리 화면 hover/focus를 최종 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- `docs/apple-style-frontend-development-guide.md` Phase 1 + Phase 2 기준으로 Apple 스타일 1차 MVP 프론트 리뉴얼을 적용했다.
|
||||
- 전역 디자인 토큰과 작은 UI 컴포넌트(`Surface`, `StatusBadge`, `SegmentedControl`, `MetricCard`, `EmptyState`)를 추가했다.
|
||||
- 공개 홈을 intro, notice, featured/latest, category shelves, popular reading, archive CTA 구조로 재구성하고 공개 운영 집계 노출을 피했다.
|
||||
- 사이드바, 카드/list item, 아카이브, 글 상세, Markdown renderer, TOC의 영어 UI와 표면/본문 폭/간격을 한국어 중심 Apple 스타일로 정리했다.
|
||||
- 관리자 dashboard 타입과 `getAdminDashboard()` API wrapper를 추가하고, `/admin`에 KPI, 트래픽 차트, 액션 센터, 콘텐츠 성과, 최근 활동, 카테고리 상태 골격과 fallback UI를 구현했다.
|
||||
- 검증: `npm run build` 통과. `npm run lint`는 기존 baseline(`src/api/http.ts`, `src/app/signup/page.tsx`, `src/app/sitemap.ts`, 댓글 컴포넌트, `src/store/authStore.ts`, `tailwind.config.ts`) 오류로 실패하지만 이번에 수정한 파일의 lint 오류는 없음. Browser로 `/`, `/?keyword=test`, `/archive`, 로그아웃 상태 `/admin` 리다이렉트를 확인했고 콘솔 에러는 없었다.
|
||||
- 다음 추천 작업: 기존 lint baseline을 먼저 정리한 뒤, 백엔드 `/api/admin/dashboard` 구현과 실제 관리자 계정 상태에서 dashboard range 전환을 검증한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 기존 lint baseline을 정리했다.
|
||||
- `src/api/http.ts`의 토큰 refresh queue, Web Locks 접근, localStorage 파싱을 명시 타입으로 좁혔다.
|
||||
- providers, signup, sitemap, comment, auth store, Tailwind config의 unused 변수와 `any`/CommonJS import lint 오류를 정리했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 백엔드/네트워크 제한으로 기존처럼 경고를 출력하지만 실패하지 않는다.
|
||||
- 다음 추천 작업: 실제 관리자 계정으로 `/admin` 대시보드 fallback과 range control을 확인하고, 백엔드 `/api/admin/dashboard` 구현을 연결한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- 배포 백엔드(`https://blogserver.wypark.me`) 기준으로 공개 posts/categories/category 필터 API 연결을 확인했다.
|
||||
- `GET /api/admin/dashboard?range=30d&timezone=Asia/Seoul`는 미인증 상태에서 `403`을 반환하고, 배포 프론트 Origin(`https://blog.wypark.me`) + Authorization preflight는 허용됨을 확인했다.
|
||||
- 실제 posts 응답에 포함되는 `updatedAt`을 `Post` 타입에 반영했다.
|
||||
- `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me npm run build`를 네트워크 허용 상태로 실행해 sitemap fetch까지 포함한 production build를 검증했다.
|
||||
- 검증: `npm run lint` 통과, `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me npm run build` 통과. 로컬 `localhost:3000`에서 배포 API를 직접 붙이는 브라우저 검증은 백엔드 CORS 정책상 `https://blog.wypark.me`만 허용되어 제한된다.
|
||||
- 다음 추천 작업: 프론트 배포 후 `https://blog.wypark.me/admin`에서 실제 관리자 로그인 상태로 dashboard 실데이터 렌더링과 range 전환을 최종 확인한다.
|
||||
|
||||
## 2026-05-28
|
||||
|
||||
- Browser로 배포 블로그(`https://blog.wypark.me`)를 직접 둘러보고 대표글 카드가 비어 보이는 현상, 검색 입력/로그인/관리자 하위 화면의 다크모드 누락을 확인했다.
|
||||
- 대표글이 1개뿐일 때 최신 글 패널이 빈 영역처럼 보이지 않도록 홈 featured/latest 레이아웃을 조정했다.
|
||||
- 검색 입력, 로그인 화면, 댓글 UI를 토큰 기반 surface/text/input 스타일로 정리했다.
|
||||
- 관리자 게시글/댓글/카테고리/프로필처럼 기존 Tailwind neutral class를 쓰는 화면도 다크모드에서 깨지지 않도록 전역 dark compatibility 레이어를 보강했다.
|
||||
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 샌드박스 네트워크 제한으로 경고가 출력됐지만 exit 0으로 완료됐다. `npm run dev` 후 Browser로 `/`, `/?keyword=network`, `/login`, 로그아웃 상태 `/admin` 리다이렉트, 관리자 하위 화면 로컬 미리보기를 확인했다.
|
||||
- 다음 추천 작업: 프론트 배포 후 실제 관리자 로그인 상태에서 `/admin/posts`, `/admin/comments`, `/admin/categories`, `/admin/profile`의 데이터 로딩 상태와 다크모드 hover/focus를 production Origin에서 최종 확인한다.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import { Post } from '@/types';
|
||||
import { Loader2, Calendar, Archive, FileText, ChevronRight } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { PostListResponse } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { Archive, Calendar, ChevronRight, FileText, Loader2 } from 'lucide-react';
|
||||
import { getPosts } from '@/api/posts';
|
||||
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 { Post, PostListResponse } from '@/types';
|
||||
|
||||
const getTotalElements = (data?: PostListResponse) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
|
||||
export default function ArchivePage() {
|
||||
// 1. 전체 게시글 조회 (최대 1000개)
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['posts', 'all'],
|
||||
queryFn: () => getPosts({ page: 0, size: 1000 }),
|
||||
staleTime: 1000 * 60 * 5, // 5분 캐시
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
// 2. 게시글 그룹화 (연도 -> 월)
|
||||
const archiveGroups = useMemo(() => {
|
||||
if (!data?.content) return {};
|
||||
|
||||
@@ -33,7 +31,7 @@ export default function ArchivePage() {
|
||||
data.content.forEach((post) => {
|
||||
const date = new Date(post.createdAt);
|
||||
const year = date.getFullYear().toString();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // '01', '02' 형식
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
if (!groups[year]) groups[year] = {};
|
||||
if (!groups[year][month]) groups[year][month] = [];
|
||||
@@ -44,63 +42,58 @@ export default function ArchivePage() {
|
||||
return groups;
|
||||
}, [data]);
|
||||
|
||||
// 3. 연도 내림차순 정렬
|
||||
const sortedYears = useMemo(() => {
|
||||
return Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a));
|
||||
}, [archiveGroups]);
|
||||
|
||||
// 🛠️ 총 게시글 수 수정 (백엔드 PagedModel 대응)
|
||||
// page 정보가 data 안에 직접 있거나, page 객체 안에 있을 수 있음
|
||||
const totalPosts = getTotalElements(data);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[50vh]">
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-10">
|
||||
{/* 헤더 섹션 */}
|
||||
<div className="mb-12 border-b border-[var(--color-line)] pb-8 text-center md:text-left">
|
||||
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
|
||||
<WindowSurface
|
||||
title="Archive"
|
||||
subtitle={`${totalPosts.toLocaleString()} posts`}
|
||||
bodyClassName="p-5 md:p-8"
|
||||
>
|
||||
<div className="mb-10 border-b border-[var(--color-line)] pb-7 text-center md:text-left">
|
||||
<h1 className="mb-3 flex items-center justify-center gap-3 text-3xl font-bold tracking-normal text-[var(--color-text)] md:justify-start">
|
||||
<Archive className="text-[var(--color-accent)]" size={32} />
|
||||
<span>아카이브</span>
|
||||
</h1>
|
||||
<p className="text-[var(--color-text-muted)]">
|
||||
지금까지 작성한 <span className="font-bold text-[var(--color-accent)]">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||
지금까지 작성한 <span className="font-bold text-[var(--color-accent)]">{totalPosts}</span>개의 글을 시간순으로 정리했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 컨텐츠 */}
|
||||
{sortedYears.length > 0 ? (
|
||||
<div className="relative 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 = archiveGroups[year];
|
||||
// 월 내림차순 정렬
|
||||
const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a));
|
||||
|
||||
return (
|
||||
<div key={year} className="relative">
|
||||
{/* 연도 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="z-10 hidden h-9 w-9 items-center justify-center rounded-full border-4 border-[var(--color-page)] bg-[var(--color-accent-soft)] shadow-[var(--shadow-control)] md:flex">
|
||||
<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="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());
|
||||
const sortedPosts = [...posts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return (
|
||||
<div key={month} className="group">
|
||||
@@ -118,13 +111,13 @@ export default function ArchivePage() {
|
||||
className="group/item block"
|
||||
>
|
||||
<Surface interactive className="flex items-center justify-between gap-4 p-4 shadow-none">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-base font-semibold text-[var(--color-text)] transition-colors group-hover/item:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="mt-1.5 flex 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>•</span>
|
||||
<span>·</span>
|
||||
<span className="tabular-nums">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
</span>
|
||||
@@ -150,6 +143,7 @@ export default function ArchivePage() {
|
||||
className="py-20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,31 +2,36 @@
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPostsByCategory } from '@/api/posts'; // 🛠️ 수정된 API 사용
|
||||
import { clsx } from 'clsx';
|
||||
import { ChevronLeft, ChevronRight, LayoutGrid, List, Loader2, Search as SearchIcon } from 'lucide-react';
|
||||
import { getPostsByCategory } from '@/api/posts';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import PostCard from '@/components/post/PostCard';
|
||||
import PostListItem from '@/components/post/PostListItem';
|
||||
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
|
||||
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import PostSearch from '@/components/post/PostSearch';
|
||||
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';
|
||||
};
|
||||
|
||||
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const categoryName = decodeURIComponent(id);
|
||||
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
||||
|
||||
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
|
||||
const isNoticeCategory = isNoticeCategoryName(apiCategoryName);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [keyword, setKeyword] = useState(''); // 🆕 검색어 상태
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
||||
if (isNoticeCategory || typeof window === 'undefined') return isNoticeCategory ? 'list' : 'grid';
|
||||
|
||||
const savedMode = localStorage.getItem('postViewMode');
|
||||
return savedMode === 'list' ? 'list' : 'grid';
|
||||
});
|
||||
const PAGE_SIZE = 10;
|
||||
const activeViewMode = isNoticeCategory ? 'list' : viewMode;
|
||||
|
||||
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||
@@ -36,22 +41,15 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 검색어가 변경되면 페이지를 0으로 초기화
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
setKeyword(newKeyword);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
|
||||
queryKey: ['posts', 'category', apiCategoryName, page, keyword], // 🔑 쿼리 키에 keyword 추가
|
||||
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword), // 🆕 검색어 전달
|
||||
// 🛠️ 수정됨: 검색어가 바뀌면 이전 데이터를 보여주지 않고 로딩 상태로 전환
|
||||
// (페이지 이동 시에는 부드럽게 보여주기 위해 유지)
|
||||
placeholderData: (previousData, previousQuery) => {
|
||||
const prevKeyword = previousQuery?.queryKey[4];
|
||||
if (prevKeyword !== keyword) return undefined;
|
||||
return previousData;
|
||||
},
|
||||
queryKey: ['posts', 'category', apiCategoryName, page, keyword],
|
||||
queryFn: () => getPostsByCategory(apiCategoryName, page, PAGE_SIZE, keyword),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
if (isLoading || (postsData === undefined && !error)) {
|
||||
@@ -64,56 +62,55 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Surface className="mx-auto max-w-3xl px-5 py-10 text-center text-red-500">
|
||||
게시글을 불러오는 중 오류가 발생했습니다.<br/>
|
||||
<WindowSurface className="mx-auto max-w-3xl" bodyClassName="px-5 py-10 text-center text-red-500">
|
||||
게시글을 불러오는 중 오류가 발생했습니다.
|
||||
<br />
|
||||
<span className="text-sm text-[var(--color-text-subtle)]">카테고리 이름을 확인해주세요.</span>
|
||||
</Surface>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const posts = postsData?.content || [];
|
||||
|
||||
// 🛠️ 백엔드 PagedModel 구조 대응 (page 필드 내부에 메타데이터가 있을 수 있음)
|
||||
const pagingData = postsData?.page || postsData;
|
||||
const totalElements = pagingData?.totalElements ?? 0;
|
||||
const totalPages = pagingData?.totalPages ?? 0;
|
||||
// page.number가 존재하면 계산해서 isLast 판단, 아니면 기존 last 필드 사용
|
||||
const isLast = pagingData?.number !== undefined
|
||||
? (pagingData.number + 1 >= pagingData.totalPages)
|
||||
? pagingData.number + 1 >= pagingData.totalPages
|
||||
: (postsData?.last ?? true);
|
||||
|
||||
const handlePrevPage = () => setPage((old) => Math.max(old - 1, 0));
|
||||
const handleNextPage = () => {
|
||||
if (!isLast) {
|
||||
setPage((old) => old + 1);
|
||||
}
|
||||
if (!isLast) setPage((old) => old + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
{/* 헤더 영역 */}
|
||||
<Surface strong className="mb-8 p-5">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
|
||||
<WindowSurface
|
||||
title="Finder"
|
||||
subtitle={`${apiCategoryName} posts`}
|
||||
bodyClassName="p-5 md:p-6"
|
||||
>
|
||||
<div className="mb-8 flex flex-col justify-between gap-4 border-b border-[var(--color-line)] pb-5 md:flex-row md:items-center">
|
||||
<h1 className="flex shrink-0 items-baseline gap-2 text-2xl font-bold tracking-normal text-[var(--color-text)]">
|
||||
{apiCategoryName} <span className="text-lg font-normal text-[var(--color-text-subtle)]">글 목록</span>
|
||||
{apiCategoryName}
|
||||
<span className="text-lg font-normal text-[var(--color-text-subtle)]">글 목록</span>
|
||||
</h1>
|
||||
|
||||
<div className="flex w-full items-center gap-3 md:w-auto">
|
||||
{/* 🔍 카테고리 내 검색바 */}
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder={`${apiCategoryName} 내 검색`}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
|
||||
{/* 뷰 모드 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewModeChange('grid')}
|
||||
className={clsx(
|
||||
'rounded-full p-2 transition-all duration-150',
|
||||
activeViewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
|
||||
? '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="카드형 보기"
|
||||
@@ -123,11 +120,12 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewModeChange('list')}
|
||||
className={clsx(
|
||||
'rounded-full p-2 transition-all duration-150',
|
||||
activeViewMode === 'list'
|
||||
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
|
||||
? '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="리스트형 보기"
|
||||
@@ -139,9 +137,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
|
||||
{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)]">
|
||||
<SearchIcon size={16} className="text-[var(--color-accent)]" />
|
||||
@@ -172,8 +168,9 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<div className="mb-8 mt-12 flex items-center justify-center gap-6">
|
||||
<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-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
|
||||
@@ -187,6 +184,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextPage}
|
||||
disabled={isLast || isPlaceholderData}
|
||||
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
|
||||
@@ -197,6 +195,7 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,21 +24,34 @@
|
||||
Consolas,
|
||||
"Liberation Mono",
|
||||
monospace;
|
||||
--color-page: #f5f5f7;
|
||||
--color-surface: rgba(255, 255, 255, 0.72);
|
||||
--color-surface-strong: #ffffff;
|
||||
--color-control: rgba(255, 255, 255, 0.66);
|
||||
--color-line: rgba(0, 0, 0, 0.1);
|
||||
--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.58);
|
||||
--window-bg-strong: rgba(255, 255, 255, 0.78);
|
||||
--window-titlebar: rgba(255, 255, 255, 0.5);
|
||||
--window-border: rgba(255, 255, 255, 0.62);
|
||||
--menubar-bg: rgba(255, 255, 255, 0.48);
|
||||
--dock-bg: rgba(255, 255, 255, 0.5);
|
||||
--sidebar-bg: rgba(255, 255, 255, 0.46);
|
||||
--color-surface: rgba(255, 255, 255, 0.58);
|
||||
--color-surface-strong: rgba(255, 255, 255, 0.82);
|
||||
--color-control: rgba(255, 255, 255, 0.54);
|
||||
--color-line: rgba(39, 54, 82, 0.13);
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #6e6e73;
|
||||
--color-text-subtle: #86868b;
|
||||
--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-panel: 0 18px 45px rgba(0, 0, 0, 0.07);
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.035);
|
||||
--shadow-control: 0 6px 18px rgba(0, 0, 0, 0.055);
|
||||
--shadow-window: 0 26px 80px rgba(70, 92, 130, 0.16), 0 1px 0 rgba(255, 255, 255, 0.7) inset;
|
||||
--shadow-panel: 0 22px 56px rgba(70, 92, 130, 0.13);
|
||||
--shadow-card: 0 10px 30px rgba(70, 92, 130, 0.08);
|
||||
--shadow-control: 0 12px 26px rgba(70, 92, 130, 0.1);
|
||||
--focus-ring: 0 0 0 4px rgba(0, 102, 204, 0.14);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
@@ -53,21 +66,34 @@
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--color-page: #0b0b0c;
|
||||
--color-surface: rgba(28, 28, 30, 0.72);
|
||||
--color-surface-strong: #1c1c1e;
|
||||
--color-control: rgba(44, 44, 46, 0.74);
|
||||
--color-page: #11131d;
|
||||
--desktop-bg:
|
||||
linear-gradient(135deg, rgba(88, 56, 104, 0.42) 0%, rgba(88, 56, 104, 0) 38%),
|
||||
linear-gradient(150deg, rgba(43, 73, 110, 0.5) 10%, rgba(43, 73, 110, 0) 54%),
|
||||
linear-gradient(42deg, rgba(38, 91, 72, 0.38) 48%, rgba(38, 91, 72, 0) 88%),
|
||||
linear-gradient(180deg, #131620 0%, #07090f 100%);
|
||||
--window-bg: rgba(27, 29, 39, 0.64);
|
||||
--window-bg-strong: rgba(34, 36, 48, 0.82);
|
||||
--window-titlebar: rgba(255, 255, 255, 0.06);
|
||||
--window-border: rgba(255, 255, 255, 0.14);
|
||||
--menubar-bg: rgba(24, 26, 36, 0.58);
|
||||
--dock-bg: rgba(24, 26, 36, 0.62);
|
||||
--sidebar-bg: rgba(22, 24, 33, 0.56);
|
||||
--color-surface: rgba(27, 29, 39, 0.64);
|
||||
--color-surface-strong: rgba(34, 36, 48, 0.82);
|
||||
--color-control: rgba(255, 255, 255, 0.08);
|
||||
--color-line: rgba(255, 255, 255, 0.12);
|
||||
--color-text: #f5f5f7;
|
||||
--color-text-muted: #a1a1a6;
|
||||
--color-text-subtle: #8e8e93;
|
||||
--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-panel: 0 18px 45px rgba(0, 0, 0, 0.32);
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
--shadow-control: 0 8px 22px rgba(0, 0, 0, 0.24);
|
||||
--shadow-window: 0 30px 90px rgba(0, 0, 0, 0.44), 0 1px 0 rgba(255, 255, 255, 0.08) inset;
|
||||
--shadow-panel: 0 24px 60px rgba(0, 0, 0, 0.36);
|
||||
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.24);
|
||||
--shadow-control: 0 14px 30px rgba(0, 0, 0, 0.26);
|
||||
--focus-ring: 0 0 0 4px rgba(41, 151, 255, 0.18);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
@@ -75,7 +101,10 @@ html[data-theme="dark"] {
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: var(--desktop-bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-apple-sans);
|
||||
font-feature-settings: "kern", "ss05";
|
||||
@@ -106,9 +135,156 @@ samp,
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Keep older Tailwind-only surfaces aligned with the app color tokens. */
|
||||
html[data-theme="dark"] .bg-white {
|
||||
.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: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.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(--color-line);
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.42) inset;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
background-color: var(--window-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,
|
||||
@@ -126,16 +302,6 @@ html[data-theme="dark"] .bg-red-100 {
|
||||
background-color: rgba(255, 69, 58, 0.13);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .bg-green-100 {
|
||||
background-color: rgba(48, 209, 88, 0.14);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .border-gray-100,
|
||||
html[data-theme="dark"] .border-gray-200,
|
||||
html[data-theme="dark"] .border-gray-300 {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .border-blue-100 {
|
||||
border-color: rgba(10, 132, 255, 0.28);
|
||||
}
|
||||
@@ -144,23 +310,6 @@ html[data-theme="dark"] .border-red-100 {
|
||||
border-color: rgba(255, 105, 97, 0.28);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .text-gray-950,
|
||||
html[data-theme="dark"] .text-gray-900,
|
||||
html[data-theme="dark"] .text-gray-800,
|
||||
html[data-theme="dark"] .text-gray-700 {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .text-gray-600,
|
||||
html[data-theme="dark"] .text-gray-500 {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .text-gray-400,
|
||||
html[data-theme="dark"] .text-gray-300 {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .text-blue-700,
|
||||
html[data-theme="dark"] .text-blue-600 {
|
||||
color: #8ecbff;
|
||||
@@ -176,10 +325,6 @@ html[data-theme="dark"] .text-green-600 {
|
||||
color: #8ff0a4;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -196,17 +341,6 @@ html[data-theme="dark"] .hover\:bg-red-50:hover {
|
||||
background-color: rgba(255, 69, 58, 0.16);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .hover\:text-gray-600:hover,
|
||||
html[data-theme="dark"] .hover\:text-gray-700:hover,
|
||||
html[data-theme="dark"] .hover\:text-gray-800:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .placeholder\:text-gray-300::placeholder,
|
||||
html[data-theme="dark"] .placeholder\:text-gray-400::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -218,50 +352,34 @@ html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]),
|
||||
html[data-theme="dark"] textarea,
|
||||
html[data-theme="dark"] select {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--color-line);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
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: rgba(255, 255, 255, 0.11);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] input::placeholder,
|
||||
html[data-theme="dark"] textarea::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] option {
|
||||
background-color: var(--color-surface-strong);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .shadow-xl,
|
||||
html[data-theme="dark"] .shadow-md,
|
||||
html[data-theme="dark"] .shadow-sm {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.w-md-editor {
|
||||
border-radius: 0.75rem !important; /* 둥근 모서리 */
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border-color: #e5e7eb !important;
|
||||
border-radius: 0.5rem !important;
|
||||
border-color: var(--color-line) !important;
|
||||
background-color: var(--window-bg-strong) !important;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar {
|
||||
border-radius: 0.75rem 0.75rem 0 0 !important;
|
||||
background-color: #f9fafb !important;
|
||||
border-radius: 0.5rem 0.5rem 0 0 !important;
|
||||
border-color: var(--color-line) !important;
|
||||
background-color: var(--window-titlebar) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .w-md-editor {
|
||||
background-color: var(--color-surface-strong) !important;
|
||||
background-color: var(--window-bg-strong) !important;
|
||||
border-color: var(--color-line) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .w-md-editor-toolbar {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
background-color: var(--window-titlebar) !important;
|
||||
border-color: var(--color-line) !important;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 TopHeader from '@/components/layout/TopHeader';
|
||||
import Script from 'next/script';
|
||||
import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
|
||||
|
||||
const pretendard = localFont({
|
||||
@@ -29,12 +30,11 @@ export default function RootLayout({
|
||||
<head>
|
||||
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
|
||||
</head>
|
||||
<body className={`${pretendard.variable} ${pretendard.className} bg-[var(--color-page)] text-[var(--color-text)] antialiased`}>
|
||||
<body className={`${pretendard.variable} ${pretendard.className} text-[var(--color-text)] antialiased`}>
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
|
||||
{/* 🌟 Google Analytics 스크립트 */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
||||
strategy="afterInteractive"
|
||||
@@ -50,17 +50,13 @@ export default function RootLayout({
|
||||
</Script>
|
||||
|
||||
<Providers>
|
||||
<div className="min-h-screen flex">
|
||||
{/* 사이드바 */}
|
||||
<div className="min-h-screen">
|
||||
<Sidebar />
|
||||
<DesktopMenuBar />
|
||||
<DesktopDock />
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { login } from '@/api/auth';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const getSafeRedirectPath = (value: string | null) => {
|
||||
@@ -58,11 +59,16 @@ function LoginForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[var(--color-page)] p-4">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-8 shadow-[var(--shadow-panel)] backdrop-blur-xl">
|
||||
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
||||
<WindowSurface
|
||||
title="Login"
|
||||
subtitle="WYPark OS"
|
||||
className="w-full max-w-md"
|
||||
bodyClassName="p-8"
|
||||
>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-[var(--color-text)]">로그인</h1>
|
||||
<p className="mt-2 text-[var(--color-text-muted)]">블로그에 오신 것을 환영합니다.</p>
|
||||
<p className="mt-2 text-[var(--color-text-muted)]">블로그 데스크톱으로 다시 들어갑니다.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -108,7 +114,7 @@ function LoginForm() {
|
||||
회원가입
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +123,7 @@ export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
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-[var(--color-accent)]" size={36} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ import { getPosts } from '@/api/posts';
|
||||
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 { Post, PostListResponse } from '@/types';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const categoryName = post.categoryName || '';
|
||||
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
@@ -65,7 +67,12 @@ function SearchResults({
|
||||
const searchTotalElements = getTotalElements(data);
|
||||
|
||||
return (
|
||||
<section className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<WindowSurface
|
||||
title="Search"
|
||||
subtitle={`"${keyword}"`}
|
||||
bodyClassName="p-5 md:p-6"
|
||||
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-2 border-b border-[var(--color-line)] pb-5">
|
||||
<div className="flex items-center gap-2 text-[var(--color-accent)]">
|
||||
<Search size={22} />
|
||||
@@ -91,7 +98,7 @@ function SearchResults({
|
||||
) : (
|
||||
<EmptyState title="검색 결과가 없습니다." description="다른 키워드로 다시 찾아보세요." />
|
||||
)}
|
||||
</section>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,18 +191,22 @@ function PostListPanel({
|
||||
isPopular?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Surface as="section" className="p-5 shadow-none md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<WindowSurface
|
||||
as="section"
|
||||
title={(
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">{title}</h2>
|
||||
</div>
|
||||
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-text-muted)] transition hover:text-[var(--color-accent)]">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
controls={(
|
||||
<Link href="/archive" className="inline-flex items-center gap-1 text-xs font-semibold text-[var(--color-text-muted)] transition hover:text-[var(--color-accent)]">
|
||||
전체 보기
|
||||
<ChevronRight size={15} />
|
||||
<ChevronRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
)}
|
||||
bodyClassName="p-5 md:p-6"
|
||||
>
|
||||
{posts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{posts.map((post, index) => (
|
||||
@@ -210,7 +221,7 @@ function PostListPanel({
|
||||
) : (
|
||||
<EmptyState title={isPopular ? '인기 글을 집계 중입니다.' : '아직 공개된 글이 없습니다.'} className="min-h-72" />
|
||||
)}
|
||||
</Surface>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,7 +261,7 @@ function HomeContent() {
|
||||
|
||||
if (keyword) {
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-4 py-8 md:px-6">
|
||||
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
|
||||
<SearchResults keyword={keyword} data={searchData} isLoading={isSearchLoading} />
|
||||
</main>
|
||||
);
|
||||
@@ -265,16 +276,22 @@ function HomeContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 md:px-6 md:py-12">
|
||||
<main className="mx-auto max-w-5xl px-1 py-4 md:px-3 md:py-6">
|
||||
<h1 className="sr-only">WYPark Blog</h1>
|
||||
<WindowSurface
|
||||
title="WYPark Desktop"
|
||||
subtitle="Blog dashboard"
|
||||
controls={(
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-text)] px-4 text-sm font-semibold text-[var(--color-page)] shadow-[var(--shadow-control)] transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
className="inline-flex h-8 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"
|
||||
>
|
||||
전체 글
|
||||
<Archive size={16} />
|
||||
<Archive size={14} />
|
||||
</Link>
|
||||
|
||||
)}
|
||||
bodyClassName="space-y-6 p-4 md:p-6"
|
||||
>
|
||||
<NoticeStrip notices={notices} />
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -290,6 +307,7 @@ function HomeContent() {
|
||||
posts={latestList}
|
||||
/>
|
||||
</section>
|
||||
</WindowSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signup, verifyEmail } from '@/api/auth';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { SignupRequest } from '@/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
||||
@@ -22,67 +23,69 @@ const getErrorMessage = (error: unknown) => {
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM'); // 단계 관리
|
||||
const [step, setStep] = useState<'FORM' | 'VERIFY'>('FORM');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState(''); // 인증할 이메일 저장
|
||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
|
||||
// React Hook Form 설정
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<SignupRequest>();
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
// 1단계: 회원가입 정보 제출
|
||||
const onSignupSubmit = async (data: SignupRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await signup(data);
|
||||
if (res.code === 'SUCCESS') {
|
||||
alert(`📧 ${data.email}로 인증 코드를 보냈습니다!`);
|
||||
setRegisteredEmail(data.email); // 이메일 기억하기
|
||||
setStep('VERIFY'); // 2단계로 이동
|
||||
alert(`${data.email}로 인증 코드를 보냈습니다.`);
|
||||
setRegisteredEmail(data.email);
|
||||
setStep('VERIFY');
|
||||
} else {
|
||||
alert('회원가입 실패: ' + res.message);
|
||||
alert(`회원가입 실패: ${res.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('오류 발생: ' + getErrorMessage(error));
|
||||
alert(`오류 발생: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 2단계: 인증 코드 제출
|
||||
const onVerifySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!verifyCode) return alert('인증 코드를 입력해주세요.');
|
||||
const onVerifySubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!verifyCode) {
|
||||
alert('인증 코드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await verifyEmail({ email: registeredEmail, code: verifyCode });
|
||||
if (res.code === 'SUCCESS') {
|
||||
alert('인증되었습니다! 로그인 페이지로 이동합니다.');
|
||||
alert('인증되었습니다. 로그인 페이지로 이동합니다.');
|
||||
router.push('/login');
|
||||
} else {
|
||||
alert('인증 실패: ' + res.message);
|
||||
alert(`인증 실패: ${res.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('오류 발생: ' + getErrorMessage(error));
|
||||
alert(`오류 발생: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--color-page)] px-4 py-12">
|
||||
<div className="w-full max-w-md rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-8 shadow-[var(--shadow-panel)] backdrop-blur-xl">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex min-h-[calc(100vh-14rem)] items-center justify-center px-1 py-8">
|
||||
<WindowSurface
|
||||
title="Signup"
|
||||
subtitle={step === 'FORM' ? 'Create account' : registeredEmail}
|
||||
className="w-full max-w-md"
|
||||
bodyClassName="p-8"
|
||||
>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text)]">회원가입</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
{step === 'FORM' ? '' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
||||
{step === 'FORM' ? '블로그 OS 계정을 만듭니다.' : '이메일로 전송된 6자리 코드를 입력하세요.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* STEP 1: 가입 정보 입력 폼 */}
|
||||
{step === 'FORM' && (
|
||||
<form onSubmit={handleSubmit(onSignupSubmit)} className="space-y-5">
|
||||
<div>
|
||||
@@ -90,12 +93,12 @@ export default function SignupPage() {
|
||||
<input
|
||||
{...register('email', {
|
||||
required: '이메일은 필수입니다.',
|
||||
pattern: { value: /\S+@\S+\.\S+/, message: '이메일 형식이 올바르지 않습니다.' }
|
||||
pattern: { value: /\S+@\S+\.\S+/, message: '이메일 형식이 올바르지 않습니다.' },
|
||||
})}
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
|
||||
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -105,18 +108,21 @@ export default function SignupPage() {
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="개발자"
|
||||
/>
|
||||
{errors.nickname && <p className="text-red-500 text-xs mt-1">{errors.nickname.message}</p>}
|
||||
{errors.nickname && <p className="mt-1 text-xs text-red-500">{errors.nickname.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password', { required: '비밀번호를 입력해주세요.', minLength: { value: 6, message: '6자 이상 입력해주세요.' } })}
|
||||
{...register('password', {
|
||||
required: '비밀번호를 입력해주세요.',
|
||||
minLength: { value: 6, message: '6자 이상 입력해주세요.' },
|
||||
})}
|
||||
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
|
||||
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -129,7 +135,6 @@ export default function SignupPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* STEP 2: 인증 코드 입력 폼 */}
|
||||
{step === 'VERIFY' && (
|
||||
<form onSubmit={onVerifySubmit} className="space-y-5">
|
||||
<div>
|
||||
@@ -137,7 +142,7 @@ export default function SignupPage() {
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
onChange={(event) => setVerifyCode(event.target.value)}
|
||||
maxLength={6}
|
||||
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"
|
||||
@@ -162,14 +167,13 @@ export default function SignupPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 하단 링크 */}
|
||||
<div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
|
||||
이미 계정이 있으신가요?{' '}
|
||||
<Link href="/login" className="font-semibold text-[var(--color-accent)] hover:underline">
|
||||
로그인하기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Folder, FolderOpen, FolderPlus, FolderTree, Loader2, Save, Trash2 } from 'lucide-react';
|
||||
import { createCategory, deleteCategory, getCategories, updateCategory } from '@/api/category';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { Category, CategoryUpdateRequest } from '@/types';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
@@ -342,7 +343,7 @@ export default function AdminCategoryPanel() {
|
||||
};
|
||||
|
||||
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>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
@@ -426,6 +427,6 @@ export default function AdminCategoryPanel() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { deleteAdminComment, getAdminComments } from '@/api/comments';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { AdminComment, AdminCommentListResponse, PageMeta } from '@/types';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -168,7 +169,7 @@ export default function AdminCommentsPanel() {
|
||||
};
|
||||
|
||||
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>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
@@ -283,6 +284,6 @@ export default function AdminCommentsPanel() {
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { deletePost, getPosts } from '@/api/posts';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { Category, PageMeta, Post, PostListResponse } from '@/types';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -399,7 +400,7 @@ export default function AdminPostsPanel() {
|
||||
};
|
||||
|
||||
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>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
@@ -665,6 +666,6 @@ export default function AdminPostsPanel() {
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
|
||||
import { Camera, Loader2, Save, UserRound } from 'lucide-react';
|
||||
import { getProfile, updateProfile } from '@/api/profile';
|
||||
import { uploadImage } from '@/api/image';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { Profile, ProfileUpdateRequest } from '@/types';
|
||||
|
||||
const defaultProfile: Profile = {
|
||||
@@ -206,7 +207,7 @@ export default function AdminProfilePanel() {
|
||||
].join('|');
|
||||
|
||||
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>
|
||||
<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} />
|
||||
)}
|
||||
</section>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { BarChart3, FileText, FolderTree, Loader2, MessageSquareText, UserRound } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const navItems = [
|
||||
@@ -57,8 +58,9 @@ 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">
|
||||
<WindowSurface title="Admin Console" subtitle="Operations toolbar" bodyClassName="p-2">
|
||||
<nav
|
||||
className="flex gap-2 overflow-x-auto rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
|
||||
className="flex gap-2 overflow-x-auto rounded-lg bg-[var(--color-control)] p-1 backdrop-blur-xl"
|
||||
aria-label="관리자 메뉴"
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
@@ -70,9 +72,9 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
'inline-flex h-9 shrink-0 items-center gap-2 rounded-full px-4 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
|
||||
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
|
||||
? 'bg-[var(--window-bg-strong)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
@@ -82,6 +84,7 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</WindowSurface>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import { getTodayChessPuzzle } from '@/api/chess';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { type ChessPuzzle } from '@/types';
|
||||
|
||||
type FeedbackTone = 'neutral' | 'success' | 'error';
|
||||
@@ -104,7 +104,7 @@ function LoadingState() {
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-3 py-6 md:px-6">
|
||||
<PageHeader />
|
||||
<section className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<Surface strong className="p-3 md:p-5">
|
||||
<WindowSurface title="Board" bodyClassName="p-3 md:p-5">
|
||||
<div className="mx-auto grid aspect-square w-full max-w-[min(78vh,680px)] grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)]">
|
||||
{BOARD_SQUARES.map((square, index) => (
|
||||
<div
|
||||
@@ -117,11 +117,11 @@ function LoadingState() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
<Surface strong as="aside" className="flex min-h-72 flex-col items-center justify-center p-5 text-center">
|
||||
</WindowSurface>
|
||||
<WindowSurface title="Puzzle" 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>
|
||||
</Surface>
|
||||
</WindowSurface>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -131,7 +131,7 @@ 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">
|
||||
<PageHeader />
|
||||
<Surface strong className="flex min-h-80 flex-col items-center justify-center p-8 text-center">
|
||||
<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} />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">오늘의 퍼즐을 불러오지 못했습니다.</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
@@ -145,7 +145,7 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
<RotateCcw size={16} />
|
||||
다시 시도
|
||||
</button>
|
||||
</Surface>
|
||||
</WindowSurface>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -267,7 +267,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
||||
<PageHeader />
|
||||
|
||||
<section className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<Surface strong className="p-3 md:p-5">
|
||||
<WindowSurface title="Board" bodyClassName="p-3 md:p-5">
|
||||
<div className="mx-auto grid aspect-square w-full max-w-[min(78vh,680px)] 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);
|
||||
@@ -339,9 +339,9 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
</WindowSurface>
|
||||
|
||||
<Surface strong as="aside" className="p-5">
|
||||
<WindowSurface title="Puzzle" as="aside" bodyClassName="p-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-[var(--color-text-subtle)]">
|
||||
@@ -409,7 +409,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
||||
Lichess 원문
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
</Surface>
|
||||
</WindowSurface>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
import { getComments } from '@/api/comments';
|
||||
import { Comment } from '@/types';
|
||||
import CommentForm from './CommentForm';
|
||||
import CommentItem from './CommentItem';
|
||||
import { Loader2, MessageCircle } from 'lucide-react';
|
||||
import { Comment } from '@/types';
|
||||
|
||||
interface CommentListProps {
|
||||
postSlug: string;
|
||||
}
|
||||
|
||||
export default function CommentList({ postSlug }: CommentListProps) {
|
||||
// 댓글 목록 조회
|
||||
const { data: comments, isLoading, error } = useQuery({
|
||||
queryKey: ['comments', postSlug],
|
||||
queryFn: () => getComments(postSlug),
|
||||
});
|
||||
|
||||
// 총 댓글 수 계산 (재귀)
|
||||
const countComments = (list: Comment[]): number => {
|
||||
if (!list) return 0;
|
||||
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
|
||||
@@ -27,7 +25,11 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
const totalCount = comments ? countComments(comments) : 0;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>;
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -35,20 +37,18 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-16 border-t border-[var(--color-line)] pt-10">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<MessageCircle className="text-[var(--color-accent)]" size={24} />
|
||||
<h3 className="text-xl font-bold text-[var(--color-text)]">
|
||||
댓글 <span className="text-[var(--color-accent)]">{totalCount}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 최상위 댓글 작성 폼 */}
|
||||
<div className="mb-10">
|
||||
<CommentForm postSlug={postSlug} />
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
<div className="space-y-6">
|
||||
{comments && comments.length > 0 ? (
|
||||
comments.map((comment) => (
|
||||
@@ -56,7 +56,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
|
||||
101
src/components/layout/DesktopDock.tsx
Normal file
101
src/components/layout/DesktopDock.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { clsx } from 'clsx';
|
||||
import { Archive, Crown, Home, LogIn, PenLine, Settings } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
type DockItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: typeof Home;
|
||||
isActive: (pathname: string) => boolean;
|
||||
};
|
||||
|
||||
export default function DesktopDock() {
|
||||
const pathname = usePathname();
|
||||
const { isLoggedIn, role, _hasHydrated } = useAuthStore();
|
||||
const isAdmin = _hasHydrated && isLoggedIn && role?.includes('ADMIN');
|
||||
|
||||
const items: DockItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: '홈',
|
||||
icon: Home,
|
||||
isActive: (currentPath) => currentPath === '/',
|
||||
},
|
||||
{
|
||||
href: '/archive',
|
||||
label: '아카이브',
|
||||
icon: Archive,
|
||||
isActive: (currentPath) => currentPath.startsWith('/archive'),
|
||||
},
|
||||
{
|
||||
href: '/play/chess',
|
||||
label: '체스',
|
||||
icon: Crown,
|
||||
isActive: (currentPath) => currentPath.startsWith('/play/chess'),
|
||||
},
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
items.push(
|
||||
{
|
||||
href: '/admin',
|
||||
label: '관리자',
|
||||
icon: Settings,
|
||||
isActive: (currentPath) => currentPath === '/admin',
|
||||
},
|
||||
{
|
||||
href: '/admin/posts/new',
|
||||
label: '글쓰기',
|
||||
icon: PenLine,
|
||||
isActive: (currentPath) => currentPath.startsWith('/admin/posts/new'),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
items.push({
|
||||
href: '/login',
|
||||
label: '로그인',
|
||||
icon: LogIn,
|
||||
isActive: (currentPath) => currentPath.startsWith('/login'),
|
||||
});
|
||||
}
|
||||
|
||||
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 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',
|
||||
active && 'bg-[var(--window-bg-strong)] text-[var(--color-accent)] ring-1 ring-[var(--color-accent-soft)]',
|
||||
)}
|
||||
>
|
||||
<Icon size={21} 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)]" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
162
src/components/layout/DesktopMenuBar.tsx
Normal file
162
src/components/layout/DesktopMenuBar.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { clsx } from 'clsx';
|
||||
import { LogOut, Menu, PenLine, Settings, User, UserPlus, X } from 'lucide-react';
|
||||
import ThemeToggle from '@/components/theme/ThemeToggle';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
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 'Finder';
|
||||
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 'Desktop';
|
||||
};
|
||||
|
||||
export default function DesktopMenuBar() {
|
||||
const router = useRouter();
|
||||
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 handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeMenu = () => setIsMenuOpen(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
closeMenu();
|
||||
logout();
|
||||
alert('로그아웃 되었습니다.');
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
const quietActionClass =
|
||||
'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"
|
||||
>
|
||||
<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">
|
||||
<Link href="/" className="shrink-0 text-sm font-bold text-[var(--color-text)]">
|
||||
WYPark OS
|
||||
</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"
|
||||
>
|
||||
{isMenuOpen ? <X size={17} /> : <Menu size={17} />}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -178,7 +178,7 @@ 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(--color-line)] bg-[var(--color-surface-strong)]/96 backdrop-blur-xl transition-transform duration-300 ease-out',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
@@ -308,7 +308,7 @@ function SidebarContent() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--color-line)] bg-[var(--color-surface-strong)]/80 p-5">
|
||||
<div className="shrink-0 border-t border-[var(--color-line)] bg-[var(--window-titlebar)] p-5">
|
||||
<div className="flex justify-center gap-3">
|
||||
<a
|
||||
href={displayProfile.githubUrl || '#'}
|
||||
@@ -338,7 +338,7 @@ function SidebarContent() {
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]" />}>
|
||||
<Suspense fallback={<div className="h-screen w-72 border-r border-[var(--window-border)] bg-[var(--sidebar-bg)]" />}>
|
||||
<SidebarContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { Post } from '@/types';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const categoryName = post.categoryName || '';
|
||||
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
'use client';
|
||||
|
||||
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 { getProfile } from '@/api/profile';
|
||||
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
|
||||
import CommentList from '@/components/comment/CommentList';
|
||||
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
|
||||
import TOC from '@/components/post/TOC';
|
||||
import { Loader2, Calendar, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import { Post } from '@/types'; // 타입 임포트
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import WindowSurface from '@/components/ui/WindowSurface';
|
||||
import { Post } from '@/types';
|
||||
|
||||
interface PostDetailClientProps {
|
||||
slug: string;
|
||||
initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수)
|
||||
initialPost: Post;
|
||||
}
|
||||
|
||||
const getPostErrorInfo = (error: unknown) => {
|
||||
@@ -37,12 +39,10 @@ const getPostErrorInfo = (error: unknown) => {
|
||||
export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 1. 게시글 상세 조회
|
||||
const { data: post, isLoading: isPostLoading, error } = useQuery({
|
||||
queryKey: ['post', slug],
|
||||
queryFn: () => getPost(slug),
|
||||
enabled: !!slug,
|
||||
// 🌟 핵심: 서버에서 가져온 데이터를 초기값으로 사용하여 즉시 렌더링 (Hydration)
|
||||
initialData: initialPost,
|
||||
});
|
||||
|
||||
@@ -51,25 +51,22 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
queryFn: getProfile,
|
||||
});
|
||||
|
||||
// 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다.
|
||||
// initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다.
|
||||
if (isPostLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 처리
|
||||
if (error || !post) {
|
||||
const { status: errorStatus, message } = getPostErrorInfo(error);
|
||||
const errorMessage = message || '게시글을 찾을 수 없습니다.';
|
||||
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-20 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<WindowSurface className="mx-auto max-w-4xl" bodyClassName="px-4 py-20 text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
|
||||
</div>
|
||||
<h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
|
||||
@@ -78,33 +75,42 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
<p className="mb-6 text-[var(--color-text-muted)]">
|
||||
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-strong)]">메인으로</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/')}
|
||||
className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-[var(--window-bg-strong)]"
|
||||
>
|
||||
메인으로
|
||||
</button>
|
||||
</WindowSurface>
|
||||
);
|
||||
}
|
||||
|
||||
// 백엔드 데이터 사용 (이전글/다음글)
|
||||
const prevPost = post.prevPost;
|
||||
const nextPost = post.nextPost;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-10 md:px-8 md:py-14">
|
||||
<Link href="/" className="mb-10 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
|
||||
<div className="mx-auto max-w-6xl px-1 py-4 md:px-3 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-8 xl:grid-cols-[minmax(0,780px)_210px] xl:justify-center xl:gap-16">
|
||||
|
||||
<main className="min-w-0">
|
||||
<article>
|
||||
<header className="mb-12 border-b border-[var(--color-line)] pb-8">
|
||||
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,780px)_220px] xl:justify-center xl:gap-8">
|
||||
<main className="min-w-0 space-y-6">
|
||||
<WindowSurface
|
||||
as="article"
|
||||
title="Reader"
|
||||
subtitle={post.categoryName || '미분류'}
|
||||
bodyClassName="p-6 md:p-10"
|
||||
>
|
||||
<header className="mb-10 border-b border-[var(--color-line)] pb-8">
|
||||
<StatusBadge tone="neutral" className="mb-5">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<h1 className="mb-7 break-keep text-4xl font-bold leading-[1.08] tracking-normal text-[var(--color-text)] md:text-6xl">{post.title}</h1>
|
||||
<h1 className="mb-7 break-keep text-4xl font-bold leading-[1.08] tracking-normal text-[var(--color-text)] md:text-6xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-5 text-sm text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{profile?.imageUrl ? (
|
||||
@@ -117,44 +123,76 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10"><User size={16} /></div>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10">
|
||||
<User size={16} />
|
||||
</div>
|
||||
)}
|
||||
<span className="font-bold text-[var(--color-text)]">{profile?.name || 'Dev Park'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString('ko-KR')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar size={16} />
|
||||
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg mb-20 max-w-none 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:bg-[#1e1e1e] prose-pre:text-gray-100">
|
||||
<div className="prose prose-lg max-w-none 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:bg-[#1e1e1e] prose-pre:text-gray-100">
|
||||
<MarkdownRenderer content={post.content || ''} />
|
||||
</div>
|
||||
</article>
|
||||
</WindowSurface>
|
||||
|
||||
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
|
||||
<WindowSurface title="Navigation" bodyClassName="p-4 md:p-5">
|
||||
<nav className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{prevPost ? (
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 transition-colors hover:bg-[var(--color-surface-strong)]">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{prevPost.title}</span>
|
||||
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-5 transition-colors hover:bg-[var(--window-bg-strong)]">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
|
||||
<ChevronLeft size={16} />
|
||||
이전 글
|
||||
</span>
|
||||
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">
|
||||
{prevPost.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : <div className="hidden w-full cursor-not-allowed rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] 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></div>}
|
||||
) : (
|
||||
<Surface className="hidden w-full cursor-not-allowed p-5 opacity-60 md:block">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">
|
||||
<ChevronLeft size={16} />
|
||||
이전 글 없음
|
||||
</span>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{nextPost ? (
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 transition-colors hover:bg-[var(--color-surface-strong)]">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{nextPost.title}</span>
|
||||
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-5 transition-colors hover:bg-[var(--window-bg-strong)]">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">
|
||||
다음 글
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">
|
||||
{nextPost.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] 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></div>}
|
||||
) : (
|
||||
<Surface className="hidden w-full cursor-not-allowed flex-col items-end gap-1 p-5 opacity-60 md:flex">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">
|
||||
다음 글 없음
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
</Surface>
|
||||
)}
|
||||
</nav>
|
||||
</WindowSurface>
|
||||
|
||||
<WindowSurface title="Comments" bodyClassName="p-5 md:p-6">
|
||||
<CommentList postSlug={post.slug} />
|
||||
</WindowSurface>
|
||||
</main>
|
||||
|
||||
<aside className="hidden w-[210px] shrink-0 xl:block">
|
||||
<div className="sticky top-24">
|
||||
<aside className="hidden w-[220px] shrink-0 xl:block">
|
||||
<WindowSurface title="Inspector" bodyClassName="p-4" className="sticky top-24">
|
||||
<TOC content={post.content || ''} />
|
||||
</div>
|
||||
</WindowSurface>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, Eye } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import { Post } from '@/types';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
@@ -10,7 +10,8 @@ interface PostListItemProps {
|
||||
}
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const categoryName = post.categoryName || '';
|
||||
return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ThemeToggle() {
|
||||
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(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
|
||||
? 'bg-[var(--window-bg-strong)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -19,9 +19,11 @@ export default function Surface({
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
|
||||
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-card)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
|
||||
interactive && 'transition duration-150 hover:border-black/15 hover:bg-[var(--color-surface-strong)] hover:shadow-[var(--shadow-control)] dark:hover:border-white/20',
|
||||
'rounded-lg border border-[var(--window-border)] backdrop-blur-2xl',
|
||||
strong
|
||||
? 'bg-[var(--window-bg-strong)] shadow-[var(--shadow-card)]'
|
||||
: 'bg-[var(--window-bg)] shadow-[var(--shadow-card)]',
|
||||
interactive && 'transition duration-150 hover:-translate-y-0.5 hover:border-black/15 hover:bg-[var(--window-bg-strong)] hover:shadow-[var(--shadow-control)] dark:hover:border-white/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
70
src/components/ui/WindowSurface.tsx
Normal file
70
src/components/ui/WindowSurface.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type WindowSurfaceElement = 'div' | 'section' | 'article' | 'aside' | 'main';
|
||||
|
||||
interface WindowSurfaceProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
as?: WindowSurfaceElement;
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
controls?: ReactNode;
|
||||
showTrafficLights?: boolean;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export default function WindowSurface({
|
||||
as: Component = 'section',
|
||||
title,
|
||||
subtitle,
|
||||
controls,
|
||||
showTrafficLights = true,
|
||||
className,
|
||||
bodyClassName,
|
||||
children,
|
||||
...props
|
||||
}: WindowSurfaceProps) {
|
||||
const hasTitlebar = Boolean(title || subtitle || controls || showTrafficLights);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
'overflow-hidden rounded-lg border border-[var(--window-border)] bg-[var(--window-bg)] shadow-[var(--shadow-window)] backdrop-blur-2xl',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hasTitlebar && (
|
||||
<div className="flex min-h-11 items-center justify-between gap-4 border-b border-[var(--color-line)] 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user