diff --git a/app/(main)/my-page/scraps/page.tsx b/app/(main)/my-page/scraps/page.tsx new file mode 100644 index 0000000..33ee8b5 --- /dev/null +++ b/app/(main)/my-page/scraps/page.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { ScrappedPostsList } from "@/components/features/my-page/ScrappedPostsList"; + +export default function ScrapsPage() { + return ( +
+ + + 마이페이지 + + +

+ 스크랩한 글들 +

+ + +
+ ); +} diff --git a/components/features/home/search/HomeTopPostsSection.tsx b/components/features/home/search/HomeTopPostsSection.tsx index 62dc8f0..067859f 100644 --- a/components/features/home/search/HomeTopPostsSection.tsx +++ b/components/features/home/search/HomeTopPostsSection.tsx @@ -26,7 +26,7 @@ function ChangeRate({ rate }: { rate: number }) { function TopPostCard({ post }: { post: TrendTopPost }) { return ( -
+
{/* 썸네일 영역 */}
{post.thumbnailUrl ? ( @@ -66,7 +66,7 @@ function TopPostCard({ post }: { post: TrendTopPost }) { function TopPostCardSkeleton() { return ( -
+
diff --git a/components/features/my-page/MyPage.tsx b/components/features/my-page/MyPage.tsx index b69e580..1ee78df 100644 --- a/components/features/my-page/MyPage.tsx +++ b/components/features/my-page/MyPage.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ChevronRight } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { ScrappedPostsSection } from "./ScrappedPostsSection"; function SectionHeader({ title, href }: { title: string; href?: string }) { return ( @@ -43,25 +44,16 @@ export default function MyPage() {

- {/* 1. 로드맵 */} -
- - -
- - {/* 2. 스크랩한 글들 */} -
- - -
+ {/* 1. 스크랩한 글들 */} + - {/* 3. 틀린 퀴즈들 */} + {/* 2. 틀린 퀴즈들 */}
- {/* 4. 추천 */} + {/* 3. 추천 */}
diff --git a/components/features/my-page/ScrappedPostCard.tsx b/components/features/my-page/ScrappedPostCard.tsx new file mode 100644 index 0000000..c10219f --- /dev/null +++ b/components/features/my-page/ScrappedPostCard.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { Bookmark } from "lucide-react"; +import { SourceLogo } from "@/components/features/home/SourceLogo"; +import { formatDate } from "@/lib/utils"; +import type { MyPageScrap } from "@/types/myPage"; + +export function ScrappedPostCard({ scrap }: { scrap: MyPageScrap }) { + const { contentId, title, sourceName, thumbnail, createdAt } = scrap; + + return ( + + {thumbnail ? ( +
+ {title} +
+ ) : ( +
+ +
+ )} + +
+
+ + + {sourceName} + +
+ +

+ {title} +

+ + + {formatDate(createdAt)} + +
+ + ); +} diff --git a/components/features/my-page/ScrappedPostListItem.tsx b/components/features/my-page/ScrappedPostListItem.tsx new file mode 100644 index 0000000..0e7bc20 --- /dev/null +++ b/components/features/my-page/ScrappedPostListItem.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { Bookmark } from "lucide-react"; +import { SourceLogo } from "@/components/features/home/SourceLogo"; +import { formatDate } from "@/lib/utils"; +import type { MyPageScrap } from "@/types/myPage"; + +export function ScrappedPostListItem({ scrap }: { scrap: MyPageScrap }) { + const { contentId, title, sourceName, thumbnail, createdAt, summary } = scrap; + + return ( + +
+ {thumbnail ? ( + {title} + ) : ( +
+ +
+ )} +
+ +
+
+ + + {sourceName} + +
+

+ {title} +

+ {summary && ( +

+ {summary} +

+ )} + + {formatDate(createdAt)} + +
+ + ); +} diff --git a/components/features/my-page/ScrappedPostsList.tsx b/components/features/my-page/ScrappedPostsList.tsx new file mode 100644 index 0000000..f309ea9 --- /dev/null +++ b/components/features/my-page/ScrappedPostsList.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { ChevronDown, Search } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrappedPostListItem } from "./ScrappedPostListItem"; +import { fetchMyScraps } from "@/lib/mock/my-page-scraps"; +import type { MyPageScrap } from "@/types/myPage"; + +type SortOrder = "newest" | "oldest"; + +function ListItemSkeleton() { + return ( +
+ +
+ + + + +
+
+ ); +} + +export function ScrappedPostsList() { + const [scraps, setScraps] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("newest"); + + useEffect(() => { + fetchMyScraps() + .then((data) => setScraps(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(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; + + 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]); + + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (isError) { + return ( +

+ 불러오는 중 오류가 발생했습니다. +

+ ); + } + + return ( +
+
+ + {processed.length}개 + +
+
+ + setQuery(e.target.value)} + 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" + /> +
+ + + {sort === "newest" ? "최신순" : "오래된순"} + + + + setSort(v as SortOrder)} + > + + 최신순 + + + 오래된순 + + + + +
+
+ + {processed.length === 0 ? ( +

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

+ ) : ( +
+ {processed.map((scrap) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/features/my-page/ScrappedPostsSection.tsx b/components/features/my-page/ScrappedPostsSection.tsx new file mode 100644 index 0000000..b3c40bf --- /dev/null +++ b/components/features/my-page/ScrappedPostsSection.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ScrappedPostCard } from "./ScrappedPostCard"; +import { fetchMyScrapsPreview } from "@/lib/mock/my-page-scraps"; +import type { MyPageScrap } from "@/types/myPage"; + +export function ScrappedPostsSection() { + const [scraps, setScraps] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchMyScrapsPreview(4).then((data) => { + setScraps(data); + setIsLoading(false); + }); + }, []); + + return ( +
+
+

+ 스크랩한 글들 +

+ + + 전체 보기 + +
+ + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+ ) : scraps.length === 0 ? ( +

스크랩한 글이 없습니다.

+ ) : ( +
+ {scraps.map((scrap) => ( + + ))} +
+ )} +
+ ); +} diff --git a/lib/mock/my-page-scraps.ts b/lib/mock/my-page-scraps.ts new file mode 100644 index 0000000..49d2680 --- /dev/null +++ b/lib/mock/my-page-scraps.ts @@ -0,0 +1,87 @@ +import type { MyPageScrap } 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 통합 방식을 예제 코드와 함께 살펴봅니다.", + }, + { + 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의 핵심 업데이트를 마이그레이션 관점에서 정리한 글입니다.", + }, + { + id: "3", + contentId: "content-103", + title: "Next.js App Router에서 Server Component와 Client Component 경계 설계하기", + sourceName: "kakao_tech", + thumbnail: "https://picsum.photos/seed/scrap3/400/240", + 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: "슬로우 쿼리를 분석하고 인덱스 전략을 수립하는 방법을 다룹니다.", + }, + { + id: "5", + contentId: "content-105", + title: "Docker Compose로 로컬 개발 환경 구성하기 — 실전 템플릿 공개", + sourceName: "velog", + thumbnail: "https://picsum.photos/seed/scrap5/400/240", + 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 설계 결정들을 사례 중심으로 설명합니다.", + }, + { + id: "7", + contentId: "content-107", + title: "Redis 캐싱 전략 비교 — Cache-Aside, Write-Through, Write-Behind", + sourceName: "toss_tech", + thumbnail: 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로 업그레이드할 때 꼭 확인해야 할 변경사항들을 정리했습니다.", + }, +]; + +export async function fetchMyScraps(): Promise { + await new Promise((resolve) => setTimeout(resolve, 400)); + return MOCK_SCRAPS; +} + +export async function fetchMyScrapsPreview(count = 4): Promise { + await new Promise((resolve) => setTimeout(resolve, 400)); + return MOCK_SCRAPS.slice(0, count); +} diff --git a/my-page-detail.jpg b/my-page-detail.jpg new file mode 100644 index 0000000..b6a37e9 Binary files /dev/null and b/my-page-detail.jpg differ diff --git a/types/myPage.ts b/types/myPage.ts new file mode 100644 index 0000000..c0cfaa8 --- /dev/null +++ b/types/myPage.ts @@ -0,0 +1,9 @@ +export interface MyPageScrap { + id: string; + contentId: string; + title: string; + sourceName: string; + thumbnail: string | null; + createdAt: string; + summary?: string; +}