From 7f160958dbef58d5e5f1fe5081a5850ff2884840 Mon Sep 17 00:00:00 2001 From: uiuuoq Date: Tue, 28 Apr 2026 14:06:17 +0900 Subject: [PATCH] =?UTF-8?q?DP-418:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?API=20=EC=8A=A4=ED=8E=99=20=EB=B0=98=EC=98=81=20(mock=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=84=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my-page/scraps/ScrappedPostsList.tsx | 114 ++++++++++-------- .../my-page/scraps/ScrappedPostsSection.tsx | 6 +- lib/mock/my-page-scraps.ts | 84 ++++++++----- types/myPage.ts | 11 +- 4 files changed, 136 insertions(+), 79 deletions(-) diff --git a/components/features/my-page/scraps/ScrappedPostsList.tsx b/components/features/my-page/scraps/ScrappedPostsList.tsx index 7f65ce9..e4af0f5 100644 --- a/components/features/my-page/scraps/ScrappedPostsList.tsx +++ b/components/features/my-page/scraps/ScrappedPostsList.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useReducer, useState } from "react"; import { ChevronDown, Search } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -13,12 +13,32 @@ import { import { ScrappedPostListItem } from "./ScrappedPostListItem"; import { MyPagePagination } from "../MyPagePagination"; import { fetchMyScraps } from "@/lib/mock/my-page-scraps"; -import type { MyPageScrap } from "@/types/myPage"; - -const PAGE_SIZE = 10; +import type { MyPageScrapResponse } from "@/types/myPage"; type SortOrder = "newest" | "oldest"; +type FetchState = { + data: MyPageScrapResponse | null; + isLoading: boolean; + isError: boolean; +}; + +type FetchAction = + | { type: "start" } + | { type: "success"; payload: MyPageScrapResponse } + | { type: "error" }; + +function fetchReducer(state: FetchState, action: FetchAction): FetchState { + switch (action.type) { + case "start": + return { ...state, isLoading: true, isError: false }; + case "success": + return { data: action.payload, isLoading: false, isError: false }; + case "error": + return { ...state, isLoading: false, isError: true }; + } +} + function ListItemSkeleton() { return (
@@ -34,43 +54,32 @@ function ListItemSkeleton() { } export function ScrappedPostsList() { - const [scraps, setScraps] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); + const [{ data, isLoading, isError }, dispatch] = useReducer(fetchReducer, { + data: null, + isLoading: true, + isError: false, + }); const [query, setQuery] = useState(""); const [sort, setSort] = useState("newest"); - const [currentPage, setCurrentPage] = useState(1); + const [page, setPage] = useState(0); useEffect(() => { - fetchMyScraps() - .then((data) => setScraps(data)) - .catch(() => setIsError(true)) - .finally(() => setIsLoading(false)); - }, []); + let cancelled = false; - const processed = useMemo(() => { - const q = query.trim().toLowerCase(); - const filtered = q - ? scraps.filter( - (s) => - s.title.toLowerCase().includes(q) || - s.sourceName.toLowerCase().includes(q) || - (s.summary?.toLowerCase().includes(q) ?? false), - ) - : scraps; + dispatch({ type: "start" }); - return [...filtered].sort((a, b) => { - const diff = - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - return sort === "newest" ? -diff : diff; - }); - }, [scraps, query, sort]); + fetchMyScraps({ q: query || undefined, sort, page, size: 10 }) + .then((res) => { + if (!cancelled) dispatch({ type: "success", payload: res }); + }) + .catch(() => { + if (!cancelled) dispatch({ type: "error" }); + }); - const totalPages = Math.ceil(processed.length / PAGE_SIZE); - const pagedItems = processed.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE, - ); + return () => { + cancelled = true; + }; + }, [query, sort, page]); if (isLoading) { return ( @@ -94,7 +103,7 @@ export function ScrappedPostsList() {
- {processed.length}개 + {data?.totalElements ?? 0}개
@@ -103,7 +112,10 @@ export function ScrappedPostsList() { type="text" placeholder="검색" value={query} - onChange={(e) => { setQuery(e.target.value); setCurrentPage(1); }} + onChange={(e) => { + setQuery(e.target.value); + setPage(0); + }} className="h-8 w-40 rounded-md border border-border bg-background pl-7 pr-3 text-sm placeholder:text-muted-foreground focus:outline-none" />
@@ -115,12 +127,18 @@ export function ScrappedPostsList() { { setSort(v as SortOrder); setCurrentPage(1); }} + onValueChange={(v) => { + setSort(v as SortOrder); + setPage(0); + }} > 최신순 - + 오래된순 @@ -129,23 +147,25 @@ export function ScrappedPostsList() {
- {processed.length === 0 ? ( + {data?.totalElements === 0 ? (

{query ? "검색 결과가 없습니다." : "스크랩한 글이 없습니다."}

) : ( <>
- {pagedItems.map((scrap) => ( - + {data?.content.map((scrap) => ( + ))}
- + {data && data.totalPages > 1 && ( + setPage(nextPage - 1)} + className="mt-8 mb-12" + /> + )} )}
diff --git a/components/features/my-page/scraps/ScrappedPostsSection.tsx b/components/features/my-page/scraps/ScrappedPostsSection.tsx index b3c40bf..0e1bba1 100644 --- a/components/features/my-page/scraps/ScrappedPostsSection.tsx +++ b/components/features/my-page/scraps/ScrappedPostsSection.tsx @@ -13,8 +13,8 @@ export function ScrappedPostsSection() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { - fetchMyScrapsPreview(4).then((data) => { - setScraps(data); + fetchMyScrapsPreview(4).then((res) => { + setScraps(res.content); setIsLoading(false); }); }, []); @@ -56,7 +56,7 @@ export function ScrappedPostsSection() { ) : (
{scraps.map((scrap) => ( - + ))}
)} diff --git a/lib/mock/my-page-scraps.ts b/lib/mock/my-page-scraps.ts index f322d01..2c137e4 100644 --- a/lib/mock/my-page-scraps.ts +++ b/lib/mock/my-page-scraps.ts @@ -1,122 +1,152 @@ -import type { MyPageScrap } from "@/types/myPage"; +import type { MyPageScrap, MyPageScrapResponse } from "@/types/myPage"; export const MOCK_SCRAPS: MyPageScrap[] = [ { - id: "1", contentId: "content-101", title: "React 19의 새로운 기능들: useActionState, useFormStatus 완벽 정리", sourceName: "medium", thumbnail: "https://picsum.photos/seed/scrap1/400/240", - createdAt: "2026-04-20T09:15:00Z", summary: "React 19에서 도입된 훅들과 Server Actions 통합 방식을 예제 코드와 함께 살펴봅니다.", + createdAt: "2026-04-20T09:15:00Z", }, { - id: "2", contentId: "content-102", title: "TypeScript 5.5 주요 변경사항 — infer 키워드 개선과 strictness 강화", sourceName: "naver_d2", thumbnail: "https://picsum.photos/seed/scrap2/400/240", - createdAt: "2026-04-18T14:30:00Z", summary: "TypeScript 5.5의 핵심 업데이트를 마이그레이션 관점에서 정리한 글입니다.", + createdAt: "2026-04-18T14:30:00Z", }, { - id: "3", contentId: "content-103", - title: "Next.js App Router에서 Server Component와 Client Component 경계 설계하기", + title: + "Next.js App Router에서 Server Component와 Client Component 경계 설계하기", sourceName: "kakao_tech", thumbnail: "https://picsum.photos/seed/scrap3/400/240", + summary: null, createdAt: "2026-04-15T11:00:00Z", }, { - id: "4", contentId: "content-104", title: "PostgreSQL EXPLAIN ANALYZE 읽는 법 — 쿼리 최적화 실전 가이드", sourceName: "stack overflow", thumbnail: null, - createdAt: "2026-04-13T08:45:00Z", summary: "슬로우 쿼리를 분석하고 인덱스 전략을 수립하는 방법을 다룹니다.", + createdAt: "2026-04-13T08:45:00Z", }, { - id: "5", contentId: "content-105", title: "Docker Compose로 로컬 개발 환경 구성하기 — 실전 템플릿 공개", sourceName: "velog", thumbnail: "https://picsum.photos/seed/scrap5/400/240", + summary: null, createdAt: "2026-04-10T16:20:00Z", }, { - id: "6", contentId: "content-106", title: "REST API 설계 원칙 — 버저닝 전략과 에러 응답 포맷 표준화", sourceName: "medium", thumbnail: "https://picsum.photos/seed/scrap6/400/240", - createdAt: "2026-04-08T10:00:00Z", summary: "실무에서 자주 마주치는 API 설계 결정들을 사례 중심으로 설명합니다.", + createdAt: "2026-04-08T10:00:00Z", }, { - id: "7", contentId: "content-107", title: "Redis 캐싱 전략 비교 — Cache-Aside, Write-Through, Write-Behind", sourceName: "toss_tech", thumbnail: null, + summary: null, createdAt: "2026-04-05T13:10:00Z", }, { - id: "8", contentId: "content-108", title: "Zustand v5 마이그레이션 가이드 — 스토어 구조와 미들웨어 변경점", sourceName: "velog", thumbnail: "https://picsum.photos/seed/scrap8/400/240", - createdAt: "2026-04-02T09:30:00Z", summary: "Zustand v4에서 v5로 업그레이드할 때 꼭 확인해야 할 변경사항들을 정리했습니다.", + createdAt: "2026-04-02T09:30:00Z", }, { - id: "9", contentId: "content-109", title: "Tailwind CSS v4 마이그레이션 — @theme 토큰과 CSS 변수 전환", sourceName: "kakao_tech", thumbnail: "https://picsum.photos/seed/scrap9/400/240", + summary: + "v3에서 v4로 넘어갈 때 바뀐 설정 방식과 토큰 시스템을 정리합니다.", createdAt: "2026-03-30T10:00:00Z", - summary: "v3에서 v4로 넘어갈 때 바뀐 설정 방식과 토큰 시스템을 정리합니다.", }, { - id: "10", contentId: "content-110", title: "Kubernetes 입문 — Pod, Service, Deployment 핵심 개념 정리", sourceName: "naver_d2", thumbnail: null, - createdAt: "2026-03-27T14:00:00Z", summary: "컨테이너 오케스트레이션의 핵심 리소스를 예제와 함께 설명합니다.", + createdAt: "2026-03-27T14:00:00Z", }, { - id: "11", contentId: "content-111", title: "웹 성능 최적화 — Core Web Vitals 개선 실전 사례", sourceName: "toss_tech", thumbnail: "https://picsum.photos/seed/scrap11/400/240", - createdAt: "2026-03-24T09:00:00Z", summary: "LCP, CLS, INP 지표를 실제 서비스에서 개선한 경험을 공유합니다.", + createdAt: "2026-03-24T09:00:00Z", }, { - id: "12", contentId: "content-112", title: "Git 브랜치 전략 — Trunk Based Development vs Git Flow 비교", sourceName: "medium", thumbnail: "https://picsum.photos/seed/scrap12/400/240", + summary: null, createdAt: "2026-03-20T11:30:00Z", }, ]; -export async function fetchMyScraps(): Promise { +type FetchMyScrapsParams = { + q?: string; + sort?: "newest" | "oldest"; + page?: number; + size?: number; +}; + +export async function fetchMyScraps( + params?: FetchMyScrapsParams, +): Promise { await new Promise((resolve) => setTimeout(resolve, 400)); - return MOCK_SCRAPS; + + const { q, sort = "newest", page = 0, size = 10 } = params ?? {}; + + let processed = [...MOCK_SCRAPS]; + + if (q) { + const keyword = q.trim().toLowerCase(); + processed = processed.filter( + (s) => + s.title.toLowerCase().includes(keyword) || + s.sourceName.toLowerCase().includes(keyword) || + (s.summary?.toLowerCase().includes(keyword) ?? false), + ); + } + + processed.sort((a, b) => { + const diff = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + return sort === "newest" ? -diff : diff; + }); + + const totalElements = processed.length; + const totalPages = Math.ceil(totalElements / size); + const start = page * size; + const content = processed.slice(start, start + size); + + return { content, page, size, totalElements, totalPages }; } -export async function fetchMyScrapsPreview(count = 4): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - return MOCK_SCRAPS.slice(0, count); +export async function fetchMyScrapsPreview( + count = 4, +): Promise { + return fetchMyScraps({ page: 0, size: count, sort: "newest" }); } diff --git a/types/myPage.ts b/types/myPage.ts index 72aa059..618afe8 100644 --- a/types/myPage.ts +++ b/types/myPage.ts @@ -14,13 +14,20 @@ export interface MyPageQuizHistory { } export interface MyPageScrap { - id: string; contentId: string; title: string; sourceName: string; thumbnail: string | null; + summary: string | null; createdAt: string; - summary?: string; +} + +export interface MyPageScrapResponse { + content: MyPageScrap[]; + page: number; + size: number; + totalElements: number; + totalPages: number; } export interface MyPageRecommendHomePost {