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
16 changes: 11 additions & 5 deletions components/features/my-page/recommend/RecommendedSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -60,7 +60,8 @@ function BookCardSkeleton() {
export function RecommendedSection() {
const [homePostsData, setHomePostsData] =
useState<MyPageRecommendContentsResponse | null>(null);
const [videos, setVideos] = useState<MyPageRecommendVideo[]>([]);
const [videosData, setVideosData] =
useState<MyPageRecommendYoutubeResponse | null>(null);
const [books, setBooks] = useState<MyPageRecommendBook[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
Expand All @@ -71,16 +72,17 @@ 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))
.finally(() => setIsLoading(false));
}, []);

const homePosts = homePostsData?.contents ?? [];
const videos = videosData?.videos ?? [];

if (isError) {
return (
Expand Down Expand Up @@ -137,14 +139,18 @@ export function RecommendedSection() {
<CardSkeleton key={i} />
))}
</div>
) : !videosData?.isPersonalized ? (
<p className="text-sm text-muted-foreground">
{videosData?.message ?? "아직 추천할 영상이 부족해요. 더 많은 글을 읽어보세요!"}
</p>
) : videos.length === 0 ? (
<p className="text-sm text-muted-foreground">
추천 영상이 없습니다.
</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{videos.map((video) => (
<RecommendedVideoCard key={video.videoId} video={video} />
<RecommendedVideoCard key={video.contentId} video={video} />
))}
</div>
)}
Expand Down
86 changes: 51 additions & 35 deletions components/features/my-page/recommend/RecommendedVideoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<a href={url} target="_blank" rel="noopener noreferrer">
<div className="group flex h-full flex-col overflow-hidden rounded-md border border-border bg-card">
<div className="relative aspect-video w-full overflow-hidden bg-muted">
{thumbnail ? (
<Image
fill
src={thumbnail}
alt={title}
className="object-cover transition-transform duration-200"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Youtube className="h-6 w-6 text-muted-foreground/30" />
</div>
)}
const content = (
<div className="group flex h-full flex-col overflow-hidden rounded-md border border-border bg-card">
<div className="relative aspect-video w-full overflow-hidden bg-muted">
{thumbnailUrl ? (
<Image
fill
src={thumbnailUrl}
alt={displayTitle}
className="object-cover transition-transform duration-200"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Youtube className="h-6 w-6 text-muted-foreground/30" />
</div>
)}
{duration && (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/70 px-1 py-0.5 text-[11px] font-medium text-white">
{duration}
{parseDuration(duration)}
</span>
</div>
)}
</div>

<div className="flex flex-1 flex-col gap-1.5 p-3">
<p className="line-clamp-2 flex-1 text-sm font-medium leading-snug tracking-[-0.01em] text-foreground">
{title}
</p>
<span className="text-xs text-muted-foreground">
{channelName}
<span className="mx-1">·</span>
조회 {formatViews(views)}
<span className="mx-1">·</span>
{formatDate(uploadedAt)}
</span>
</div>
<div className="flex flex-1 flex-col gap-1.5 p-3">
<p className="line-clamp-2 flex-1 text-sm font-medium leading-snug tracking-[-0.01em] text-foreground">
{displayTitle}
</p>
<span className="text-xs text-muted-foreground">
{channelName && (
<>
{channelName}
<span className="mx-1">·</span>
</>
)}
{formatDate(publishedAt)}
</span>
</div>
</div>
);

return (
<a href={youtubeUrl} target="_blank" rel="noopener noreferrer">
{content}
</a>
);
}
47 changes: 20 additions & 27 deletions components/features/my-page/recommend/RecommendedVideoList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -23,24 +20,18 @@ function ListItemSkeleton() {
}

export function RecommendedVideoList() {
const [videos, setVideos] = useState<MyPageRecommendVideo[]>([]);
const [videosData, setVideosData] =
useState<MyPageRecommendYoutubeResponse | null>(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 (
<p className="text-sm text-muted-foreground">
Expand All @@ -59,6 +50,16 @@ export function RecommendedVideoList() {
);
}

if (!videosData?.isPersonalized) {
return (
<p className="py-10 text-center text-sm text-muted-foreground">
{videosData?.message ?? "아직 추천할 영상이 부족해요. 더 많은 글을 읽어보세요!"}
</p>
);
}

const videos = videosData.videos;

if (videos.length === 0) {
return (
<p className="py-10 text-center text-sm text-muted-foreground">
Expand All @@ -68,18 +69,10 @@ export function RecommendedVideoList() {
}

return (
<>
<div className="divide-y divide-border">
{pagedItems.map((video) => (
<RecommendedVideoListItem key={video.videoId} video={video} />
))}
</div>
<MyPagePagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
className="mt-8 mb-12"
/>
</>
<div className="divide-y divide-border">
{videos.map((video) => (
<RecommendedVideoListItem key={video.contentId} video={video} />
))}
</div>
);
}
74 changes: 52 additions & 22 deletions components/features/my-page/recommend/RecommendedVideoListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,81 @@ 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({
video,
}: {
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 (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="-mx-2 flex gap-4 px-2 py-3 transition-colors"
>
const content = (
<>
<div className="relative aspect-[3/2] w-36 shrink-0 overflow-hidden rounded-sm bg-muted">
{thumbnail ? (
<Image fill src={thumbnail} alt={title} className="object-cover" />
{thumbnailUrl ? (
<Image fill src={thumbnailUrl} alt={displayTitle} className="object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<Youtube className="h-5 w-5 text-muted-foreground/30" />
</div>
)}
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1 py-0.5 text-[10px] font-medium text-white">
{duration}
</span>
{duration && (
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1 py-0.5 text-[10px] font-medium text-white">
{parseDuration(duration)}
</span>
)}
</div>

<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-foreground">
{title}
{displayTitle}
</p>
<p className="text-xs text-muted-foreground">{channelName}</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-muted px-1.5 py-px text-[11px] text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
<span className="mt-auto pt-1 text-xs text-muted-foreground">
조회 {formatViews(views)}
<span className="mx-1">·</span>
{formatDate(uploadedAt)}
{channelName && (
<>
{channelName}
<span className="mx-1">·</span>
</>
)}
{formatDate(publishedAt)}
</span>
</div>
</>
);

return (
<a
href={youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="-mx-2 flex gap-4 px-2 py-3 transition-colors"
>
{content}
</a>
);
}
Loading
Loading