From 5e87257f767435377ffa1dbadbd5aa336e0cf7da Mon Sep 17 00:00:00 2001 From: uiuuoq Date: Fri, 24 Apr 2026 12:42:52 +0900 Subject: [PATCH] =?UTF-8?q?DP-408:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EC=B2=9C=20=EC=84=B9=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=ED=8B=80=EB=A6=B0=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9/=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/my-page/wrong-quizzes/page.tsx | 9 +- components/features/my-page/MyPage.tsx | 50 +----- .../quizzes/WrongQuizListItemSkeleton.tsx | 14 ++ .../my-page/quizzes/WrongQuizListWrapper.tsx | 46 ++++++ .../my-page/quizzes/WrongQuizSection.tsx | 21 ++- .../my-page/recommend/RecommendedBookCard.tsx | 43 +++++ .../recommend/RecommendedHomePostCard.tsx | 48 ++++++ .../my-page/recommend/RecommendedSection.tsx | 147 ++++++++++++++++++ .../recommend/RecommendedVideoCard.tsx | 51 ++++++ lib/mock/my-page-recommend-book.ts | 47 ++++++ lib/mock/my-page-recommend-home.ts | 42 +++++ lib/mock/my-page-recommend-video.ts | 51 ++++++ types/myPage.ts | 30 ++++ 13 files changed, 540 insertions(+), 59 deletions(-) create mode 100644 components/features/my-page/quizzes/WrongQuizListItemSkeleton.tsx create mode 100644 components/features/my-page/quizzes/WrongQuizListWrapper.tsx create mode 100644 components/features/my-page/recommend/RecommendedBookCard.tsx create mode 100644 components/features/my-page/recommend/RecommendedHomePostCard.tsx create mode 100644 components/features/my-page/recommend/RecommendedSection.tsx create mode 100644 components/features/my-page/recommend/RecommendedVideoCard.tsx create mode 100644 lib/mock/my-page-recommend-book.ts create mode 100644 lib/mock/my-page-recommend-home.ts create mode 100644 lib/mock/my-page-recommend-video.ts diff --git a/app/(main)/my-page/wrong-quizzes/page.tsx b/app/(main)/my-page/wrong-quizzes/page.tsx index 256f60a..5c0117e 100644 --- a/app/(main)/my-page/wrong-quizzes/page.tsx +++ b/app/(main)/my-page/wrong-quizzes/page.tsx @@ -1,11 +1,8 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; -import { WrongQuizList } from "@/components/features/my-page/quizzes/WrongQuizList"; -import { fetchMyWrongQuizzes } from "@/lib/mock/my-page-wrong-quizzes"; - -export default async function WrongQuizzesPage() { - const quizzes = await fetchMyWrongQuizzes(); +import { WrongQuizListWrapper } from "@/components/features/my-page/quizzes/WrongQuizListWrapper"; +export default function WrongQuizzesPage() { return (
- +
); } diff --git a/components/features/my-page/MyPage.tsx b/components/features/my-page/MyPage.tsx index b00e6aa..c70585b 100644 --- a/components/features/my-page/MyPage.tsx +++ b/components/features/my-page/MyPage.tsx @@ -1,37 +1,8 @@ "use client"; -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; -import { Skeleton } from "@/components/ui/skeleton"; import { ScrappedPostsSection } from "./scraps/ScrappedPostsSection"; import { WrongQuizSection } from "./quizzes/WrongQuizSection"; - -function SectionHeader({ title, href }: { title: string; href?: string }) { - return ( -
-

{title}

- {href && ( - - 전체 보기 - - - )} -
- ); -} - -function SkeletonCards({ count = 4 }: { count?: number }) { - return ( -
- {Array.from({ length: count }).map((_, i) => ( - - ))} -
- ); -} +import { RecommendedSection } from "./recommend/RecommendedSection"; export default function MyPage() { return ( @@ -52,24 +23,7 @@ export default function MyPage() { {/* 3. 추천 */} -
- - -
- - -
- -
- - -
- -
- - -
-
+ ); } diff --git a/components/features/my-page/quizzes/WrongQuizListItemSkeleton.tsx b/components/features/my-page/quizzes/WrongQuizListItemSkeleton.tsx new file mode 100644 index 0000000..04d772c --- /dev/null +++ b/components/features/my-page/quizzes/WrongQuizListItemSkeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function WrongQuizListItemSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/components/features/my-page/quizzes/WrongQuizListWrapper.tsx b/components/features/my-page/quizzes/WrongQuizListWrapper.tsx new file mode 100644 index 0000000..385d7f0 --- /dev/null +++ b/components/features/my-page/quizzes/WrongQuizListWrapper.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { WrongQuizList } from "./WrongQuizList"; +import { WrongQuizListItemSkeleton } from "./WrongQuizListItemSkeleton"; +import { fetchMyWrongQuizzes } from "@/lib/mock/my-page-wrong-quizzes"; +import type { MyPageQuizHistory } from "@/types/myPage"; + +export function WrongQuizListWrapper() { + const [quizzes, setQuizzes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + fetchMyWrongQuizzes() + .then((data) => { + setQuizzes(data); + }) + .catch(() => { + setIsError(true); + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + if (isError) { + return ( +

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

+ ); + } + + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + return ; +} diff --git a/components/features/my-page/quizzes/WrongQuizSection.tsx b/components/features/my-page/quizzes/WrongQuizSection.tsx index da275e9..4115a8e 100644 --- a/components/features/my-page/quizzes/WrongQuizSection.tsx +++ b/components/features/my-page/quizzes/WrongQuizSection.tsx @@ -11,12 +11,19 @@ import type { MyPageQuizHistory } from "@/types/myPage"; export function WrongQuizSection() { const [quizzes, setQuizzes] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); useEffect(() => { - fetchMyWrongQuizzesPreview(4).then((data) => { - setQuizzes(data); - setIsLoading(false); - }); + fetchMyWrongQuizzesPreview(4) + .then((data) => { + setQuizzes(data); + }) + .catch(() => { + setIsError(true); + }) + .finally(() => { + setIsLoading(false); + }); }, []); return ( @@ -32,7 +39,11 @@ export function WrongQuizSection() { - {isLoading ? ( + {isError ? ( +

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

+ ) : isLoading ? (
{Array.from({ length: 4 }).map((_, i) => (
+
+
+ {cover ? ( + {title} + ) : ( +
+ +
+ )} +
+ +
+

+ {title} +

+ + {authors.join(", ")} + + + {publisher} + · + {year} + +
+
+ + ); +} diff --git a/components/features/my-page/recommend/RecommendedHomePostCard.tsx b/components/features/my-page/recommend/RecommendedHomePostCard.tsx new file mode 100644 index 0000000..3294510 --- /dev/null +++ b/components/features/my-page/recommend/RecommendedHomePostCard.tsx @@ -0,0 +1,48 @@ +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 RecommendedHomePostCard({ + post, +}: { + post: MyPageRecommendHomePost; +}) { + const { contentId, title, sourceName, thumbnail, date } = post; + + return ( + +
+
+ {thumbnail ? ( + {title} + ) : ( +
+ +
+ )} +
+ +
+
+ + {sourceName} +
+

+ {title} +

+ + {formatDate(date)} + +
+
+ + ); +} diff --git a/components/features/my-page/recommend/RecommendedSection.tsx b/components/features/my-page/recommend/RecommendedSection.tsx new file mode 100644 index 0000000..02f1db3 --- /dev/null +++ b/components/features/my-page/recommend/RecommendedSection.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { RecommendedHomePostCard } from "./RecommendedHomePostCard"; +import { RecommendedVideoCard } from "./RecommendedVideoCard"; +import { RecommendedBookCard } from "./RecommendedBookCard"; +import { fetchRecommendHomePosts } from "@/lib/mock/my-page-recommend-home"; +import { fetchRecommendVideos } from "@/lib/mock/my-page-recommend-video"; +import { fetchRecommendBooks } from "@/lib/mock/my-page-recommend-book"; +import type { + MyPageRecommendHomePost, + MyPageRecommendVideo, + MyPageRecommendBook, +} from "@/types/myPage"; + +function SubSectionHeader({ + title, + href, +}: { + title: string; + href: string; +}) { + return ( +
+

{title}

+ + + 전체 보기 + +
+ ); +} + +function CardSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} + +function BookCardSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} + +export function RecommendedSection() { + const [homePosts, setHomePosts] = useState([]); + const [videos, setVideos] = useState([]); + const [books, setBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + Promise.all([ + fetchRecommendHomePosts(4), + fetchRecommendVideos(4), + fetchRecommendBooks(4), + ]).then(([posts, vids, bks]) => { + setHomePosts(posts); + setVideos(vids); + setBooks(bks); + setIsLoading(false); + }); + }, []); + + return ( +
+

추천

+ +
+ + {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) => ( + + ))} +
+ ) : books.length === 0 ? ( +

추천 서적이 없습니다.

+ ) : ( +
+ {books.map((book) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/components/features/my-page/recommend/RecommendedVideoCard.tsx b/components/features/my-page/recommend/RecommendedVideoCard.tsx new file mode 100644 index 0000000..4b00d7a --- /dev/null +++ b/components/features/my-page/recommend/RecommendedVideoCard.tsx @@ -0,0 +1,51 @@ +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 RecommendedVideoCard({ 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/lib/mock/my-page-recommend-book.ts b/lib/mock/my-page-recommend-book.ts new file mode 100644 index 0000000..b63e4ad --- /dev/null +++ b/lib/mock/my-page-recommend-book.ts @@ -0,0 +1,47 @@ +import type { MyPageRecommendBook } from "@/types/myPage"; + +export const MOCK_RECOMMEND_BOOKS: MyPageRecommendBook[] = [ + { + bookId: "book-001", + title: "클린 아키텍처", + authors: ["로버트 C. 마틴"], + cover: "https://picsum.photos/seed/book1/300/400", + url: "https://www.yes24.com/Product/Goods/77283734", + publisher: "인사이트", + publishedAt: "2019-08-20", + }, + { + bookId: "book-002", + title: "가상 면접 사례로 배우는 대규모 시스템 설계 기초", + authors: ["알렉스 쉬"], + cover: "https://picsum.photos/seed/book2/300/400", + url: "https://www.yes24.com/Product/Goods/102819435", + publisher: "인사이트", + publishedAt: "2021-11-24", + }, + { + bookId: "book-003", + title: "이펙티브 타입스크립트", + authors: ["댄 밴더캄"], + cover: null, + url: "https://www.yes24.com/Product/Goods/102124327", + publisher: "인사이트", + publishedAt: "2021-11-12", + }, + { + bookId: "book-004", + title: "데이터 중심 애플리케이션 설계", + authors: ["마틴 클레퍼만"], + cover: "https://picsum.photos/seed/book4/300/400", + url: "https://www.yes24.com/Product/Goods/59566585", + publisher: "위키북스", + publishedAt: "2018-12-04", + }, +]; + +export async function fetchRecommendBooks( + count = 4, +): Promise { + await new Promise((resolve) => setTimeout(resolve, 400)); + return MOCK_RECOMMEND_BOOKS.slice(0, count); +} diff --git a/lib/mock/my-page-recommend-home.ts b/lib/mock/my-page-recommend-home.ts new file mode 100644 index 0000000..b1733cd --- /dev/null +++ b/lib/mock/my-page-recommend-home.ts @@ -0,0 +1,42 @@ +import type { MyPageRecommendHomePost } from "@/types/myPage"; + +export const MOCK_RECOMMEND_HOME_POSTS: MyPageRecommendHomePost[] = [ + { + contentId: "content-301", + title: "React 서버 컴포넌트 완전 정복 — 렌더링 모델과 캐싱 전략", + sourceName: "velog", + thumbnail: "https://picsum.photos/seed/rec1/400/240", + summary: "RSC의 렌더링 흐름과 fetch 캐싱 옵션을 심층 분석합니다.", + date: "2026-04-20T10:00:00Z", + }, + { + contentId: "content-302", + title: "TypeScript satisfies 연산자 — 타입 추론을 유지하면서 검증하기", + sourceName: "kakao_tech", + thumbnail: "https://picsum.photos/seed/rec2/400/240", + summary: "as와 satisfies의 차이점과 실전 활용 패턴을 정리합니다.", + date: "2026-04-18T09:00:00Z", + }, + { + contentId: "content-303", + title: "모노레포 환경에서 Turborepo로 빌드 캐시 최적화하기", + sourceName: "toss_tech", + thumbnail: null, + date: "2026-04-15T14:00:00Z", + }, + { + contentId: "content-304", + title: "PostgreSQL 파티셔닝 전략 — 대용량 테이블 성능 개선 실전 사례", + sourceName: "naver_d2", + thumbnail: "https://picsum.photos/seed/rec4/400/240", + summary: "레인지/리스트/해시 파티셔닝을 실제 서비스 케이스에 적용한 경험을 공유합니다.", + date: "2026-04-13T11:00:00Z", + }, +]; + +export async function fetchRecommendHomePosts( + count = 4, +): Promise { + await new Promise((resolve) => setTimeout(resolve, 400)); + return MOCK_RECOMMEND_HOME_POSTS.slice(0, count); +} diff --git a/lib/mock/my-page-recommend-video.ts b/lib/mock/my-page-recommend-video.ts new file mode 100644 index 0000000..b74cbbb --- /dev/null +++ b/lib/mock/my-page-recommend-video.ts @@ -0,0 +1,51 @@ +import type { MyPageRecommendVideo } from "@/types/myPage"; + +export const MOCK_RECOMMEND_VIDEOS: MyPageRecommendVideo[] = [ + { + videoId: "vid-001", + title: "10분 만에 이해하는 React 19 Actions — useActionState 실전편", + channelName: "코딩애플", + thumbnail: "https://picsum.photos/seed/vid1/400/240", + url: "https://www.youtube.com/watch?v=example1", + duration: "10:24", + views: 142300, + uploadedAt: "2026-04-19T00:00:00Z", + }, + { + videoId: "vid-002", + title: "Next.js App Router 완전 정복 — 캐싱부터 스트리밍까지", + channelName: "Traversy Media", + thumbnail: "https://picsum.photos/seed/vid2/400/240", + url: "https://www.youtube.com/watch?v=example2", + duration: "42:17", + views: 89500, + uploadedAt: "2026-04-14T00:00:00Z", + }, + { + videoId: "vid-003", + title: "Docker & Kubernetes 입문 — 컨테이너 오케스트레이션 기초", + channelName: "드림코딩", + thumbnail: null, + url: "https://www.youtube.com/watch?v=example3", + duration: "28:50", + views: 61200, + uploadedAt: "2026-04-10T00:00:00Z", + }, + { + videoId: "vid-004", + title: "TanStack Query v5 마이그레이션 완벽 가이드", + channelName: "Jack Herrington", + thumbnail: "https://picsum.photos/seed/vid4/400/240", + url: "https://www.youtube.com/watch?v=example4", + duration: "18:03", + views: 34800, + uploadedAt: "2026-04-07T00:00:00Z", + }, +]; + +export async function fetchRecommendVideos( + count = 4, +): Promise { + await new Promise((resolve) => setTimeout(resolve, 400)); + return MOCK_RECOMMEND_VIDEOS.slice(0, count); +} diff --git a/types/myPage.ts b/types/myPage.ts index 8d193dc..b33b804 100644 --- a/types/myPage.ts +++ b/types/myPage.ts @@ -22,3 +22,33 @@ export interface MyPageScrap { createdAt: string; summary?: string; } + +export interface MyPageRecommendHomePost { + contentId: string; + title: string; + sourceName: string; + thumbnail: string | null; + summary?: string; + date: string; +} + +export interface MyPageRecommendVideo { + videoId: string; + title: string; + channelName: string; + thumbnail: string | null; + url: string; + duration: string; + views: number; + uploadedAt: string; +} + +export interface MyPageRecommendBook { + bookId: string; + title: string; + authors: string[]; + cover: string | null; + url: string; + publisher: string; + publishedAt: string; +}