Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 67 additions & 47 deletions components/features/my-page/scraps/ScrappedPostsList.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<div className="-mx-2 flex gap-4 px-2 py-3">
Expand All @@ -34,43 +54,32 @@ function ListItemSkeleton() {
}

export function ScrappedPostsList() {
const [scraps, setScraps] = useState<MyPageScrap[]>([]);
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<SortOrder>("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 (
Expand All @@ -94,7 +103,7 @@ export function ScrappedPostsList() {
<div>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground">
{processed.length}개
{data?.totalElements ?? 0}개
</span>
<div className="flex items-center gap-2 sm:justify-end">
<div className="relative">
Expand All @@ -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"
/>
</div>
Expand All @@ -115,12 +127,18 @@ export function ScrappedPostsList() {
<DropdownMenuContent align="end" className="min-w-[6rem] p-1">
<DropdownMenuRadioGroup
value={sort}
onValueChange={(v) => { setSort(v as SortOrder); setCurrentPage(1); }}
onValueChange={(v) => {
setSort(v as SortOrder);
setPage(0);
}}
>
<DropdownMenuRadioItem className="cursor-pointer" value="newest">
최신순
</DropdownMenuRadioItem>
<DropdownMenuRadioItem className="cursor-pointer" value="oldest">
<DropdownMenuRadioItem
className="cursor-pointer"
value="oldest"
>
오래된순
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
Expand All @@ -129,23 +147,25 @@ export function ScrappedPostsList() {
</div>
</div>

{processed.length === 0 ? (
{data?.totalElements === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
{query ? "검색 결과가 없습니다." : "스크랩한 글이 없습니다."}
</p>
) : (
<>
<div className="divide-y divide-border">
{pagedItems.map((scrap) => (
<ScrappedPostListItem key={scrap.id} scrap={scrap} />
{data?.content.map((scrap) => (
<ScrappedPostListItem key={scrap.contentId} scrap={scrap} />
))}
</div>
<MyPagePagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
className="mt-8 mb-12"
/>
{data && data.totalPages > 1 && (
<MyPagePagination
currentPage={page + 1}
totalPages={data.totalPages}
onPageChange={(nextPage) => setPage(nextPage - 1)}
className="mt-8 mb-12"
/>
)}
</>
)}
</div>
Expand Down
6 changes: 3 additions & 3 deletions components/features/my-page/scraps/ScrappedPostsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}, []);
Expand Down Expand Up @@ -56,7 +56,7 @@ export function ScrappedPostsSection() {
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{scraps.map((scrap) => (
<ScrappedPostCard key={scrap.id} scrap={scrap} />
<ScrappedPostCard key={scrap.contentId} scrap={scrap} />
))}
</div>
)}
Expand Down
84 changes: 57 additions & 27 deletions lib/mock/my-page-scraps.ts
Original file line number Diff line number Diff line change
@@ -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<MyPageScrap[]> {
type FetchMyScrapsParams = {
q?: string;
sort?: "newest" | "oldest";
page?: number;
size?: number;
};

export async function fetchMyScraps(
params?: FetchMyScrapsParams,
): Promise<MyPageScrapResponse> {
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<MyPageScrap[]> {
await new Promise((resolve) => setTimeout(resolve, 400));
return MOCK_SCRAPS.slice(0, count);
export async function fetchMyScrapsPreview(
count = 4,
): Promise<MyPageScrapResponse> {
return fetchMyScraps({ page: 0, size: count, sort: "newest" });
}
Loading
Loading