diff --git a/components/features/home/SourceLogo.tsx b/components/features/home/SourceLogo.tsx index 1b41fc7..8a0b7a1 100644 --- a/components/features/home/SourceLogo.tsx +++ b/components/features/home/SourceLogo.tsx @@ -3,6 +3,7 @@ import { isStackOverflowSource } from "@/lib/content/sourceGuards"; interface SourceLogoProps { sourceName: string; size?: number; + paddingOverride?: number; } type LogoConfig = { @@ -68,7 +69,7 @@ const SOURCE_LOGO: Record = { nextjs_blog: { src: "/icons/sources/nextjs_blog.svg", bg: "#FFFFFF" }, }; -export function SourceLogo({ sourceName, size = 17 }: SourceLogoProps) { +export function SourceLogo({ sourceName, size = 17, paddingOverride }: SourceLogoProps) { const key = sourceName.trim().toLowerCase(); let config: LogoConfig | undefined; @@ -82,7 +83,7 @@ export function SourceLogo({ sourceName, size = 17 }: SourceLogoProps) { } if (config) { - const pad = config.innerPadding ?? 0; + const pad = paddingOverride !== undefined ? paddingOverride : (config.innerPadding ?? 0); const innerSize = size - pad * 2; return ( +
- {thumbnail ? ( + {thumbnailUrl ? ( {title} ) : ( @@ -32,14 +34,18 @@ export function RecommendedHomePostCard({
- + {sourceName}

- {title} + {displayTitle}

- {formatDate(date)} + {formatDate(publishedAt)}
diff --git a/components/features/my-page/recommend/RecommendedHomePostList.tsx b/components/features/my-page/recommend/RecommendedHomePostList.tsx index 26ff353..5b1e19a 100644 --- a/components/features/my-page/recommend/RecommendedHomePostList.tsx +++ b/components/features/my-page/recommend/RecommendedHomePostList.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 { RecommendedHomePostListItem } from "./RecommendedHomePostListItem"; -import { MyPagePagination } from "../MyPagePagination"; import { fetchRecommendHomePosts } from "@/lib/mock/my-page-recommend-home"; -import type { MyPageRecommendHomePost } from "@/types/myPage"; - -const PAGE_SIZE = 10; +import type { MyPageRecommendContentsResponse } from "@/types/myPage"; function ListItemSkeleton() { return ( @@ -24,24 +21,18 @@ function ListItemSkeleton() { } export function RecommendedHomePostList() { - const [posts, setPosts] = useState([]); + const [postsData, setPostsData] = + useState(null); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); - const [currentPage, setCurrentPage] = useState(1); useEffect(() => { fetchRecommendHomePosts() - .then((data) => setPosts(data)) + .then((data) => setPostsData(data)) .catch(() => setIsError(true)) .finally(() => setIsLoading(false)); }, []); - const totalPages = Math.ceil(posts.length / PAGE_SIZE); - const pagedItems = useMemo( - () => posts.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), - [posts, currentPage], - ); - if (isError) { return (

@@ -60,6 +51,16 @@ export function RecommendedHomePostList() { ); } + if (!postsData?.isPersonalized) { + return ( +

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

+ ); + } + + const posts = postsData.contents; + if (posts.length === 0) { return (

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

- {pagedItems.map((post) => ( - - ))} -
- - +
+ {posts.map((post) => ( + + ))} +
); } diff --git a/components/features/my-page/recommend/RecommendedHomePostListItem.tsx b/components/features/my-page/recommend/RecommendedHomePostListItem.tsx index c3c9c93..d2bfe40 100644 --- a/components/features/my-page/recommend/RecommendedHomePostListItem.tsx +++ b/components/features/my-page/recommend/RecommendedHomePostListItem.tsx @@ -10,16 +10,30 @@ export function RecommendedHomePostListItem({ }: { post: MyPageRecommendHomePost; }) { - const { contentId, title, sourceName, thumbnail, summary, date } = post; + const { + id, + title, + translatedTitle, + sourceName, + thumbnailUrl, + preview, + publishedAt, + } = post; + const displayTitle = translatedTitle ?? title; return (
- {thumbnail ? ( - {title} + {thumbnailUrl ? ( + {displayTitle} ) : (
@@ -29,19 +43,25 @@ export function RecommendedHomePostListItem({
- + {sourceName}

- {title} + {displayTitle}

- {summary && ( -

{summary}

+ {preview && ( +

+ {preview} +

)} - {formatDate(date)} + {formatDate(publishedAt)}
diff --git a/components/features/my-page/recommend/RecommendedSection.tsx b/components/features/my-page/recommend/RecommendedSection.tsx index 2c5c451..ea6d29c 100644 --- a/components/features/my-page/recommend/RecommendedSection.tsx +++ b/components/features/my-page/recommend/RecommendedSection.tsx @@ -11,7 +11,7 @@ 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, + MyPageRecommendContentsResponse, MyPageRecommendVideo, MyPageRecommendBook, } from "@/types/myPage"; @@ -58,7 +58,8 @@ function BookCardSkeleton() { } export function RecommendedSection() { - const [homePosts, setHomePosts] = useState([]); + const [homePostsData, setHomePostsData] = + useState(null); const [videos, setVideos] = useState([]); const [books, setBooks] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -70,8 +71,8 @@ export function RecommendedSection() { fetchRecommendVideos(4), fetchRecommendBooks(4), ]) - .then(([posts, vids, bks]) => { - setHomePosts(posts); + .then(([postsData, vids, bks]) => { + setHomePostsData(postsData); setVideos(vids); setBooks(bks); }) @@ -79,6 +80,8 @@ export function RecommendedSection() { .finally(() => setIsLoading(false)); }, []); + const homePosts = homePostsData?.contents ?? []; + if (isError) { return (
@@ -107,12 +110,17 @@ export function RecommendedSection() { ))}
+ ) : !homePostsData?.isPersonalized ? ( +

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

) : homePosts.length === 0 ? (

추천 글이 없습니다.

) : (
{homePosts.map((post) => ( - + ))}
)} diff --git a/lib/mock/my-page-recommend-home.ts b/lib/mock/my-page-recommend-home.ts index c444594..98f48f1 100644 --- a/lib/mock/my-page-recommend-home.ts +++ b/lib/mock/my-page-recommend-home.ts @@ -1,105 +1,229 @@ -import type { MyPageRecommendHomePost } from "@/types/myPage"; +import type { + MyPageRecommendHomePost, + MyPageRecommendContentsResponse, +} from "@/types/myPage"; export const MOCK_RECOMMEND_HOME_POSTS: MyPageRecommendHomePost[] = [ { - contentId: "content-301", + id: "content-301", title: "React 서버 컴포넌트 완전 정복 — 렌더링 모델과 캐싱 전략", + translatedTitle: null, + author: null, sourceName: "velog", - thumbnail: "https://picsum.photos/seed/rec1/400/240", - summary: "RSC의 렌더링 흐름과 fetch 캐싱 옵션을 심층 분석합니다.", - date: "2026-04-20T10:00:00Z", + thumbnailUrl: "https://picsum.photos/seed/rec1/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: "https://velog.io/@example/react-server-components", + tags: ["React", "Next.js"], + preview: "RSC의 렌더링 흐름과 fetch 캐싱 옵션을 심층 분석합니다.", + publishedAt: "2026-04-20T10:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.98, + likes: 42, + commentsCount: null, + isAnswered: null, }, { - 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", + id: "content-302", + title: + "How I built a type-safe API client with TypeScript satisfies operator", + translatedTitle: + "TypeScript satisfies 연산자로 타입 안전 API 클라이언트 만든 방법", + author: "Dan Abramov", + sourceName: "medium", + thumbnailUrl: "https://picsum.photos/seed/rec2/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: "https://medium.com/@example/typescript-satisfies-api-client", + tags: ["TypeScript"], + preview: "as와 satisfies의 차이점과 실전 활용 패턴을 정리합니다.", + publishedAt: "2026-04-18T09:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.95, + likes: 38, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-303", + id: "content-303", title: "모노레포 환경에서 Turborepo로 빌드 캐시 최적화하기", + translatedTitle: null, + author: null, sourceName: "toss_tech", - thumbnail: null, - date: "2026-04-15T14:00:00Z", + thumbnailUrl: null, + thumbnailWidth: null, + thumbnailHeight: null, + canonicalUrl: "https://toss.tech/posts/turborepo-build-cache", + tags: ["Turborepo", "Monorepo"], + preview: null, + publishedAt: "2026-04-15T14:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.91, + likes: 27, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-304", - title: "PostgreSQL 파티셔닝 전략 — 대용량 테이블 성능 개선 실전 사례", - sourceName: "naver_d2", - thumbnail: "https://picsum.photos/seed/rec4/400/240", - summary: "레인지/리스트/해시 파티셔닝을 실제 서비스 케이스에 적용한 경험을 공유합니다.", - date: "2026-04-13T11:00:00Z", + id: "content-304", + title: "Understanding PostgreSQL Partitioning: Range, List, and Hash", + translatedTitle: "PostgreSQL 파티셔닝 이해하기: 레인지, 리스트, 해시", + author: "Bruce Momjian", + sourceName: "stackoverflow", + thumbnailUrl: "https://picsum.photos/seed/rec4/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: + "https://stackoverflow.com/questions/example/postgresql-partitioning", + tags: ["PostgreSQL", "Database"], + preview: + "레인지/리스트/해시 파티셔닝을 실제 서비스 케이스에 적용한 경험을 공유합니다.", + publishedAt: "2026-04-13T11:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.89, + likes: 55, + commentsCount: 12, + isAnswered: true, }, { - contentId: "content-305", + id: "content-305", title: "Jest + React Testing Library로 컴포넌트 테스트 작성하기", - sourceName: "medium", - thumbnail: "https://picsum.photos/seed/rec5/400/240", - summary: "단위 테스트부터 인터랙션 테스트까지 실전 패턴을 다룹니다.", - date: "2026-04-11T09:00:00Z", - }, - { - contentId: "content-306", - title: "Spring Boot 3 마이그레이션 가이드 — Jakarta EE 전환과 주요 변경점", + translatedTitle: null, + author: null, sourceName: "kakao_tech", - thumbnail: null, - summary: "Spring Boot 2에서 3으로 업그레이드할 때 반드시 확인해야 할 사항들을 정리합니다.", - date: "2026-04-09T14:00:00Z", - }, - { - contentId: "content-307", - title: "AWS Lambda + API Gateway로 서버리스 백엔드 구축하기", - sourceName: "velog", - thumbnail: "https://picsum.photos/seed/rec7/400/240", - date: "2026-04-07T10:00:00Z", + thumbnailUrl: "https://picsum.photos/seed/rec5/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: "https://tech.kakao.com/posts/jest-rtl-component-testing", + tags: ["Jest", "React", "Testing"], + preview: "단위 테스트부터 인터랙션 테스트까지 실전 패턴을 다룹니다.", + publishedAt: "2026-04-11T09:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.87, + likes: 31, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-308", - title: "Tailwind CSS v4 완전 가이드 — @theme 토큰과 CSS 변수 전환", - sourceName: "toss_tech", - thumbnail: "https://picsum.photos/seed/rec8/400/240", - summary: "v3에서 v4로 마이그레이션하는 방법과 새 토큰 시스템을 설명합니다.", - date: "2026-04-05T11:00:00Z", + id: "content-306", + title: "Migrating from Spring Boot 2 to 3: What you need to know", + translatedTitle: "Spring Boot 2에서 3으로 마이그레이션: 알아야 할 것들", + author: "Josh Long", + sourceName: "medium", + thumbnailUrl: null, + thumbnailWidth: null, + thumbnailHeight: null, + canonicalUrl: "https://medium.com/@example/spring-boot-3-migration", + tags: ["Spring Boot", "Java"], + preview: + "Spring Boot 2에서 3으로 업그레이드할 때 반드시 확인해야 할 사항들을 정리합니다.", + publishedAt: "2026-04-09T14:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.85, + likes: 49, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-309", - title: "CI/CD 파이프라인 구축 — GitHub Actions로 자동 배포 환경 만들기", + id: "content-307", + title: "AWS Lambda + API Gateway로 서버리스 백엔드 구축하기", + translatedTitle: null, + author: null, sourceName: "naver_d2", - thumbnail: null, - summary: "테스트, 빌드, 배포를 자동화하는 전체 워크플로우를 구성하는 방법을 소개합니다.", - date: "2026-04-03T09:00:00Z", + thumbnailUrl: "https://picsum.photos/seed/rec7/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: "https://d2.naver.com/posts/aws-lambda-api-gateway", + tags: ["AWS", "Serverless"], + preview: null, + publishedAt: "2026-04-07T10:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.83, + likes: 22, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-310", - title: "웹 접근성 A11y 실천 가이드 — WCAG 2.1 기준으로 코드 개선하기", + id: "content-308", + title: "A deep dive into Tailwind CSS v4 and the new @theme directive", + translatedTitle: "Tailwind CSS v4 심층 분석 — 새로운 @theme 지시어", + author: "Adam Wathan", sourceName: "medium", - thumbnail: "https://picsum.photos/seed/rec10/400/240", - date: "2026-04-01T13:00:00Z", + thumbnailUrl: "https://picsum.photos/seed/rec8/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: "https://medium.com/@example/tailwind-css-v4-theme", + tags: ["Tailwind CSS", "CSS"], + preview: "v3에서 v4로 마이그레이션하는 방법과 새 토큰 시스템을 설명합니다.", + publishedAt: "2026-04-05T11:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.81, + likes: 36, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-311", - title: "Storybook 8 업데이트 — CSF3 형식과 Play function 실전 활용", - sourceName: "kakao_tech", - thumbnail: "https://picsum.photos/seed/rec11/400/240", - summary: "Storybook 최신 버전의 핵심 변경점과 인터랙션 테스트 작성법을 살펴봅니다.", - date: "2026-03-30T10:00:00Z", + id: "content-309", + title: "CI/CD 파이프라인 구축 — GitHub Actions로 자동 배포 환경 만들기", + translatedTitle: null, + author: null, + sourceName: "toss_tech", + thumbnailUrl: null, + thumbnailWidth: null, + thumbnailHeight: null, + canonicalUrl: "https://toss.tech/posts/github-actions-cicd", + tags: ["GitHub Actions", "CI/CD"], + preview: + "테스트, 빌드, 배포를 자동화하는 전체 워크플로우를 구성하는 방법을 소개합니다.", + publishedAt: "2026-04-03T09:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.79, + likes: 18, + commentsCount: null, + isAnswered: null, }, { - contentId: "content-312", - title: "OpenTelemetry로 분산 추적 구현하기 — 마이크로서비스 관찰 가능성 확보", - sourceName: "toss_tech", - thumbnail: null, - date: "2026-03-27T14:00:00Z", + id: "content-310", + title: "How to make your web app accessible: A practical WCAG 2.1 guide", + translatedTitle: "웹 앱 접근성 확보 방법: WCAG 2.1 실전 가이드", + author: "Léonie Watson", + sourceName: "stackoverflow", + thumbnailUrl: "https://picsum.photos/seed/rec10/400/240", + thumbnailWidth: 400, + thumbnailHeight: 240, + canonicalUrl: + "https://stackoverflow.com/questions/example/wcag-accessibility", + tags: ["Accessibility", "HTML"], + preview: null, + publishedAt: "2026-04-01T13:00:00Z", + isScrapped: false, + isLiked: false, + score: 0.77, + likes: 14, + commentsCount: 8, + isAnswered: true, }, ]; export async function fetchRecommendHomePosts( count?: number, -): Promise { +): Promise { await new Promise((resolve) => setTimeout(resolve, 400)); - return count !== undefined - ? MOCK_RECOMMEND_HOME_POSTS.slice(0, count) - : MOCK_RECOMMEND_HOME_POSTS; + const contents = + count !== undefined + ? MOCK_RECOMMEND_HOME_POSTS.slice(0, count) + : MOCK_RECOMMEND_HOME_POSTS; + return { + contents, + isPersonalized: true, + message: null, + }; } diff --git a/types/myPage.ts b/types/myPage.ts index 59ea608..f77ac0f 100644 --- a/types/myPage.ts +++ b/types/myPage.ts @@ -68,12 +68,30 @@ export interface QuizHistoryDetail { } export interface MyPageRecommendHomePost { - contentId: string; + id: string; title: string; + translatedTitle: string | null; + author: string | null; sourceName: string; - thumbnail: string | null; - summary?: string; - date: string; + preview: string | null; + thumbnailUrl: string | null; + thumbnailWidth: number | null; + thumbnailHeight: number | null; + canonicalUrl: string; + tags: string[]; + publishedAt: string; + isScrapped: boolean; + isLiked: boolean; + score: number | null; + likes: number | null; + commentsCount: number | null; + isAnswered: boolean | null; +} + +export interface MyPageRecommendContentsResponse { + contents: MyPageRecommendHomePost[]; + isPersonalized: boolean; + message?: string | null; } export interface MyPageRecommendVideo {