diff --git a/LOG.md b/LOG.md index 18f5722..963196f 100644 --- a/LOG.md +++ b/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. diff --git a/src/app/archive/page.tsx b/src/app/archive/page.tsx index 4045491..853e591 100644 --- a/src/app/archive/page.tsx +++ b/src/app/archive/page.tsx @@ -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,112 +42,108 @@ 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 ( -
+
); } return ( -
- {/* 헤더 섹션 */} -
-

- - 아카이브 -

-

- 지금까지 작성한 {totalPosts}개의 글이 기록되어 있습니다. -

-
- - {/* 타임라인 컨텐츠 */} - {sortedYears.length > 0 ? ( -
- {/* 타임라인 수직선 (좌측 장식) */} -
- - {sortedYears.map((year) => { - const months = archiveGroups[year]; - // 월 내림차순 정렬 - const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a)); - - return ( -
- {/* 연도 헤더 */} -
-
-
-
-

{year}

-
- - {/* 월별 그룹 */} -
- {sortedMonths.map((month) => { - const posts = months[month]; - // 게시글 날짜 내림차순 정렬 - const sortedPosts = posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - - return ( -
-

- - {month}월 - {posts.length} -

- -
- {sortedPosts.map((post) => ( - - -
-

- {post.title} -

-
- {post.categoryName || '미분류'} - - - {format(new Date(post.createdAt), 'yyyy.MM.dd')} - -
-
- -
- - ))} -
-
- ); - })} -
-
- ); - })} +
+ +
+

+ + 아카이브 +

+

+ 지금까지 작성한 {totalPosts}개의 글을 시간순으로 정리했습니다. +

- ) : ( - } - className="py-20" - /> - )} -
+ + {sortedYears.length > 0 ? ( +
+
+ + {sortedYears.map((year) => { + const months = archiveGroups[year]; + const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a)); + + return ( +
+
+
+
+
+

{year}

+
+ +
+ {sortedMonths.map((month) => { + const posts = months[month]; + const sortedPosts = [...posts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return ( +
+

+ + {month}월 + {posts.length} +

+ +
+ {sortedPosts.map((post) => ( + + +
+

+ {post.title} +

+
+ {post.categoryName || '미분류'} + · + + {format(new Date(post.createdAt), 'yyyy.MM.dd')} + +
+
+ +
+ + ))} +
+
+ ); + })} +
+
+ ); + })} +
+ ) : ( + } + className="py-20" + /> + )} + + ); } diff --git a/src/app/category/[id]/page.tsx b/src/app/category/[id]/page.tsx index 633a9c8..b4f681f 100644 --- a/src/app/category/[id]/page.tsx +++ b/src/app/category/[id]/page.tsx @@ -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 ( - - 게시글을 불러오는 중 오류가 발생했습니다.
+ + 게시글을 불러오는 중 오류가 발생했습니다. +
카테고리 이름을 확인해주세요. -
+ ); } 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) + const isLast = pagingData?.number !== undefined + ? 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 ( -
- {/* 헤더 영역 */} - -
+
+ +

- {apiCategoryName} 글 목록 + {apiCategoryName} + 글 목록

- {/* 🔍 카테고리 내 검색바 */} - {/* 뷰 모드 버튼 */}
- - {/* 검색 결과 안내 (검색 중일 때만 표시) */} - {keyword && ( -
- - - 검색어 {keyword} 결과: {totalElements}건 - -
- )} - - {posts.length === 0 ? ( - - ) : ( - <> - {activeViewMode === 'grid' ? ( -
- {posts.map((post) => ( - - ))} -
- ) : ( - - {posts.map((post) => ( - - ))} - - )} - -
- - - - 페이지 {page + 1} {totalPages > 0 && `/ ${totalPages}`} + {keyword && ( +
+ + + 검색어 {keyword} 결과: {totalElements} - -
- - )} -
+ )} + + {posts.length === 0 ? ( + + ) : ( + <> + {activeViewMode === 'grid' ? ( +
+ {posts.map((post) => ( + + ))} +
+ ) : ( + + {posts.map((post) => ( + + ))} + + )} + +
+ + + + 페이지 {page + 1} {totalPages > 0 && `/ ${totalPages}`} + + + +
+ + )} +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index d449ef1..358c660 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 70c1006..b6d834a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - + - {/* 🌟 Google Analytics 스크립트 */}