From 0d08a3e4cbe529f1c32c1e8ebd10f665da3e9b3c Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 00:01:21 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20ai=20news=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20route=20handler=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/news/route.ts | 45 ++++++++++ src/entities/news/handler/index.ts | 0 .../news/handler/news-pagination.handler.ts | 83 +++++++++++++++++++ src/entities/news/index.ts | 0 4 files changed, 128 insertions(+) create mode 100644 src/app/api/news/route.ts create mode 100644 src/entities/news/handler/index.ts create mode 100644 src/entities/news/handler/news-pagination.handler.ts create mode 100644 src/entities/news/index.ts diff --git a/src/app/api/news/route.ts b/src/app/api/news/route.ts new file mode 100644 index 0000000..aec77b9 --- /dev/null +++ b/src/app/api/news/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { newsPaginationHandler } from "@/entities/news/handler/news-pagination.handler"; + +export async function GET(request: NextRequest) { + try { + // URL에서 page 파라미터 추출 + const searchParams = request.nextUrl.searchParams; + const pageParam = searchParams.get("page"); + const page = pageParam ? parseInt(pageParam, 10) : 0; + + // 페이지 번호 유효성 검사 + if (isNaN(page) || page < 0) { + return NextResponse.json( + { + status: "error", + data: null, + error: "Invalid page parameter", + }, + { status: 400 }, + ); + } + + // 핸들러 호출 + const data = await newsPaginationHandler(page); + + return NextResponse.json( + { + status: "success", + data, + }, + { status: 200 }, + ); + } catch (error) { + console.error("News API error:", error); + return NextResponse.json( + { + status: "error", + data: null, + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500 }, + ); + } +} diff --git a/src/entities/news/handler/index.ts b/src/entities/news/handler/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/entities/news/handler/news-pagination.handler.ts b/src/entities/news/handler/news-pagination.handler.ts new file mode 100644 index 0000000..a9858dc --- /dev/null +++ b/src/entities/news/handler/news-pagination.handler.ts @@ -0,0 +1,83 @@ +import { AnalysisItem, NewsAnalysisItem, NewsItem, Summary, analysisAPI, newsAPI } from "../model"; + +export interface PaginatedNewsResponse { + newsDate: string; + totalNews: number; + investmentIndex: number; + summary: Summary; + keywords: string[]; + newsAnalysis: NewsAnalysisItem[]; + pagination: { + currentPage: number; + totalPages: number; + totalItems: number; + hasNext: boolean; + hasPrev: boolean; + }; +} + +const ITEMS_PER_PAGE = 5; + +export const newsPaginationHandler = async (page: number = 0): Promise => { + // 두 API를 병렬로 호출 + const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); + + if (!newsResponse.success || !analysisResponse.success) { + throw new Error("Failed to fetch news data"); + } + + const newsData = newsResponse.data as NewsItem[]; + const analysisData = analysisResponse.data as AnalysisItem; + + // newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해) + const analysisMap = new Map(analysisData.newsAnalysis.map((item) => [item.newsId, item])); + + // newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭 + const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => { + // analysisMap에서 같은 newsId를 가진 분석 찾기 + const analysis = analysisMap.get(news.id) || { + newsId: news.id, + reason: "", + keywords: [], + sentiment: "neutral" as const, + confidence: 0, + }; + + return { + newsId: news.id, + title: news.title, + content: news.content, + url: news.url, + source: news.source, + reason: analysis.reason, + keywords: analysis.keywords, + sentiment: analysis.sentiment, + confidence: analysis.confidence, + }; + }); + + // 페이지네이션 계산 + const totalItems = combinedNewsAnalysis.length; + const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); + const startIndex = page * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + + // 현재 페이지의 데이터만 추출 + const paginatedNewsAnalysis = combinedNewsAnalysis.slice(startIndex, endIndex); + + return { + newsDate: analysisData.date, + totalNews: analysisData.totalNews, + investmentIndex: analysisData.investmentIndex, + summary: analysisData.summary, + keywords: analysisData.keywords, + newsAnalysis: paginatedNewsAnalysis, + pagination: { + currentPage: page, + totalPages, + totalItems, + hasNext: page < totalPages - 1, + hasPrev: page > 0, + }, + }; +}; diff --git a/src/entities/news/index.ts b/src/entities/news/index.ts new file mode 100644 index 0000000..e69de29 From b822dd4cd357e620063ff0e4a2314a2de002027e Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 01:25:27 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20progress,=20badge=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 26 +++++++++++++++++++ src/shared/components/ui/badge.tsx | 37 +++++++++++++++++++++++++++ src/shared/components/ui/index.ts | 2 ++ src/shared/components/ui/progress.tsx | 25 ++++++++++++++++++ 5 files changed, 91 insertions(+) create mode 100644 src/shared/components/ui/badge.tsx create mode 100644 src/shared/components/ui/progress.tsx diff --git a/package.json b/package.json index b4923e9..065060b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3c3d32..09a0ae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.8 version: 2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -738,6 +741,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -3493,6 +3509,16 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 diff --git a/src/shared/components/ui/badge.tsx b/src/shared/components/ui/badge.tsx new file mode 100644 index 0000000..973b93f --- /dev/null +++ b/src/shared/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; + +import { cn } from "../../utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ; +} + +export { Badge, badgeVariants }; diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index 4946e48..12d600c 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -1,4 +1,5 @@ export * from "./avatar"; +export * from "./badge"; export * from "./button"; export * from "./card"; export * from "./chart"; @@ -14,3 +15,4 @@ export * from "./sonner"; export * from "./spinner"; export * from "./table"; export * from "./textarea"; +export * from "./progress"; diff --git a/src/shared/components/ui/progress.tsx b/src/shared/components/ui/progress.tsx new file mode 100644 index 0000000..a505142 --- /dev/null +++ b/src/shared/components/ui/progress.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as React from "react"; + +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "../../utils"; + +function Progress({ className, value, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; From 7aaefe40335b224bcdfde6ff2004015b39b536d8 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 01:27:18 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20news=20pagination=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=83=80=EC=9E=85=20=EC=BD=94=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/handler/news-pagination.handler.ts | 18 +----------------- src/entities/news/model/types/news.type.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/entities/news/handler/news-pagination.handler.ts b/src/entities/news/handler/news-pagination.handler.ts index a9858dc..28025f9 100644 --- a/src/entities/news/handler/news-pagination.handler.ts +++ b/src/entities/news/handler/news-pagination.handler.ts @@ -1,20 +1,4 @@ -import { AnalysisItem, NewsAnalysisItem, NewsItem, Summary, analysisAPI, newsAPI } from "../model"; - -export interface PaginatedNewsResponse { - newsDate: string; - totalNews: number; - investmentIndex: number; - summary: Summary; - keywords: string[]; - newsAnalysis: NewsAnalysisItem[]; - pagination: { - currentPage: number; - totalPages: number; - totalItems: number; - hasNext: boolean; - hasPrev: boolean; - }; -} +import { AnalysisItem, NewsAnalysisItem, NewsItem, PaginatedNewsResponse, analysisAPI, newsAPI } from "../model"; const ITEMS_PER_PAGE = 5; diff --git a/src/entities/news/model/types/news.type.ts b/src/entities/news/model/types/news.type.ts index 93e0ced..bfae645 100644 --- a/src/entities/news/model/types/news.type.ts +++ b/src/entities/news/model/types/news.type.ts @@ -42,3 +42,19 @@ export type AnalysisItem = { confidence: number; }[]; }; + +export interface PaginatedNewsResponse { + newsDate: string; + totalNews: number; + investmentIndex: number; + summary: Summary; + keywords: string[]; + newsAnalysis: NewsAnalysisItem[]; + pagination: { + currentPage: number; + totalPages: number; + totalItems: number; + hasNext: boolean; + hasPrev: boolean; + }; +} From c3b1744f481eb68d0c0957f89f1aed96027e0427 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 01:27:42 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20news=20list=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20api=20=EB=B0=8F=20query=20hook=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/index.ts | 1 + src/entities/news/index.ts | 1 + src/entities/news/model/apis/index.ts | 3 ++- src/entities/news/model/apis/news-list.api.ts | 19 +++++++++++++++++++ src/entities/news/model/hooks/index.ts | 1 + .../news/model/hooks/useGetNewsList.ts | 12 ++++++++++++ src/entities/news/model/index.ts | 1 + 7 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/entities/news/model/apis/news-list.api.ts create mode 100644 src/entities/news/model/hooks/index.ts create mode 100644 src/entities/news/model/hooks/useGetNewsList.ts diff --git a/src/entities/index.ts b/src/entities/index.ts index f0a06a4..f19112a 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./market"; export * from "./user"; +export * from "./news"; diff --git a/src/entities/news/index.ts b/src/entities/news/index.ts index e69de29..b6837c3 100644 --- a/src/entities/news/index.ts +++ b/src/entities/news/index.ts @@ -0,0 +1 @@ +export * from "./model"; diff --git a/src/entities/news/model/apis/index.ts b/src/entities/news/model/apis/index.ts index 388887b..a6e5904 100644 --- a/src/entities/news/model/apis/index.ts +++ b/src/entities/news/model/apis/index.ts @@ -1,2 +1,3 @@ -export * from "./news.api"; export * from "./analysis.api"; +export * from "./news-list.api"; +export * from "./news.api"; diff --git a/src/entities/news/model/apis/news-list.api.ts b/src/entities/news/model/apis/news-list.api.ts new file mode 100644 index 0000000..ba8e527 --- /dev/null +++ b/src/entities/news/model/apis/news-list.api.ts @@ -0,0 +1,19 @@ +import { APIResponse } from "@/shared"; + +import { PaginatedNewsResponse } from "../types"; + +export type NewsListParams = { + page?: number; +}; + +export type NewsListResponse = APIResponse; + +export const newsListAPI = async ({ page = 0 }: NewsListParams): Promise => { + const response = await fetch(`/api/news?page=${page}`); + + if (!response.ok) { + throw new Error("Failed to fetch news list"); + } + + return response.json(); +}; diff --git a/src/entities/news/model/hooks/index.ts b/src/entities/news/model/hooks/index.ts new file mode 100644 index 0000000..60c68b4 --- /dev/null +++ b/src/entities/news/model/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useGetNewsList"; diff --git a/src/entities/news/model/hooks/useGetNewsList.ts b/src/entities/news/model/hooks/useGetNewsList.ts new file mode 100644 index 0000000..0babc40 --- /dev/null +++ b/src/entities/news/model/hooks/useGetNewsList.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { newsListAPI } from "../apis"; + +export const useGetNewsList = (page: number) => { + return useQuery({ + queryKey: ["news-list", page], + queryFn: () => newsListAPI({ page }), + }); +}; diff --git a/src/entities/news/model/index.ts b/src/entities/news/model/index.ts index 1063c8d..a1c1e7c 100644 --- a/src/entities/news/model/index.ts +++ b/src/entities/news/model/index.ts @@ -1,2 +1,3 @@ export * from "./apis"; export * from "./types"; +export * from "./hooks"; From 07db577f798945d40a777a4d8d8df2082dbd6600 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 01:30:27 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20news=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/news/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index 6b7f388..aa9679c 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -1,3 +1,184 @@ +"use client"; + +import { useGetNewsList } from "@/entities"; +import { Badge, Button, Separator } from "@/shared"; +import { ExternalLink, Minus, TrendingDown, TrendingUp } from "lucide-react"; + +const cleanContent = (content: string) => { + const regex = /\[블록미디어\s+.*?\s*기자\]/; + const match = content.match(regex); + if (match) { + const matchIndex = content.indexOf(match[0]); + return content.slice(matchIndex + match[0].length).trim(); + } + return content; +}; + +// 감정 상태에 따른 스타일 및 아이콘 매핑 +const getSentimentConfig = (sentiment: string) => { + switch (sentiment) { + case "positive": + return { + color: "text-green-400", + bg: "bg-green-400/10", + border: "border-green-400/20", + label: "호재", + icon: , + }; + case "negative": + return { + color: "text-red-400", + bg: "bg-red-400/10", + border: "border-red-400/20", + label: "악재", + icon: , + }; + default: + return { + color: "text-gray-400", + bg: "bg-gray-400/10", + border: "border-gray-400/20", + label: "중립", + icon: , + }; + } +}; + export default function NewsPage() { - return
News Page
; + const { data: newsResponse, isLoading } = useGetNewsList(0); + + if (isLoading) return
데이터를 불러오는 중입니다...
; + if (!newsResponse?.data) return null; + + const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsResponse.data; + return ( +
+

뉴스 목록

+

AI가 분석한 코인 관련 뉴스입니다.

+
+
+
+
+

Market Briefing

+

+ {newsDate} 기준 • 총 {totalNews}건의 뉴스 분석 +

+
+
+

투자 심리 지수

+
+ = 50 ? "text-green-400" : "text-red-400"}`}> + {investmentIndex} + + / 100 +
+
+
+ + {/* 감정 분포 바 (Progress Bar) */} +
+
+
+
+
+
+ 긍정 {summary.positive}건 + 중립 {summary.neutral}건 + 부정 {summary.negative}건 +
+ + + + {/* 오늘의 핵심 키워드 */} +
+

Today's Keywords

+
+ {keywords.map((k) => ( + + # {k} + + ))} +
+
+
+ + {/* --- 3. Micro View: 개별 뉴스 카드 리스트 --- */} +
+ {newsAnalysis.map((news) => { + const config = getSentimentConfig(news.sentiment); + + return ( +
+ {/* 상단: 타이틀 및 뱃지 */} +
+
+
+
+ + {config.icon} + {config.label} + + AI 신뢰도 {news.confidence}% +
+

+ {news.title} +

+
+ +
+ + {/* 핵심: AI 분석 이유 (Reason) 강조 */} +
+

+ AI Insight +

+

{news.reason}

+
+ + {/* 본문 내용 */} +

+ {cleanContent(news.content)} +

+
+ + {/* 하단: 메타 정보 */} +
+
+ {news.keywords.map((k) => ( + + # {k} + + ))} +
+ {news.source} +
+
+ ); + })} +
+
+
+ ); } From 503910becd5f7e8009600731508eb315b14c8827 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 15:53:57 +0900 Subject: [PATCH 06/12] =?UTF-8?q?chore:=20tailwind=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=A0=9C=EA=B1=B0=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/package.json b/package.json index 065060b..2bdac41 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", "supabase": ">1.8.1", + "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09a0ae7..1a0c85c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: supabase: specifier: '>1.8.1' version: 2.58.5 + tailwind-scrollbar-hide: + specifier: ^4.0.0 + version: 4.0.0(tailwindcss@4.1.17) tailwindcss: specifier: ^4 version: 4.1.17 @@ -2754,6 +2757,11 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwind-scrollbar-hide@4.0.0: + resolution: {integrity: sha512-gobtvVcThB2Dxhy0EeYSS1RKQJ5baDFkamkhwBvzvevwX6L4XQfpZ3me9s25Ss1ecFVT5jPYJ50n+7xTBJG9WQ==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 4.0.0 || >= 4.0.0-beta.8 || >= 4.0.0-alpha.20' + tailwindcss@4.1.17: resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} @@ -5628,6 +5636,10 @@ snapshots: tailwind-merge@3.3.1: {} + tailwind-scrollbar-hide@4.0.0(tailwindcss@4.1.17): + dependencies: + tailwindcss: 4.1.17 + tailwindcss@4.1.17: {} tapable@2.3.0: {} From 65e569d3511b311cd5f21de6aed52bdf2c90a940 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 15:54:14 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20tailwind=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=A0=9C=EA=B1=B0=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index 3adadee..f5a476d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "tailwind-scrollbar-hide/v4"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @@ -132,7 +133,7 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply scrollbar-hide border-border outline-ring/50; } body { @apply bg-background text-text-dark; From fd18c63df3a3246563c88b705697dc8f18df2dee Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 15:55:10 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20news=20list=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20skeleton=20=EC=BD=94=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/news/page.tsx | 186 ++---------------- src/entities/news/model/types/news.type.ts | 16 +- src/features/index.ts | 1 + src/features/news/components/common/index.ts | 1 + .../common/news-card/ContentBox.tsx | 27 +++ .../common/news-card/MetaDataBox.tsx | 35 ++++ .../components/common/news-card/ReasonBox.tsx | 29 +++ .../components/common/news-card/TitleBox.tsx | 63 ++++++ .../news/components/common/news-card/index.ts | 4 + .../news/components/features/index.ts | 2 + .../features/news-list/NewsCard.tsx | 26 +++ .../components/features/news-list/index.ts | 1 + .../features/summary-news/DistributionBox.tsx | 61 ++++++ .../features/summary-news/KeywordBox.tsx | 27 +++ .../features/summary-news/NewsListHeader.tsx | 38 ++++ .../components/features/summary-news/index.ts | 3 + src/features/news/components/index.ts | 1 + src/features/news/index.ts | 1 + src/features/news/ui/NewsListSection.tsx | 42 ++++ src/features/news/ui/SummaryNewsSection.tsx | 31 +++ src/features/news/ui/index.ts | 2 + src/features/news/utils/clean-content.ts | 21 ++ src/features/news/utils/index.ts | 3 + src/features/news/utils/progress-width.ts | 5 + src/features/news/utils/sentiment-config.tsx | 30 +++ 25 files changed, 482 insertions(+), 174 deletions(-) create mode 100644 src/features/news/components/common/index.ts create mode 100644 src/features/news/components/common/news-card/ContentBox.tsx create mode 100644 src/features/news/components/common/news-card/MetaDataBox.tsx create mode 100644 src/features/news/components/common/news-card/ReasonBox.tsx create mode 100644 src/features/news/components/common/news-card/TitleBox.tsx create mode 100644 src/features/news/components/common/news-card/index.ts create mode 100644 src/features/news/components/features/index.ts create mode 100644 src/features/news/components/features/news-list/NewsCard.tsx create mode 100644 src/features/news/components/features/news-list/index.ts create mode 100644 src/features/news/components/features/summary-news/DistributionBox.tsx create mode 100644 src/features/news/components/features/summary-news/KeywordBox.tsx create mode 100644 src/features/news/components/features/summary-news/NewsListHeader.tsx create mode 100644 src/features/news/components/features/summary-news/index.ts create mode 100644 src/features/news/components/index.ts create mode 100644 src/features/news/index.ts create mode 100644 src/features/news/ui/NewsListSection.tsx create mode 100644 src/features/news/ui/SummaryNewsSection.tsx create mode 100644 src/features/news/ui/index.ts create mode 100644 src/features/news/utils/clean-content.ts create mode 100644 src/features/news/utils/index.ts create mode 100644 src/features/news/utils/progress-width.ts create mode 100644 src/features/news/utils/sentiment-config.tsx diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index aa9679c..ed29bb8 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -1,183 +1,35 @@ "use client"; import { useGetNewsList } from "@/entities"; -import { Badge, Button, Separator } from "@/shared"; -import { ExternalLink, Minus, TrendingDown, TrendingUp } from "lucide-react"; - -const cleanContent = (content: string) => { - const regex = /\[블록미디어\s+.*?\s*기자\]/; - const match = content.match(regex); - if (match) { - const matchIndex = content.indexOf(match[0]); - return content.slice(matchIndex + match[0].length).trim(); - } - return content; -}; - -// 감정 상태에 따른 스타일 및 아이콘 매핑 -const getSentimentConfig = (sentiment: string) => { - switch (sentiment) { - case "positive": - return { - color: "text-green-400", - bg: "bg-green-400/10", - border: "border-green-400/20", - label: "호재", - icon: , - }; - case "negative": - return { - color: "text-red-400", - bg: "bg-red-400/10", - border: "border-red-400/20", - label: "악재", - icon: , - }; - default: - return { - color: "text-gray-400", - bg: "bg-gray-400/10", - border: "border-gray-400/20", - label: "중립", - icon: , - }; - } -}; +import { NewsListSection, SummaryNewsSection } from "@/features"; export default function NewsPage() { const { data: newsResponse, isLoading } = useGetNewsList(0); - if (isLoading) return
데이터를 불러오는 중입니다...
; - if (!newsResponse?.data) return null; + const newsData = newsResponse?.data; + const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || { + newsDate: "", + totalNews: 0, + investmentIndex: 0, + summary: { positive: 0, negative: 0, neutral: 0 }, + keywords: [], + newsAnalysis: [], + }; - const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsResponse.data; return (

뉴스 목록

AI가 분석한 코인 관련 뉴스입니다.

-
-
-
-

Market Briefing

-

- {newsDate} 기준 • 총 {totalNews}건의 뉴스 분석 -

-
-
-

투자 심리 지수

-
- = 50 ? "text-green-400" : "text-red-400"}`}> - {investmentIndex} - - / 100 -
-
-
- - {/* 감정 분포 바 (Progress Bar) */} -
-
-
-
-
-
- 긍정 {summary.positive}건 - 중립 {summary.neutral}건 - 부정 {summary.negative}건 -
- - - - {/* 오늘의 핵심 키워드 */} -
-

Today's Keywords

-
- {keywords.map((k) => ( - - # {k} - - ))} -
-
-
- - {/* --- 3. Micro View: 개별 뉴스 카드 리스트 --- */} -
- {newsAnalysis.map((news) => { - const config = getSentimentConfig(news.sentiment); - - return ( -
- {/* 상단: 타이틀 및 뱃지 */} -
-
-
-
- - {config.icon} - {config.label} - - AI 신뢰도 {news.confidence}% -
-

- {news.title} -

-
- -
- - {/* 핵심: AI 분석 이유 (Reason) 강조 */} -
-

- AI Insight -

-

{news.reason}

-
- - {/* 본문 내용 */} -

- {cleanContent(news.content)} -

-
- - {/* 하단: 메타 정보 */} -
-
- {news.keywords.map((k) => ( - - # {k} - - ))} -
- {news.source} -
-
- ); - })} -
+ +
); diff --git a/src/entities/news/model/types/news.type.ts b/src/entities/news/model/types/news.type.ts index bfae645..9933c4b 100644 --- a/src/entities/news/model/types/news.type.ts +++ b/src/entities/news/model/types/news.type.ts @@ -28,19 +28,21 @@ export type NewsItem = { scrapedAt: string; }; +export type NewsAnalysisData = { + newsId: number; + reason: string; + keywords: string[]; + sentiment: Sentiment; + confidence: number; +}; + export type AnalysisItem = { date: string; totalNews: number; investmentIndex: number; summary: Summary; keywords: string[]; - newsAnalysis: { - newsId: number; - reason: string; - keywords: string[]; - sentiment: Sentiment; - confidence: number; - }[]; + newsAnalysis: NewsAnalysisData[]; }; export interface PaginatedNewsResponse { diff --git a/src/features/index.ts b/src/features/index.ts index c33a01f..7626f0e 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,5 +1,6 @@ export * from "./home"; export * from "./login"; +export * from "./news"; export * from "./setting"; export * from "./signup"; export * from "./wallet"; diff --git a/src/features/news/components/common/index.ts b/src/features/news/components/common/index.ts new file mode 100644 index 0000000..caf1a9f --- /dev/null +++ b/src/features/news/components/common/index.ts @@ -0,0 +1 @@ +export * from "./news-card"; diff --git a/src/features/news/components/common/news-card/ContentBox.tsx b/src/features/news/components/common/news-card/ContentBox.tsx new file mode 100644 index 0000000..b1e5d83 --- /dev/null +++ b/src/features/news/components/common/news-card/ContentBox.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/shared"; + +import { cleanContent } from "../../../utils"; + +type Props = { + content: string; + isLoading?: boolean; +}; + +export const ContentBox = ({ content, isLoading }: Props) => { + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + return ( +

+ {cleanContent(content)} +

+ ); +}; diff --git a/src/features/news/components/common/news-card/MetaDataBox.tsx b/src/features/news/components/common/news-card/MetaDataBox.tsx new file mode 100644 index 0000000..b929e50 --- /dev/null +++ b/src/features/news/components/common/news-card/MetaDataBox.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from "@/shared"; + +type Props = { + keywords: string[]; + source: string; + isLoading?: boolean; +}; + +export const MetaDataBox = ({ keywords, source, isLoading }: Props) => { + if (isLoading) { + return ( +
+
+ + + +
+ +
+ ); + } + + return ( +
+
+ {keywords.map((k) => ( + + # {k} + + ))} +
+ {source} +
+ ); +}; diff --git a/src/features/news/components/common/news-card/ReasonBox.tsx b/src/features/news/components/common/news-card/ReasonBox.tsx new file mode 100644 index 0000000..943751c --- /dev/null +++ b/src/features/news/components/common/news-card/ReasonBox.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@/shared"; + +type Props = { + reason: string; + config: { + border: string; + bg: string; + }; + isLoading?: boolean; +}; + +export const ReasonBox = ({ reason, config, isLoading }: Props) => { + if (isLoading) { + return ( +
+ + + +
+ ); + } + + return ( +
+

AI Insight

+

{reason}

+
+ ); +}; diff --git a/src/features/news/components/common/news-card/TitleBox.tsx b/src/features/news/components/common/news-card/TitleBox.tsx new file mode 100644 index 0000000..8847b01 --- /dev/null +++ b/src/features/news/components/common/news-card/TitleBox.tsx @@ -0,0 +1,63 @@ +import { Badge, Button, Skeleton } from "@/shared"; +import { ExternalLink } from "lucide-react"; + +type Props = { + title: string; + url: string; + confidence: number; + config: { + label: string; + icon: React.ReactNode; + color: string; + bg: string; + border: string; + }; + isLoading?: boolean; +}; + +export const TitleBox = ({ title, url, confidence, config, isLoading }: Props) => { + const handleOpenLink = () => { + window.open(url, "_blank", "noopener,noreferrer"); + }; + + if (isLoading) { + return ( +
+
+
+ + +
+ + +
+ +
+ ); + } + + return ( +
+
+
+ + {config.icon} + {config.label} + + AI 신뢰도 {confidence}% +
+

+ {title} +

+
+ +
+ ); +}; diff --git a/src/features/news/components/common/news-card/index.ts b/src/features/news/components/common/news-card/index.ts new file mode 100644 index 0000000..735dbf3 --- /dev/null +++ b/src/features/news/components/common/news-card/index.ts @@ -0,0 +1,4 @@ +export * from "./ContentBox"; +export * from "./MetaDataBox"; +export * from "./ReasonBox"; +export * from "./TitleBox"; diff --git a/src/features/news/components/features/index.ts b/src/features/news/components/features/index.ts new file mode 100644 index 0000000..097599a --- /dev/null +++ b/src/features/news/components/features/index.ts @@ -0,0 +1,2 @@ +export * from "./news-list"; +export * from "./summary-news"; diff --git a/src/features/news/components/features/news-list/NewsCard.tsx b/src/features/news/components/features/news-list/NewsCard.tsx new file mode 100644 index 0000000..37e6dcf --- /dev/null +++ b/src/features/news/components/features/news-list/NewsCard.tsx @@ -0,0 +1,26 @@ +import { NewsAnalysisItem } from "@/entities"; + +import { getSentimentConfig } from "../../../utils"; +import { ContentBox, MetaDataBox, ReasonBox, TitleBox } from "../../common"; + +type NewsCardProps = { + news: NewsAnalysisItem; + isLoading?: boolean; +}; + +export const NewsCard = ({ news, isLoading }: NewsCardProps) => { + const { reason, content, title, keywords, source } = news; + + const config = getSentimentConfig(news.sentiment); + + return ( +
+ + + +
+ +
+
+ ); +}; diff --git a/src/features/news/components/features/news-list/index.ts b/src/features/news/components/features/news-list/index.ts new file mode 100644 index 0000000..1e94492 --- /dev/null +++ b/src/features/news/components/features/news-list/index.ts @@ -0,0 +1 @@ +export * from "./NewsCard"; diff --git a/src/features/news/components/features/summary-news/DistributionBox.tsx b/src/features/news/components/features/summary-news/DistributionBox.tsx new file mode 100644 index 0000000..b04f197 --- /dev/null +++ b/src/features/news/components/features/summary-news/DistributionBox.tsx @@ -0,0 +1,61 @@ +import { Summary } from "@/entities"; +import { Skeleton, cn } from "@/shared"; + +import { getWidthStyle } from "../../../utils"; + +type Props = { + summary: Summary; + totalNews: number; + isLoading: boolean; +}; + +export const DistributionBox = ({ summary, totalNews, isLoading }: Props) => { + if (isLoading) { + return ( + <> + +
+ + + +
+ + ); + } + const segments = [ + { + key: "positive", + value: summary.positive, + color: "bg-positive", + }, + { + key: "neutral", + value: summary.neutral, + color: "bg-text-dark/50", + }, + { + key: "negative", + value: summary.negative, + color: "bg-negative", + }, + ]; + + return ( + <> +
+ {segments.map((segment) => ( +
+ ))} +
+
+ 긍정 {summary.positive}건 + 중립 {summary.neutral}건 + 부정 {summary.negative}건 +
+ + ); +}; diff --git a/src/features/news/components/features/summary-news/KeywordBox.tsx b/src/features/news/components/features/summary-news/KeywordBox.tsx new file mode 100644 index 0000000..79695d9 --- /dev/null +++ b/src/features/news/components/features/summary-news/KeywordBox.tsx @@ -0,0 +1,27 @@ +import { Badge, Skeleton } from "@/shared"; + +type Props = { + keywords: string[]; + isLoading: boolean; +}; + +export const KeywordBox = ({ keywords, isLoading }: Props) => { + return ( +
+

Today's Keywords

+
+ {isLoading + ? Array.from({ length: 5 }).map((_, index) => ) + : keywords.map((k) => ( + + # {k} + + ))} +
+
+ ); +}; diff --git a/src/features/news/components/features/summary-news/NewsListHeader.tsx b/src/features/news/components/features/summary-news/NewsListHeader.tsx new file mode 100644 index 0000000..a968254 --- /dev/null +++ b/src/features/news/components/features/summary-news/NewsListHeader.tsx @@ -0,0 +1,38 @@ +import { Skeleton } from "@/shared"; + +type Props = { + newsDate: string; + totalNews: number; + investmentIndex: number; + isLoading: boolean; +}; + +export const NewsListHeader = ({ newsDate, totalNews, investmentIndex, isLoading }: Props) => { + return ( +
+
+

Coin Market Briefing

+ {isLoading ? ( + + ) : ( +

+ {newsDate} 기준 • 총 {totalNews}건의 뉴스 분석 +

+ )} +
+
+

투자 심리 지수

+ {isLoading ? ( + + ) : ( +
+ = 50 ? "text-positive" : "text-negative"}`}> + {investmentIndex} + + / 100 +
+ )} +
+
+ ); +}; diff --git a/src/features/news/components/features/summary-news/index.ts b/src/features/news/components/features/summary-news/index.ts new file mode 100644 index 0000000..37ec3a9 --- /dev/null +++ b/src/features/news/components/features/summary-news/index.ts @@ -0,0 +1,3 @@ +export * from "./DistributionBox"; +export * from "./KeywordBox"; +export * from "./NewsListHeader"; diff --git a/src/features/news/components/index.ts b/src/features/news/components/index.ts new file mode 100644 index 0000000..0e84926 --- /dev/null +++ b/src/features/news/components/index.ts @@ -0,0 +1 @@ +export * from "./features"; diff --git a/src/features/news/index.ts b/src/features/news/index.ts new file mode 100644 index 0000000..4aedf59 --- /dev/null +++ b/src/features/news/index.ts @@ -0,0 +1 @@ +export * from "./ui"; diff --git a/src/features/news/ui/NewsListSection.tsx b/src/features/news/ui/NewsListSection.tsx new file mode 100644 index 0000000..3277eb1 --- /dev/null +++ b/src/features/news/ui/NewsListSection.tsx @@ -0,0 +1,42 @@ +import { NewsAnalysisItem } from "@/entities"; + +import { NewsCard } from "../components"; + +type Props = { + newsAnalysis: NewsAnalysisItem[]; + isLoading?: boolean; +}; + +export const NewsListSection = ({ newsAnalysis, isLoading }: Props) => { + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+ {newsAnalysis.map((news) => ( + + ))} +
+ ); +}; diff --git a/src/features/news/ui/SummaryNewsSection.tsx b/src/features/news/ui/SummaryNewsSection.tsx new file mode 100644 index 0000000..7eb498b --- /dev/null +++ b/src/features/news/ui/SummaryNewsSection.tsx @@ -0,0 +1,31 @@ +import { Summary } from "@/entities"; +import { Separator } from "@/shared"; + +import { DistributionBox, KeywordBox, NewsListHeader } from "../components"; + +type Props = { + newsDate: string; + totalNews: number; + investmentIndex: number; + summary: Summary; + keywords: string[]; + isLoading: boolean; +}; + +export const SummaryNewsSection = ({ newsDate, totalNews, investmentIndex, summary, keywords, isLoading }: Props) => { + return ( +
+ + + + + + +
+ ); +}; diff --git a/src/features/news/ui/index.ts b/src/features/news/ui/index.ts new file mode 100644 index 0000000..33d63a3 --- /dev/null +++ b/src/features/news/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./NewsListSection"; +export * from "./SummaryNewsSection"; diff --git a/src/features/news/utils/clean-content.ts b/src/features/news/utils/clean-content.ts new file mode 100644 index 0000000..0418ae6 --- /dev/null +++ b/src/features/news/utils/clean-content.ts @@ -0,0 +1,21 @@ +export const cleanContent = (content: string) => { + // 정규식 설명: + // \[ : 대괄호 시작 + // 블록미디어 : 해당 문자열 찾기 + // \s+ : 공백이 하나 이상 있음 + // .*? : 기자 이름 (어떤 문자든 올 수 있음, 최소 매칭) + // 기자 : 해당 문자열 찾기 + // \] : 대괄호 끝 + const regex = /\[블록미디어\s+.*?\s*기자\]/; + + const match = content.match(regex); + + if (match) { + const matchIndex = content.indexOf(match[0]); + const contentAfterTag = content.slice(matchIndex + match[0].length); + + return contentAfterTag.trim(); // 앞뒤 불필요한 공백 제거 + } + + return content; +}; diff --git a/src/features/news/utils/index.ts b/src/features/news/utils/index.ts new file mode 100644 index 0000000..5ec6697 --- /dev/null +++ b/src/features/news/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./clean-content"; +export * from "./progress-width"; +export * from "./sentiment-config"; diff --git a/src/features/news/utils/progress-width.ts b/src/features/news/utils/progress-width.ts new file mode 100644 index 0000000..86f7273 --- /dev/null +++ b/src/features/news/utils/progress-width.ts @@ -0,0 +1,5 @@ +export const getWidthStyle = (count: number, total: number) => { + const percentage = total > 0 ? (count / total) * 100 : 0; + + return { width: `${percentage}%` }; +}; diff --git a/src/features/news/utils/sentiment-config.tsx b/src/features/news/utils/sentiment-config.tsx new file mode 100644 index 0000000..59bca7d --- /dev/null +++ b/src/features/news/utils/sentiment-config.tsx @@ -0,0 +1,30 @@ +import { Minus, TrendingDown, TrendingUp } from "lucide-react"; + +export const getSentimentConfig = (sentiment: string) => { + switch (sentiment) { + case "positive": + return { + color: "text-positive", + bg: "bg-positive/10", + border: "border-positive/20", + label: "호재", + icon: , + }; + case "negative": + return { + color: "text-negative", + bg: "bg-negative/10", + border: "border-negative/20", + label: "악재", + icon: , + }; + default: + return { + color: "text-text-muted-dark", + bg: "bg-text-muted-dark/10", + border: "border-text-muted-dark/20", + label: "중립", + icon: , + }; + } +}; From 9b9b33ddcbe392287d83d10d6445244920f0df3b Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 16:25:44 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=EB=89=B4=EC=8A=A4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20API=20=EC=9D=91=EB=8B=B5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EC=9C=84=ED=95=9C=20Zod=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/handler/news-pagination.handler.ts | 129 ++++++++++-------- src/entities/news/model/index.ts | 1 + src/entities/news/model/schema/index.ts | 1 + src/entities/news/model/schema/news.schema.ts | 58 ++++++++ 4 files changed, 135 insertions(+), 54 deletions(-) create mode 100644 src/entities/news/model/schema/index.ts create mode 100644 src/entities/news/model/schema/news.schema.ts diff --git a/src/entities/news/handler/news-pagination.handler.ts b/src/entities/news/handler/news-pagination.handler.ts index 28025f9..4622a77 100644 --- a/src/entities/news/handler/news-pagination.handler.ts +++ b/src/entities/news/handler/news-pagination.handler.ts @@ -1,67 +1,88 @@ -import { AnalysisItem, NewsAnalysisItem, NewsItem, PaginatedNewsResponse, analysisAPI, newsAPI } from "../model"; +import { + AnalysisAPIResponseSchema, + NewsAPIResponseSchema, + NewsAnalysisData, + NewsAnalysisItem, + PaginatedNewsResponse, + analysisAPI, + newsAPI, +} from "../model"; const ITEMS_PER_PAGE = 5; export const newsPaginationHandler = async (page: number = 0): Promise => { - // 두 API를 병렬로 호출 - const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); + try { + // 두 API를 병렬로 호출 + const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); - if (!newsResponse.success || !analysisResponse.success) { - throw new Error("Failed to fetch news data"); - } + // Zod를 사용한 API 응답 검증 + const validatedNewsResponse = NewsAPIResponseSchema.parse(newsResponse); + const validatedAnalysisResponse = AnalysisAPIResponseSchema.parse(analysisResponse); - const newsData = newsResponse.data as NewsItem[]; - const analysisData = analysisResponse.data as AnalysisItem; + if (!validatedNewsResponse.success || !validatedAnalysisResponse.success) { + throw new Error("Failed to fetch news data"); + } - // newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해) - const analysisMap = new Map(analysisData.newsAnalysis.map((item) => [item.newsId, item])); + const newsData = validatedNewsResponse.data; + const analysisData = validatedAnalysisResponse.data; - // newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭 - const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => { - // analysisMap에서 같은 newsId를 가진 분석 찾기 - const analysis = analysisMap.get(news.id) || { - newsId: news.id, - reason: "", - keywords: [], - sentiment: "neutral" as const, - confidence: 0, - }; + // newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해) + const analysisMap = new Map(analysisData.newsAnalysis.map((item) => [item.newsId, item])); - return { - newsId: news.id, - title: news.title, - content: news.content, - url: news.url, - source: news.source, - reason: analysis.reason, - keywords: analysis.keywords, - sentiment: analysis.sentiment, - confidence: analysis.confidence, - }; - }); + // newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭 + const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => { + // analysisMap에서 같은 newsId를 가진 분석 찾기 + const analysis: NewsAnalysisData = analysisMap.get(news.id) || { + newsId: news.id, + reason: "", + keywords: [], + sentiment: "neutral" as const, + confidence: 0, + }; - // 페이지네이션 계산 - const totalItems = combinedNewsAnalysis.length; - const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); - const startIndex = page * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; + return { + newsId: news.id, + title: news.title, + content: news.content, + url: news.url, + source: news.source, + reason: analysis.reason, + keywords: analysis.keywords, + sentiment: analysis.sentiment, + confidence: analysis.confidence, + }; + }); - // 현재 페이지의 데이터만 추출 - const paginatedNewsAnalysis = combinedNewsAnalysis.slice(startIndex, endIndex); + // 페이지네이션 계산 + const totalItems = combinedNewsAnalysis.length; + const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); + const startIndex = page * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; - return { - newsDate: analysisData.date, - totalNews: analysisData.totalNews, - investmentIndex: analysisData.investmentIndex, - summary: analysisData.summary, - keywords: analysisData.keywords, - newsAnalysis: paginatedNewsAnalysis, - pagination: { - currentPage: page, - totalPages, - totalItems, - hasNext: page < totalPages - 1, - hasPrev: page > 0, - }, - }; + // 현재 페이지의 데이터만 추출 + const paginatedNewsAnalysis = combinedNewsAnalysis.slice(startIndex, endIndex); + + return { + newsDate: analysisData.date, + totalNews: analysisData.totalNews, + investmentIndex: analysisData.investmentIndex, + summary: analysisData.summary, + keywords: analysisData.keywords, + newsAnalysis: paginatedNewsAnalysis, + pagination: { + currentPage: page, + totalPages, + totalItems, + hasNext: page < totalPages - 1, + hasPrev: page > 0, + }, + }; + } catch (error) { + // Zod 검증 실패 또는 기타 에러 처리 + if (error instanceof Error) { + console.error("News pagination handler error:", error.message); + throw new Error(`Failed to process news data: ${error.message}`); + } + throw new Error("Failed to process news data: Unknown error"); + } }; diff --git a/src/entities/news/model/index.ts b/src/entities/news/model/index.ts index a1c1e7c..f8e3786 100644 --- a/src/entities/news/model/index.ts +++ b/src/entities/news/model/index.ts @@ -1,3 +1,4 @@ export * from "./apis"; export * from "./types"; export * from "./hooks"; +export * from "./schema"; diff --git a/src/entities/news/model/schema/index.ts b/src/entities/news/model/schema/index.ts new file mode 100644 index 0000000..39f5df6 --- /dev/null +++ b/src/entities/news/model/schema/index.ts @@ -0,0 +1 @@ +export * from "./news.schema"; diff --git a/src/entities/news/model/schema/news.schema.ts b/src/entities/news/model/schema/news.schema.ts new file mode 100644 index 0000000..2e7a186 --- /dev/null +++ b/src/entities/news/model/schema/news.schema.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +// Sentiment enum +export const SentimentSchema = z.enum(["positive", "negative", "neutral"]); + +// Summary schema +export const SummarySchema = z.object({ + positive: z.number(), + negative: z.number(), + neutral: z.number(), +}); + +// NewsItem schema +export const NewsItemSchema = z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + url: z.string(), + publishedAt: z.string(), + source: z.string(), + scrapedAt: z.string(), +}); + +// NewsAnalysisData schema +export const NewsAnalysisDataSchema = z.object({ + newsId: z.number(), + reason: z.string(), + keywords: z.array(z.string()), + sentiment: SentimentSchema, + confidence: z.number(), +}); + +// AnalysisItem schema +export const AnalysisItemSchema = z.object({ + date: z.string(), + totalNews: z.number(), + investmentIndex: z.number(), + summary: SummarySchema, + keywords: z.array(z.string()), + newsAnalysis: z.array(NewsAnalysisDataSchema), +}); + +// API Response schemas +export const NewsAPIResponseSchema = z.object({ + success: z.boolean(), + data: z.array(NewsItemSchema), +}); + +export const AnalysisAPIResponseSchema = z.object({ + success: z.boolean(), + data: AnalysisItemSchema, +}); + +// Type inference from schemas +export type NewsItemSchemaType = z.infer; +export type AnalysisItemSchemaType = z.infer; +export type NewsAPIResponseSchemaType = z.infer; +export type AnalysisAPIResponseSchemaType = z.infer; From 234e16cca264da1168fa5d63b8a02aed0a5739c3 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 16:29:01 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/handler/news-pagination.handler.ts | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/entities/news/handler/news-pagination.handler.ts b/src/entities/news/handler/news-pagination.handler.ts index 4622a77..d56391e 100644 --- a/src/entities/news/handler/news-pagination.handler.ts +++ b/src/entities/news/handler/news-pagination.handler.ts @@ -1,3 +1,5 @@ +import { unstable_cache } from "next/cache"; + import { AnalysisAPIResponseSchema, NewsAPIResponseSchema, @@ -9,9 +11,10 @@ import { } from "../model"; const ITEMS_PER_PAGE = 5; +const REVALIDATE_TIME = 300; // 5분 (초 단위) -export const newsPaginationHandler = async (page: number = 0): Promise => { - try { +const getMergedNewsData = unstable_cache( + async () => { // 두 API를 병렬로 호출 const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); @@ -31,7 +34,6 @@ export const newsPaginationHandler = async (page: number = 0): Promise { - // analysisMap에서 같은 newsId를 가진 분석 찾기 const analysis: NewsAnalysisData = analysisMap.get(news.id) || { newsId: news.id, reason: "", @@ -53,21 +55,44 @@ export const newsPaginationHandler = async (page: number = 0): Promise => { + try { + // 캐시된 전체 데이터를 가져옴 (API 호출 X, 메모리/파일시스템에서 로드 O) + const cachedData = await getMergedNewsData(); + + const { items, meta } = cachedData; + // 페이지네이션 계산 - const totalItems = combinedNewsAnalysis.length; + const totalItems = items.length; const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); const startIndex = page * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; // 현재 페이지의 데이터만 추출 - const paginatedNewsAnalysis = combinedNewsAnalysis.slice(startIndex, endIndex); + const paginatedNewsAnalysis = items.slice(startIndex, endIndex); return { - newsDate: analysisData.date, - totalNews: analysisData.totalNews, - investmentIndex: analysisData.investmentIndex, - summary: analysisData.summary, - keywords: analysisData.keywords, + newsDate: meta.newsDate, + totalNews: meta.totalNews, + investmentIndex: meta.investmentIndex, + summary: meta.summary, + keywords: meta.keywords, newsAnalysis: paginatedNewsAnalysis, pagination: { currentPage: page, @@ -78,7 +103,6 @@ export const newsPaginationHandler = async (page: number = 0): Promise Date: Sun, 23 Nov 2025 16:30:27 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20map=20=ED=95=A8=EC=88=98=EA=B0=80?= =?UTF-8?q?=20=EA=B3=A0=EC=9C=A0=20key=EB=A5=BC=20=EA=B0=96=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20index=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/news/components/common/news-card/MetaDataBox.tsx | 4 ++-- .../news/components/features/summary-news/KeywordBox.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/news/components/common/news-card/MetaDataBox.tsx b/src/features/news/components/common/news-card/MetaDataBox.tsx index b929e50..c1b89de 100644 --- a/src/features/news/components/common/news-card/MetaDataBox.tsx +++ b/src/features/news/components/common/news-card/MetaDataBox.tsx @@ -23,8 +23,8 @@ export const MetaDataBox = ({ keywords, source, isLoading }: Props) => { return (
- {keywords.map((k) => ( - + {keywords.map((k, index) => ( + # {k} ))} diff --git a/src/features/news/components/features/summary-news/KeywordBox.tsx b/src/features/news/components/features/summary-news/KeywordBox.tsx index 79695d9..c6607e7 100644 --- a/src/features/news/components/features/summary-news/KeywordBox.tsx +++ b/src/features/news/components/features/summary-news/KeywordBox.tsx @@ -12,9 +12,9 @@ export const KeywordBox = ({ keywords, isLoading }: Props) => {
{isLoading ? Array.from({ length: 5 }).map((_, index) => ) - : keywords.map((k) => ( + : keywords.map((k, index) => ( From 00e97281a67e6525254c60208571869954453947 Mon Sep 17 00:00:00 2001 From: Dobbymin Date: Sun, 23 Nov 2025 16:32:25 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20cn=20=EC=9C=A0=ED=8B=B8=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/news/components/common/news-card/ReasonBox.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/news/components/common/news-card/ReasonBox.tsx b/src/features/news/components/common/news-card/ReasonBox.tsx index 943751c..24f3a36 100644 --- a/src/features/news/components/common/news-card/ReasonBox.tsx +++ b/src/features/news/components/common/news-card/ReasonBox.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from "@/shared"; +import { Skeleton, cn } from "@/shared"; type Props = { reason: string; @@ -21,7 +21,7 @@ export const ReasonBox = ({ reason, config, isLoading }: Props) => { } return ( -
+

AI Insight

{reason}