From aec3925d01c5fc637597fec3cd249a27f4e2ba48 Mon Sep 17 00:00:00 2001 From: uiuuoq Date: Fri, 24 Apr 2026 13:17:02 +0900 Subject: [PATCH] =?UTF-8?q?DP-409:=20=EC=B6=94=EC=B2=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=B3=B4=EA=B8=B0=20=EC=83=81=EC=84=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=203=EC=A2=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/my-page/recommend/book/page.tsx | 23 +++ app/(main)/my-page/recommend/home/page.tsx | 23 +++ app/(main)/my-page/recommend/video/page.tsx | 23 +++ .../my-page/quizzes/WrongQuizListItem.tsx | 2 +- .../my-page/recommend/RecommendedBookCard.tsx | 2 +- .../my-page/recommend/RecommendedBookList.tsx | 84 ++++++++++ .../recommend/RecommendedBookListItem.tsx | 63 +++++++ .../recommend/RecommendedHomePostList.tsx | 68 ++++++++ .../recommend/RecommendedHomePostListItem.tsx | 49 ++++++ .../my-page/recommend/RecommendedSection.tsx | 157 ++++++++++-------- .../recommend/RecommendedVideoList.tsx | 67 ++++++++ .../recommend/RecommendedVideoListItem.tsx | 53 ++++++ .../my-page/scraps/ScrappedPostListItem.tsx | 2 +- lib/mock/my-page-recommend-book.ts | 12 ++ types/myPage.ts | 2 + 15 files changed, 559 insertions(+), 71 deletions(-) create mode 100644 app/(main)/my-page/recommend/book/page.tsx create mode 100644 app/(main)/my-page/recommend/home/page.tsx create mode 100644 app/(main)/my-page/recommend/video/page.tsx create mode 100644 components/features/my-page/recommend/RecommendedBookList.tsx create mode 100644 components/features/my-page/recommend/RecommendedBookListItem.tsx create mode 100644 components/features/my-page/recommend/RecommendedHomePostList.tsx create mode 100644 components/features/my-page/recommend/RecommendedHomePostListItem.tsx create mode 100644 components/features/my-page/recommend/RecommendedVideoList.tsx create mode 100644 components/features/my-page/recommend/RecommendedVideoListItem.tsx diff --git a/app/(main)/my-page/recommend/book/page.tsx b/app/(main)/my-page/recommend/book/page.tsx new file mode 100644 index 0000000..d3796af --- /dev/null +++ b/app/(main)/my-page/recommend/book/page.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { RecommendedBookList } from "@/components/features/my-page/recommend/RecommendedBookList"; + +export default function RecommendBookPage() { + return ( +
+ + + 마이페이지 + + +

+ 추천 서적 +

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

+ 홈 추천 글 +

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

+ 추천 유튜브 +

+ + +
+ ); +} diff --git a/components/features/my-page/quizzes/WrongQuizListItem.tsx b/components/features/my-page/quizzes/WrongQuizListItem.tsx index b8b7f50..a88ef1d 100644 --- a/components/features/my-page/quizzes/WrongQuizListItem.tsx +++ b/components/features/my-page/quizzes/WrongQuizListItem.tsx @@ -19,7 +19,7 @@ export function WrongQuizListItem({ quiz }: { quiz: MyPageQuizHistory }) { return (
{thumbnail ? ( diff --git a/components/features/my-page/recommend/RecommendedBookCard.tsx b/components/features/my-page/recommend/RecommendedBookCard.tsx index 38d4c4e..db5ccd3 100644 --- a/components/features/my-page/recommend/RecommendedBookCard.tsx +++ b/components/features/my-page/recommend/RecommendedBookCard.tsx @@ -4,7 +4,7 @@ import type { MyPageRecommendBook } from "@/types/myPage"; export function RecommendedBookCard({ book }: { book: MyPageRecommendBook }) { const { title, authors, cover, url, publisher, publishedAt } = book; - const year = publishedAt.slice(0, 4); + const year = publishedAt ? publishedAt.slice(0, 4) : ""; return ( diff --git a/components/features/my-page/recommend/RecommendedBookList.tsx b/components/features/my-page/recommend/RecommendedBookList.tsx new file mode 100644 index 0000000..8b2a069 --- /dev/null +++ b/components/features/my-page/recommend/RecommendedBookList.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { RecommendedBookListItem } from "./RecommendedBookListItem"; +import { fetchRecommendBooks } from "@/lib/mock/my-page-recommend-book"; +import type { MyPageRecommendBook } from "@/types/myPage"; + +function ListItemSkeleton() { + return ( +
+ {/* 썸네일 */} + + +
+ {/* title */} + + + {/* description */} + + + + {/* authors */} + + + {/* bottom 영역 */} +
+ {/* publisher · year */} + + + {/* price */} + +
+
+
+ ); +} + +export function RecommendedBookList() { + const [books, setBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + fetchRecommendBooks() + .then((data) => setBooks(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, []); + + if (isError) { + return ( +

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

+ ); + } + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (books.length === 0) { + return ( +

+ 추천 서적이 없습니다. +

+ ); + } + + return ( +
+ {books.map((book) => ( + + ))} +
+ ); +} diff --git a/components/features/my-page/recommend/RecommendedBookListItem.tsx b/components/features/my-page/recommend/RecommendedBookListItem.tsx new file mode 100644 index 0000000..53507db --- /dev/null +++ b/components/features/my-page/recommend/RecommendedBookListItem.tsx @@ -0,0 +1,63 @@ +import Image from "next/image"; +import { BookOpen } from "lucide-react"; +import type { MyPageRecommendBook } from "@/types/myPage"; + +export function RecommendedBookListItem({ + book, +}: { + book: MyPageRecommendBook; +}) { + const { + title, + authors, + description, + cover, + url, + price, + publisher, + publishedAt, + } = book; + const year = publishedAt ? publishedAt.slice(0, 4) : ""; + + return ( +
+
+ {cover ? ( + {title} + ) : ( +
+ +
+ )} +
+ +
+

+ {title} +

+ + {description && ( +

+ {description} +

+ )} +

{authors.join(", ")}

+ + {publisher} + · + {year} + + {price && ( + + {price.toLocaleString()}원 + + )} +
+
+ ); +} diff --git a/components/features/my-page/recommend/RecommendedHomePostList.tsx b/components/features/my-page/recommend/RecommendedHomePostList.tsx new file mode 100644 index 0000000..e473e11 --- /dev/null +++ b/components/features/my-page/recommend/RecommendedHomePostList.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { RecommendedHomePostListItem } from "./RecommendedHomePostListItem"; +import { fetchRecommendHomePosts } from "@/lib/mock/my-page-recommend-home"; +import type { MyPageRecommendHomePost } from "@/types/myPage"; + +function ListItemSkeleton() { + return ( +
+ +
+ + + + +
+
+ ); +} + +export function RecommendedHomePostList() { + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + fetchRecommendHomePosts() + .then((data) => setPosts(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, []); + + if (isError) { + return ( +

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

+ ); + } + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (posts.length === 0) { + return ( +

+ 추천 글이 없습니다. +

+ ); + } + + return ( +
+ {posts.map((post) => ( + + ))} +
+ ); +} diff --git a/components/features/my-page/recommend/RecommendedHomePostListItem.tsx b/components/features/my-page/recommend/RecommendedHomePostListItem.tsx new file mode 100644 index 0000000..c3c9c93 --- /dev/null +++ b/components/features/my-page/recommend/RecommendedHomePostListItem.tsx @@ -0,0 +1,49 @@ +import Link from "next/link"; +import Image from "next/image"; +import { FileText } from "lucide-react"; +import { SourceLogo } from "@/components/features/home/SourceLogo"; +import { formatDate } from "@/lib/utils"; +import type { MyPageRecommendHomePost } from "@/types/myPage"; + +export function RecommendedHomePostListItem({ + post, +}: { + post: MyPageRecommendHomePost; +}) { + const { contentId, title, sourceName, thumbnail, summary, date } = post; + + return ( + +
+ {thumbnail ? ( + {title} + ) : ( +
+ +
+ )} +
+ +
+
+ + + {sourceName} + +
+

+ {title} +

+ {summary && ( +

{summary}

+ )} + + {formatDate(date)} + +
+ + ); +} diff --git a/components/features/my-page/recommend/RecommendedSection.tsx b/components/features/my-page/recommend/RecommendedSection.tsx index 02f1db3..2c5c451 100644 --- a/components/features/my-page/recommend/RecommendedSection.tsx +++ b/components/features/my-page/recommend/RecommendedSection.tsx @@ -16,13 +16,7 @@ import type { MyPageRecommendBook, } from "@/types/myPage"; -function SubSectionHeader({ - title, - href, -}: { - title: string; - href: string; -}) { +function SubSectionHeader({ title, href }: { title: string; href: string }) { return (

{title}

@@ -68,79 +62,106 @@ export function RecommendedSection() { const [videos, setVideos] = useState([]); const [books, setBooks] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); useEffect(() => { Promise.all([ fetchRecommendHomePosts(4), fetchRecommendVideos(4), fetchRecommendBooks(4), - ]).then(([posts, vids, bks]) => { - setHomePosts(posts); - setVideos(vids); - setBooks(bks); - setIsLoading(false); - }); + ]) + .then(([posts, vids, bks]) => { + setHomePosts(posts); + setVideos(vids); + setBooks(bks); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); }, []); + if (isError) { + return ( +
+

+ 사용자 맞춤 추천 +

+

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

+
+ ); + } + return ( -
-

추천

+
+

+ 사용자 맞춤 추천 +

-
- - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : homePosts.length === 0 ? ( -

추천 글이 없습니다.

- ) : ( -
- {homePosts.map((post) => ( - - ))} -
- )} -
+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : homePosts.length === 0 ? ( +

추천 글이 없습니다.

+ ) : ( +
+ {homePosts.map((post) => ( + + ))} +
+ )} +
-
- - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : videos.length === 0 ? ( -

추천 영상이 없습니다.

- ) : ( -
- {videos.map((video) => ( - - ))} -
- )} -
+
+ + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : videos.length === 0 ? ( +

+ 추천 영상이 없습니다. +

+ ) : ( +
+ {videos.map((video) => ( + + ))} +
+ )} +
-
- - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : books.length === 0 ? ( -

추천 서적이 없습니다.

- ) : ( -
- {books.map((book) => ( - - ))} -
- )} +
+ + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : books.length === 0 ? ( +

+ 추천 서적이 없습니다. +

+ ) : ( +
+ {books.map((book) => ( + + ))} +
+ )} +
); diff --git a/components/features/my-page/recommend/RecommendedVideoList.tsx b/components/features/my-page/recommend/RecommendedVideoList.tsx new file mode 100644 index 0000000..6aeb714 --- /dev/null +++ b/components/features/my-page/recommend/RecommendedVideoList.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { RecommendedVideoListItem } from "./RecommendedVideoListItem"; +import { fetchRecommendVideos } from "@/lib/mock/my-page-recommend-video"; +import type { MyPageRecommendVideo } from "@/types/myPage"; + +function ListItemSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} + +export function RecommendedVideoList() { + const [videos, setVideos] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + fetchRecommendVideos() + .then((data) => setVideos(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, []); + + if (isError) { + return ( +

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

+ ); + } + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (videos.length === 0) { + return ( +

+ 추천 영상이 없습니다. +

+ ); + } + + return ( +
+ {videos.map((video) => ( + + ))} +
+ ); +} diff --git a/components/features/my-page/recommend/RecommendedVideoListItem.tsx b/components/features/my-page/recommend/RecommendedVideoListItem.tsx new file mode 100644 index 0000000..dc4782b --- /dev/null +++ b/components/features/my-page/recommend/RecommendedVideoListItem.tsx @@ -0,0 +1,53 @@ +import Image from "next/image"; +import { Youtube } from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import type { MyPageRecommendVideo } from "@/types/myPage"; + +function formatViews(views: number): string { + if (views >= 10000) return `${Math.floor(views / 10000)}만`; + if (views >= 1000) return `${Math.floor(views / 1000)}천`; + return String(views); +} + +export function RecommendedVideoListItem({ + video, +}: { + video: MyPageRecommendVideo; +}) { + const { title, channelName, thumbnail, url, duration, views, uploadedAt } = + video; + + return ( + +
+ {thumbnail ? ( + {title} + ) : ( +
+ +
+ )} + + {duration} + +
+ +
+

+ {title} +

+

{channelName}

+ + 조회 {formatViews(views)} + · + {formatDate(uploadedAt)} + +
+
+ ); +} diff --git a/components/features/my-page/scraps/ScrappedPostListItem.tsx b/components/features/my-page/scraps/ScrappedPostListItem.tsx index 0e7bc20..37a236a 100644 --- a/components/features/my-page/scraps/ScrappedPostListItem.tsx +++ b/components/features/my-page/scraps/ScrappedPostListItem.tsx @@ -13,7 +13,7 @@ export function ScrappedPostListItem({ scrap }: { scrap: MyPageScrap }) { return (
{thumbnail ? ( diff --git a/lib/mock/my-page-recommend-book.ts b/lib/mock/my-page-recommend-book.ts index b63e4ad..97d6afe 100644 --- a/lib/mock/my-page-recommend-book.ts +++ b/lib/mock/my-page-recommend-book.ts @@ -5,8 +5,11 @@ export const MOCK_RECOMMEND_BOOKS: MyPageRecommendBook[] = [ bookId: "book-001", title: "클린 아키텍처", authors: ["로버트 C. 마틴"], + description: + "소프트웨어 구조 설계의 핵심 원칙과 클린 아키텍처의 개념을 설명하는 책.", cover: "https://picsum.photos/seed/book1/300/400", url: "https://www.yes24.com/Product/Goods/77283734", + price: 16000, publisher: "인사이트", publishedAt: "2019-08-20", }, @@ -14,8 +17,11 @@ export const MOCK_RECOMMEND_BOOKS: MyPageRecommendBook[] = [ bookId: "book-002", title: "가상 면접 사례로 배우는 대규모 시스템 설계 기초", authors: ["알렉스 쉬"], + description: + "대규모 시스템 설계 면접을 대비하기 위한 핵심 개념과 설계 방법을 다룬다.", cover: "https://picsum.photos/seed/book2/300/400", url: "https://www.yes24.com/Product/Goods/102819435", + price: 21000, publisher: "인사이트", publishedAt: "2021-11-24", }, @@ -23,8 +29,11 @@ export const MOCK_RECOMMEND_BOOKS: MyPageRecommendBook[] = [ bookId: "book-003", title: "이펙티브 타입스크립트", authors: ["댄 밴더캄"], + description: + "타입스크립트를 더 안전하고 효과적으로 사용하는 방법을 소개하는 실전 가이드.", cover: null, url: "https://www.yes24.com/Product/Goods/102124327", + price: 32000, publisher: "인사이트", publishedAt: "2021-11-12", }, @@ -32,8 +41,11 @@ export const MOCK_RECOMMEND_BOOKS: MyPageRecommendBook[] = [ bookId: "book-004", title: "데이터 중심 애플리케이션 설계", authors: ["마틴 클레퍼만"], + description: + "데이터 시스템의 설계 원칙과 분산 시스템의 핵심 개념을 깊이 있게 설명한다.", cover: "https://picsum.photos/seed/book4/300/400", url: "https://www.yes24.com/Product/Goods/59566585", + price: 28000, publisher: "위키북스", publishedAt: "2018-12-04", }, diff --git a/types/myPage.ts b/types/myPage.ts index b33b804..72aa059 100644 --- a/types/myPage.ts +++ b/types/myPage.ts @@ -47,8 +47,10 @@ export interface MyPageRecommendBook { bookId: string; title: string; authors: string[]; + description?: string; cover: string | null; url: string; + price?: number; publisher: string; publishedAt: string; }