diff --git a/package.json b/package.json index b4923e9..2bdac41 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", @@ -50,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 e3c3d32..1a0c85c 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) @@ -123,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 @@ -738,6 +744,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: @@ -2738,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==} @@ -3493,6 +3517,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 @@ -5602,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: {} 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/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; diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index 6b7f388..ed29bb8 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -1,3 +1,36 @@ +"use client"; + +import { useGetNewsList } from "@/entities"; +import { NewsListSection, SummaryNewsSection } from "@/features"; + export default function NewsPage() { - return
News Page
; + const { data: newsResponse, isLoading } = useGetNewsList(0); + + 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: [], + }; + + return ( +
+

뉴스 목록

+

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

+
+ + +
+
+ ); } 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/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..d56391e --- /dev/null +++ b/src/entities/news/handler/news-pagination.handler.ts @@ -0,0 +1,112 @@ +import { unstable_cache } from "next/cache"; + +import { + AnalysisAPIResponseSchema, + NewsAPIResponseSchema, + NewsAnalysisData, + NewsAnalysisItem, + PaginatedNewsResponse, + analysisAPI, + newsAPI, +} from "../model"; + +const ITEMS_PER_PAGE = 5; +const REVALIDATE_TIME = 300; // 5분 (초 단위) + +const getMergedNewsData = unstable_cache( + async () => { + // 두 API를 병렬로 호출 + const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); + + // Zod를 사용한 API 응답 검증 + const validatedNewsResponse = NewsAPIResponseSchema.parse(newsResponse); + const validatedAnalysisResponse = AnalysisAPIResponseSchema.parse(analysisResponse); + + if (!validatedNewsResponse.success || !validatedAnalysisResponse.success) { + throw new Error("Failed to fetch news data"); + } + + const newsData = validatedNewsResponse.data; + const analysisData = validatedAnalysisResponse.data; + + // newsId를 기준으로 analysis 데이터를 Map으로 변환 (빠른 조회를 위해) + const analysisMap = new Map(analysisData.newsAnalysis.map((item) => [item.newsId, item])); + + // newsData와 analysisData.newsAnalysis를 newsId 기준으로 매칭 + const combinedNewsAnalysis: NewsAnalysisItem[] = newsData.map((news) => { + const analysis: NewsAnalysisData = 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, + }; + }); + + // 전체 데이터 반환 (페이지네이션 되지 않음) + return { + meta: { + newsDate: analysisData.date, + totalNews: analysisData.totalNews, + investmentIndex: analysisData.investmentIndex, + summary: analysisData.summary, + keywords: analysisData.keywords, + }, + items: combinedNewsAnalysis, + }; + }, + ["combined-news-data"], + { revalidate: REVALIDATE_TIME, tags: ["news"] }, +); + +export const newsPaginationHandler = async (page: number = 0): Promise => { + try { + // 캐시된 전체 데이터를 가져옴 (API 호출 X, 메모리/파일시스템에서 로드 O) + const cachedData = await getMergedNewsData(); + + const { items, meta } = cachedData; + + // 페이지네이션 계산 + 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 = items.slice(startIndex, endIndex); + + return { + newsDate: meta.newsDate, + totalNews: meta.totalNews, + investmentIndex: meta.investmentIndex, + summary: meta.summary, + keywords: meta.keywords, + newsAnalysis: paginatedNewsAnalysis, + pagination: { + currentPage: page, + totalPages, + totalItems, + hasNext: page < totalPages - 1, + hasPrev: page > 0, + }, + }; + } catch (error) { + 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/index.ts b/src/entities/news/index.ts new file mode 100644 index 0000000..b6837c3 --- /dev/null +++ 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..f8e3786 100644 --- a/src/entities/news/model/index.ts +++ b/src/entities/news/model/index.ts @@ -1,2 +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; diff --git a/src/entities/news/model/types/news.type.ts b/src/entities/news/model/types/news.type.ts index 93e0ced..9933c4b 100644 --- a/src/entities/news/model/types/news.type.ts +++ b/src/entities/news/model/types/news.type.ts @@ -28,17 +28,35 @@ 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 { + newsDate: string; + totalNews: number; + investmentIndex: number; + summary: Summary; + keywords: string[]; + newsAnalysis: NewsAnalysisItem[]; + pagination: { + currentPage: number; + totalPages: number; + totalItems: number; + hasNext: boolean; + hasPrev: boolean; + }; +} 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..c1b89de --- /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, index) => ( + + # {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..24f3a36 --- /dev/null +++ b/src/features/news/components/common/news-card/ReasonBox.tsx @@ -0,0 +1,29 @@ +import { Skeleton, cn } 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..c6607e7 --- /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, index) => ( + + # {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: , + }; + } +}; 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 };