From 146180513035b592ef3fc51798535171d07e3ec2 Mon Sep 17 00:00:00 2001 From: uiuuoq Date: Thu, 30 Apr 2026 21:32:08 +0900 Subject: [PATCH] =?UTF-8?q?DP-442:=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=9C=A0=ED=8A=9C=EB=B8=8C=20?= =?UTF-8?q?mock=20=E2=86=92=20=EC=8B=A0=EA=B7=9C=20API=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my-page/recommend/RecommendedSection.tsx | 16 +- .../recommend/RecommendedVideoCard.tsx | 86 +++++--- .../recommend/RecommendedVideoList.tsx | 47 ++--- .../recommend/RecommendedVideoListItem.tsx | 74 +++++-- lib/mock/my-page-recommend-video.ts | 199 ++++++++++-------- types/myPage.ts | 23 +- 6 files changed, 259 insertions(+), 186 deletions(-) diff --git a/components/features/my-page/recommend/RecommendedSection.tsx b/components/features/my-page/recommend/RecommendedSection.tsx index ea6d29c..a6e6e10 100644 --- a/components/features/my-page/recommend/RecommendedSection.tsx +++ b/components/features/my-page/recommend/RecommendedSection.tsx @@ -12,7 +12,7 @@ import { fetchRecommendVideos } from "@/lib/mock/my-page-recommend-video"; import { fetchRecommendBooks } from "@/lib/mock/my-page-recommend-book"; import type { MyPageRecommendContentsResponse, - MyPageRecommendVideo, + MyPageRecommendYoutubeResponse, MyPageRecommendBook, } from "@/types/myPage"; @@ -60,7 +60,8 @@ function BookCardSkeleton() { export function RecommendedSection() { const [homePostsData, setHomePostsData] = useState(null); - const [videos, setVideos] = useState([]); + const [videosData, setVideosData] = + useState(null); const [books, setBooks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); @@ -71,9 +72,9 @@ export function RecommendedSection() { fetchRecommendVideos(4), fetchRecommendBooks(4), ]) - .then(([postsData, vids, bks]) => { + .then(([postsData, vidsData, bks]) => { setHomePostsData(postsData); - setVideos(vids); + setVideosData(vidsData); setBooks(bks); }) .catch(() => setIsError(true)) @@ -81,6 +82,7 @@ export function RecommendedSection() { }, []); const homePosts = homePostsData?.contents ?? []; + const videos = videosData?.videos ?? []; if (isError) { return ( @@ -137,6 +139,10 @@ export function RecommendedSection() { ))} + ) : !videosData?.isPersonalized ? ( +

+ {videosData?.message ?? "아직 추천할 영상이 부족해요. 더 많은 글을 읽어보세요!"} +

) : videos.length === 0 ? (

추천 영상이 없습니다. @@ -144,7 +150,7 @@ export function RecommendedSection() { ) : (

{videos.map((video) => ( - + ))}
)} diff --git a/components/features/my-page/recommend/RecommendedVideoCard.tsx b/components/features/my-page/recommend/RecommendedVideoCard.tsx index 4b00d7a..2ca97de 100644 --- a/components/features/my-page/recommend/RecommendedVideoCard.tsx +++ b/components/features/my-page/recommend/RecommendedVideoCard.tsx @@ -3,49 +3,65 @@ 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); +function parseDuration(iso: string): string { + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return iso; + const h = match[1] ? parseInt(match[1]) : 0; + const m = match[2] ? parseInt(match[2]) : 0; + const s = match[3] ? parseInt(match[3]) : 0; + if (h > 0) + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; } export function RecommendedVideoCard({ video }: { video: MyPageRecommendVideo }) { - const { title, channelName, thumbnail, url, duration, views, uploadedAt } = video; + const { title, translatedTitle, videoId, channelName, thumbnailUrl, duration, publishedAt } = + video; + const displayTitle = translatedTitle ?? title; + const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`; - return ( - -
-
- {thumbnail ? ( - {title} - ) : ( -
- -
- )} + const content = ( +
+
+ {thumbnailUrl ? ( + {displayTitle} + ) : ( +
+ +
+ )} + {duration && ( - {duration} + {parseDuration(duration)} -
+ )} +
-
-

- {title} -

- - {channelName} - · - 조회 {formatViews(views)} - · - {formatDate(uploadedAt)} - -
+
+

+ {displayTitle} +

+ + {channelName && ( + <> + {channelName} + · + + )} + {formatDate(publishedAt)} +
+
+ ); + + return ( +
+ {content} ); } diff --git a/components/features/my-page/recommend/RecommendedVideoList.tsx b/components/features/my-page/recommend/RecommendedVideoList.tsx index 645db31..a420ce7 100644 --- a/components/features/my-page/recommend/RecommendedVideoList.tsx +++ b/components/features/my-page/recommend/RecommendedVideoList.tsx @@ -1,13 +1,10 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { RecommendedVideoListItem } from "./RecommendedVideoListItem"; -import { MyPagePagination } from "../MyPagePagination"; import { fetchRecommendVideos } from "@/lib/mock/my-page-recommend-video"; -import type { MyPageRecommendVideo } from "@/types/myPage"; - -const PAGE_SIZE = 10; +import type { MyPageRecommendYoutubeResponse } from "@/types/myPage"; function ListItemSkeleton() { return ( @@ -23,24 +20,18 @@ function ListItemSkeleton() { } export function RecommendedVideoList() { - const [videos, setVideos] = useState([]); + const [videosData, setVideosData] = + useState(null); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); - const [currentPage, setCurrentPage] = useState(1); useEffect(() => { fetchRecommendVideos() - .then((data) => setVideos(data)) + .then((data) => setVideosData(data)) .catch(() => setIsError(true)) .finally(() => setIsLoading(false)); }, []); - const totalPages = Math.ceil(videos.length / PAGE_SIZE); - const pagedItems = useMemo( - () => videos.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), - [videos, currentPage], - ); - if (isError) { return (

@@ -59,6 +50,16 @@ export function RecommendedVideoList() { ); } + if (!videosData?.isPersonalized) { + return ( +

+ {videosData?.message ?? "아직 추천할 영상이 부족해요. 더 많은 글을 읽어보세요!"} +

+ ); + } + + const videos = videosData.videos; + if (videos.length === 0) { return (

@@ -68,18 +69,10 @@ export function RecommendedVideoList() { } return ( - <> -

- {pagedItems.map((video) => ( - - ))} -
- - +
+ {videos.map((video) => ( + + ))} +
); } diff --git a/components/features/my-page/recommend/RecommendedVideoListItem.tsx b/components/features/my-page/recommend/RecommendedVideoListItem.tsx index dc4782b..33af3c3 100644 --- a/components/features/my-page/recommend/RecommendedVideoListItem.tsx +++ b/components/features/my-page/recommend/RecommendedVideoListItem.tsx @@ -3,10 +3,15 @@ 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); +function parseDuration(iso: string): string { + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return iso; + const h = match[1] ? parseInt(match[1]) : 0; + const m = match[2] ? parseInt(match[2]) : 0; + const s = match[3] ? parseInt(match[3]) : 0; + if (h > 0) + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; } export function RecommendedVideoListItem({ @@ -14,40 +19,65 @@ export function RecommendedVideoListItem({ }: { video: MyPageRecommendVideo; }) { - const { title, channelName, thumbnail, url, duration, views, uploadedAt } = + const { title, translatedTitle, videoId, channelName, thumbnailUrl, duration, publishedAt, tags } = video; + const displayTitle = translatedTitle ?? title; + const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`; - return ( - + const content = ( + <>
- {thumbnail ? ( - {title} + {thumbnailUrl ? ( + {displayTitle} ) : (
)} - - {duration} - + {duration && ( + + {parseDuration(duration)} + + )}

- {title} + {displayTitle}

-

{channelName}

+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} - 조회 {formatViews(views)} - · - {formatDate(uploadedAt)} + {channelName && ( + <> + {channelName} + · + + )} + {formatDate(publishedAt)}
+ + ); + + return ( +
+ {content} ); } diff --git a/lib/mock/my-page-recommend-video.ts b/lib/mock/my-page-recommend-video.ts index fb7240a..1d14166 100644 --- a/lib/mock/my-page-recommend-video.ts +++ b/lib/mock/my-page-recommend-video.ts @@ -1,133 +1,152 @@ -import type { MyPageRecommendVideo } from "@/types/myPage"; +import type { + MyPageRecommendVideo, + MyPageRecommendYoutubeResponse, +} from "@/types/myPage"; export const MOCK_RECOMMEND_VIDEOS: MyPageRecommendVideo[] = [ { - videoId: "vid-001", + contentId: "video-content-001", title: "10분 만에 이해하는 React 19 Actions — useActionState 실전편", + translatedTitle: null, + videoId: "dQw4w9WgXcQ", 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", + duration: "PT10M24S", + thumbnailUrl: "https://picsum.photos/seed/vid1/400/240", + tags: ["React"], + publishedAt: "2026-04-19T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-002", - title: "Next.js App Router 완전 정복 — 캐싱부터 스트리밍까지", + contentId: "video-content-002", + title: "Next.js App Router Full Course — Caching, Streaming & More", + translatedTitle: "Next.js App Router 완전 정복 — 캐싱부터 스트리밍까지", + videoId: "wm5gMKuwSYk", 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", + duration: "PT42M17S", + thumbnailUrl: "https://picsum.photos/seed/vid2/400/240", + tags: ["Next.js", "React"], + publishedAt: "2026-04-14T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-003", + contentId: "video-content-003", title: "Docker & Kubernetes 입문 — 컨테이너 오케스트레이션 기초", + translatedTitle: null, + videoId: "bhBSlnQcq2k", channelName: "드림코딩", - thumbnail: null, - url: "https://www.youtube.com/watch?v=example3", - duration: "28:50", - views: 61200, - uploadedAt: "2026-04-10T00:00:00Z", + duration: "PT28M50S", + thumbnailUrl: null, + tags: ["Docker", "Kubernetes"], + publishedAt: "2026-04-10T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-004", - title: "TanStack Query v5 마이그레이션 완벽 가이드", + contentId: "video-content-004", + title: "TanStack Query v5 Complete Migration Guide", + translatedTitle: "TanStack Query v5 마이그레이션 완벽 가이드", + videoId: "novnyCaa7To", 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", + duration: "PT18M3S", + thumbnailUrl: "https://picsum.photos/seed/vid4/400/240", + tags: ["TanStack Query", "React"], + publishedAt: "2026-04-07T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-005", + contentId: "video-content-005", title: "TypeScript 제네릭 완전 정복 — 실전 패턴 7가지", + translatedTitle: null, + videoId: "nViEqpgwxHE", channelName: "코딩애플", - thumbnail: "https://picsum.photos/seed/vid5/400/240", - url: "https://www.youtube.com/watch?v=example5", - duration: "22:15", - views: 78400, - uploadedAt: "2026-04-04T00:00:00Z", + duration: "PT22M15S", + thumbnailUrl: "https://picsum.photos/seed/vid5/400/240", + tags: ["TypeScript"], + publishedAt: "2026-04-04T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-006", - title: "Redis 기초부터 실전까지 — 캐싱 전략과 세션 관리", + contentId: "video-content-006", + title: "Redis from Zero to Hero — Caching Strategies & Session Management", + translatedTitle: "Redis 기초부터 실전까지 — 캐싱 전략과 세션 관리", + videoId: "XltTfSMSHjA", channelName: "드림코딩", - thumbnail: null, - url: "https://www.youtube.com/watch?v=example6", - duration: "35:40", - views: 52100, - uploadedAt: "2026-04-01T00:00:00Z", + duration: "PT35M40S", + thumbnailUrl: null, + tags: ["Redis", "Backend"], + publishedAt: "2026-04-01T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-007", - title: "CI/CD 파이프라인 구축 — GitHub Actions 실전편", + contentId: "video-content-007", + title: "CI/CD in 100 Seconds — GitHub Actions", + translatedTitle: "CI/CD 파이프라인 구축 — GitHub Actions 실전편", + videoId: "scEDHsr3APg", channelName: "Fireship", - thumbnail: "https://picsum.photos/seed/vid7/400/240", - url: "https://www.youtube.com/watch?v=example7", - duration: "14:32", - views: 120500, - uploadedAt: "2026-03-28T00:00:00Z", + duration: "PT14M32S", + thumbnailUrl: "https://picsum.photos/seed/vid7/400/240", + tags: ["GitHub Actions", "CI/CD"], + publishedAt: "2026-03-28T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-008", + contentId: "video-content-008", title: "Spring Boot REST API 설계 — 실무 패턴과 예외 처리", + translatedTitle: null, + videoId: "9SGDpanrc8U", channelName: "우아한테크", - thumbnail: "https://picsum.photos/seed/vid8/400/240", - url: "https://www.youtube.com/watch?v=example8", - duration: "51:08", - views: 43700, - uploadedAt: "2026-03-25T00:00:00Z", + duration: "PT51M8S", + thumbnailUrl: "https://picsum.photos/seed/vid8/400/240", + tags: ["Spring Boot", "Java"], + publishedAt: "2026-03-25T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-009", + contentId: "video-content-009", title: "SQL 쿼리 최적화 — 인덱스 설계와 실행 계획 분석", + translatedTitle: null, + videoId: "7kmEBi0bPdQ", channelName: "데이터리안", - thumbnail: null, - url: "https://www.youtube.com/watch?v=example9", - duration: "38:20", - views: 29300, - uploadedAt: "2026-03-22T00:00:00Z", + duration: "PT38M20S", + thumbnailUrl: null, + tags: ["SQL", "Database"], + publishedAt: "2026-03-22T00:00:00Z", + isScrapped: false, + isLiked: false, }, { - videoId: "vid-010", - title: "Tailwind CSS v4 신기능 총정리 — 10분 핵심 정리", + contentId: "video-content-010", + title: "Tailwind CSS v4 — Everything New in 10 Minutes", + translatedTitle: "Tailwind CSS v4 신기능 총정리 — 10분 핵심 정리", + videoId: "6biMWgD6_T4", channelName: "Traversy Media", - thumbnail: "https://picsum.photos/seed/vid10/400/240", - url: "https://www.youtube.com/watch?v=example10", - duration: "09:55", - views: 67800, - uploadedAt: "2026-03-18T00:00:00Z", - }, - { - videoId: "vid-011", - title: "Zustand로 전역 상태 관리하기 — Redux 없이 깔끔하게", - channelName: "코딩애플", - thumbnail: "https://picsum.photos/seed/vid11/400/240", - url: "https://www.youtube.com/watch?v=example11", - duration: "16:47", - views: 91200, - uploadedAt: "2026-03-14T00:00:00Z", - }, - { - videoId: "vid-012", - title: "웹 접근성 A11y 기초 — 스크린 리더 대응과 ARIA 사용법", - channelName: "드림코딩", - thumbnail: null, - url: "https://www.youtube.com/watch?v=example12", - duration: "24:11", - views: 18600, - uploadedAt: "2026-03-10T00:00:00Z", + duration: "PT9M55S", + thumbnailUrl: "https://picsum.photos/seed/vid10/400/240", + tags: ["Tailwind CSS", "CSS"], + publishedAt: "2026-03-18T00:00:00Z", + isScrapped: false, + isLiked: false, }, ]; export async function fetchRecommendVideos( count?: number, -): Promise { +): Promise { await new Promise((resolve) => setTimeout(resolve, 400)); - return count !== undefined - ? MOCK_RECOMMEND_VIDEOS.slice(0, count) - : MOCK_RECOMMEND_VIDEOS; + const videos = + count !== undefined + ? MOCK_RECOMMEND_VIDEOS.slice(0, count) + : MOCK_RECOMMEND_VIDEOS; + return { + videos, + isPersonalized: true, + message: null, + }; } diff --git a/types/myPage.ts b/types/myPage.ts index f77ac0f..6d2f80f 100644 --- a/types/myPage.ts +++ b/types/myPage.ts @@ -95,14 +95,23 @@ export interface MyPageRecommendContentsResponse { } export interface MyPageRecommendVideo { - videoId: string; + contentId: string; title: string; - channelName: string; - thumbnail: string | null; - url: string; - duration: string; - views: number; - uploadedAt: string; + translatedTitle: string | null; + videoId: string; + channelName: string | null; + duration: string | null; + thumbnailUrl: string | null; + tags: string[]; + publishedAt: string; + isScrapped: boolean; + isLiked: boolean; +} + +export interface MyPageRecommendYoutubeResponse { + videos: MyPageRecommendVideo[]; + isPersonalized: boolean; + message?: string | null; } export interface MyPageRecommendBook {