-
Notifications
You must be signed in to change notification settings - Fork 0
AI News 페이지 구현 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
AI News 페이지 구현 #13
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Summary of ChangesHello @Dobbymin, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 PR은 AI가 분석한 암호화폐 뉴스를 제공하는 전용 뉴스 페이지를 구현합니다. 외부 AI 뉴스 API와 연동하여 뉴스 데이터와 감성 분석 결과를 결합하고, 페이지네이션 기반의 API를 구축하여 효율적인 데이터 로딩을 지원합니다. 또한, Skeleton 로딩 상태를 적용하여 사용자 경험을 향상시키고, 일관된 UI/UX를 위한 레이아웃 시스템을 도입했습니다. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
AI 뉴스 페이지 구현을 위한 PR 잘 보았습니다. 전반적으로 기능 구현이 상세한 설명과 함께 잘 이루어졌습니다. 특히 외부 API 연동 시 Promise.all을 사용한 병렬 처리와 Map을 활용한 데이터 병합 로직은 성능적으로 훌륭한 접근입니다. 몇 가지 개선점을 제안합니다. API 핸들러에서는 응답 객체를 생성하는 헬퍼 함수를 도입하여 코드 중복을 줄이고, 외부 API 응답에 대한 타입 안정성을 높이기 위해 zod와 같은 유효성 검사 라이브러리 사용을 고려해볼 수 있습니다. 또한, API 응답 캐싱을 통해 성능을 더욱 향상시킬 수 있습니다. UI 측면에서는 cn 유틸리티를 일관되게 사용하여 클래스명을 관리하고, 전역적으로 스크롤바를 숨기는 스타일에 대한 접근성 문제를 검토해보는 것이 좋겠습니다. 마지막으로, 컴포넌트에서 map 함수 사용 시 고유한 key 값을 보장하는 방법에 대한 제안과 빈 데이터 상태에 대한 UI 처리도 포함했습니다. 자세한 내용은 각 파일에 남긴 코멘트를 참고해주세요.
| const newsData = newsResponse.data as NewsItem[]; | ||
| const analysisData = analysisResponse.data as AnalysisItem; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
외부 API(newsAPI, analysisAPI) 호출 결과를 타입 단언(as)을 사용하여 처리하고 있습니다. 이는 런타임에 예기치 않은 데이터 구조로 인해 오류를 발생시킬 수 있습니다. zod와 같은 스키마 유효성 검사 라이브러리를 사용하여 API 응답을 파싱하고 검증하는 것이 더 안전합니다. 이를 통해 타입 안정성을 보장하고 데이터 구조 변경에 더 유연하게 대응할 수 있습니다.
// 예시: zod 스키마 정의
import { z } from 'zod';
const NewsItemSchema = z.array(z.object({ /* ... */ }));
const AnalysisItemSchema = z.object({ /* ... */ });
// 핸들러 내부
const newsData = NewsItemSchema.parse(newsResponse.data);
const analysisData = AnalysisItemSchema.parse(analysisResponse.data);| @layer base { | ||
| * { | ||
| @apply border-border outline-ring/50; | ||
| @apply scrollbar-hide border-border outline-ring/50; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export const newsPaginationHandler = async (page: number = 0): Promise<PaginatedNewsResponse> => { | ||
| // 두 API를 병렬로 호출 | ||
| const [newsResponse, analysisResponse] = await Promise.all([newsAPI(), analysisAPI()]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| {keywords.map((k) => ( | ||
| <span key={k} className='text-xs text-text-muted-dark'> | ||
| # {k} | ||
| </span> | ||
| ))} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keywords 배열을 map으로 순회할 때 k(키워드 문자열)를 key로 사용하고 있습니다. 만약 키워드 배열에 중복된 값이 있다면 React에서 key 중복 경고가 발생하고, 예기치 않은 렌더링 문제가 발생할 수 있습니다. map의 두 번째 인자인 index를 조합하여 고유한 key를 보장하는 것이 더 안전합니다.
| {keywords.map((k) => ( | |
| <span key={k} className='text-xs text-text-muted-dark'> | |
| # {k} | |
| </span> | |
| ))} | |
| {keywords.map((k, index) => ( | |
| <span key={`${k}-${index}`} className='text-xs text-text-muted-dark'> | |
| # {k} | |
| </span> | |
| ))} |
| const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || { | ||
| newsDate: "", | ||
| totalNews: 0, | ||
| investmentIndex: 0, | ||
| summary: { positive: 0, negative: 0, neutral: 0 }, | ||
| keywords: [], | ||
| newsAnalysis: [], | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
newsData가 undefined일 경우를 대비한 기본값이 길고 복잡합니다. 이 기본 객체를 DEFAULT_NEWS_DATA와 같은 상수로 추출하면 코드 가독성을 높이고, 다른 곳에서 재사용하기도 용이해집니다.
| const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || { | |
| newsDate: "", | |
| totalNews: 0, | |
| investmentIndex: 0, | |
| summary: { positive: 0, negative: 0, neutral: 0 }, | |
| keywords: [], | |
| newsAnalysis: [], | |
| }; | |
| const DEFAULT_NEWS_DATA = { | |
| newsDate: "", | |
| totalNews: 0, | |
| investmentIndex: 0, | |
| summary: { positive: 0, negative: 0, neutral: 0 }, | |
| keywords: [], | |
| newsAnalysis: [], | |
| }; | |
| const { newsDate, totalNews, investmentIndex, summary, keywords, newsAnalysis } = newsData || DEFAULT_NEWS_DATA; |
| : keywords.map((k) => ( | ||
| <Badge | ||
| key={k} | ||
| variant='secondary' | ||
| className='bg-white/5 text-gray-300 transition-colors hover:bg-white/10' | ||
| > | ||
| # {k} | ||
| </Badge> | ||
| ))} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keywords 배열을 map으로 순회할 때 k(키워드 문자열)를 key로 사용하고 있습니다. 키워드에 중복이 있을 경우 key 중복 문제가 발생할 수 있습니다. map의 index를 함께 사용하여 고유한 key를 만들어주는 것이 좋습니다.
: keywords.map((k, index) => (
<Badge
key={`${k}-${index}`}
variant='secondary'
className='bg-white/5 text-gray-300 transition-colors hover:bg-white/10'
>
# {k}
</Badge>
))
| <span className={`text-3xl font-bold ${investmentIndex >= 50 ? "text-positive" : "text-negative"}`}> | ||
| {investmentIndex} | ||
| </span> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
투자 심리 지수의 색상을 동적으로 결정하기 위해 템플릿 리터럴을 사용하고 있습니다. cn 유틸리티를 사용하면 조건부 클래스를 더 명확하고 안전하게 관리할 수 있습니다.
| <span className={`text-3xl font-bold ${investmentIndex >= 50 ? "text-positive" : "text-negative"}`}> | |
| {investmentIndex} | |
| </span> | |
| <span className={cn("text-3xl font-bold", investmentIndex >= 50 ? "text-positive" : "text-negative")}> | |
| {investmentIndex} | |
| </span> |
| if (isLoading) { | ||
| return ( | ||
| <section className='flex flex-col gap-6'> | ||
| {Array.from({ length: 5 }).map((_, index) => ( | ||
| <NewsCard | ||
| key={index} | ||
| news={{ | ||
| newsId: index, | ||
| title: "", | ||
| content: "", | ||
| url: "", | ||
| source: "", | ||
| reason: "", | ||
| keywords: [], | ||
| sentiment: "neutral", | ||
| confidence: 0, | ||
| }} | ||
| isLoading | ||
| /> | ||
| ))} | ||
| </section> | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| return ( | ||
| <section className='flex flex-col gap-6'> | ||
| {newsAnalysis.map((news) => ( | ||
| <NewsCard key={news.newsId} news={news} /> | ||
| ))} | ||
| </section> | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
로딩이 끝났지만 newsAnalysis 배열이 비어있는 경우, 사용자에게 아무것도 표시되지 않습니다. "표시할 뉴스가 없습니다."와 같은 메시지를 보여주어 사용자 경험을 개선하는 것이 좋습니다.
if (newsAnalysis.length === 0) {
return <section className='py-10 text-center text-text-muted-dark'>표시할 뉴스가 없습니다.</section>;
}
return (
<section className='flex flex-col gap-6'>
{newsAnalysis.map((news) => (
<NewsCard key={news.newsId} news={news} />
))}
</section>
);
| @@ -0,0 +1,30 @@ | |||
| import { Minus, TrendingDown, TrendingUp } from "lucide-react"; | |||
|
|
|||
| export const getSentimentConfig = (sentiment: string) => { | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements an AI-powered news page that displays cryptocurrency news with sentiment analysis. The implementation fetches news data from an external API, merges it with AI analysis results (sentiment, keywords, confidence scores), and presents it through a paginated interface with comprehensive skeleton loading states.
- Adds a new
/api/newsendpoint that serves paginated news data with AI analysis - Implements a news pagination handler that merges news content with AI sentiment analysis
- Creates a complete news UI with summary statistics, sentiment distribution visualization, and individual news cards
- Adds skeleton loading states throughout for improved UX
Reviewed changes
Copilot reviewed 41 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/api/news/route.ts | New API route handler for paginated news requests with validation |
| src/entities/news/handler/news-pagination.handler.ts | Core handler that merges news and analysis data, implements pagination logic |
| src/entities/news/model/types/news.type.ts | Type definitions for news, analysis, and paginated response structures |
| src/entities/news/model/apis/news-list.api.ts | Client-side API function for fetching paginated news |
| src/entities/news/model/hooks/useGetNewsList.ts | React Query hook for news data fetching |
| src/app/news/page.tsx | Main news page component integrating all features |
| src/features/news/ui/SummaryNewsSection.tsx | Summary section with date, total count, investment index |
| src/features/news/ui/NewsListSection.tsx | News cards list with loading states |
| src/features/news/components/features/summary-news/ | Components for distribution bar, keywords, and header |
| src/features/news/components/features/news-list/NewsCard.tsx | Individual news card with title, content, analysis, metadata |
| src/features/news/components/common/news-card/ | Reusable components for news card sections (title, content, reason, metadata) |
| src/features/news/utils/ | Utility functions for content cleaning, sentiment config, progress width calculation |
| src/shared/components/ui/progress.tsx | New Progress UI component using Radix UI primitives |
| src/shared/components/ui/badge.tsx | New Badge UI component with variants |
| package.json | Added @radix-ui/react-progress and tailwind-scrollbar-hide dependencies |
| src/app/globals.css | Global scrollbar-hide utility applied |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } from "../model"; | ||
|
|
||
| const ITEMS_PER_PAGE = 5; | ||
|
|
||
| export const newsPaginationHandler = async (page: number = 0): Promise<PaginatedNewsResponse> => { | ||
| try { | ||
| // 두 API를 병렬로 호출 |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The newsResponse and analysisResponse do not have a success property. According to the newsAPI() and analysisAPI() implementations in the codebase, they return the parsed JSON data directly (NewsItem[] and AnalysisItem respectively), not wrapped in an APIResponse object. This condition will always evaluate to false, causing the handler to throw an error even when the APIs succeed.
Consider removing this check or updating the API functions to return APIResponse objects:
// Option 1: Remove the check (rely on try-catch for errors)
const newsData = newsResponse as NewsItem[];
const analysisData = analysisResponse as AnalysisItem;
// Option 2: Update newsAPI() and analysisAPI() to return APIResponse<T>| } from "../model"; | |
| const ITEMS_PER_PAGE = 5; | |
| export const newsPaginationHandler = async (page: number = 0): Promise<PaginatedNewsResponse> => { | |
| try { | |
| // 두 API를 병렬로 호출 | |
| const newsData = newsResponse as NewsItem[]; | |
| const analysisData = analysisResponse as AnalysisItem; |
| const config = getSentimentConfig(news.sentiment); | ||
|
|
||
| return ( | ||
| <article className='flex flex-col gap-4 overflow-hidden bg-surface-dark p-6 transition-all hover:border-white/20'> |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The group-hover:line-clamp-none class in ContentBox won't work because the parent article element in NewsCard (line 17) is missing the group class. Add group to the article's className:
<article className='group flex flex-col gap-4 overflow-hidden bg-surface-dark p-6 transition-all hover:border-white/20'>Also note that line 49 of TitleBox.tsx has group-hover:text-blue-400 which has the same issue.
| <article className='flex flex-col gap-4 overflow-hidden bg-surface-dark p-6 transition-all hover:border-white/20'> | |
| <article className='group flex flex-col gap-4 overflow-hidden bg-surface-dark p-6 transition-all hover:border-white/20'> |
| return { | ||
| newsId: news.id, | ||
| title: news.title, | ||
| content: news.content, | ||
| url: news.url, | ||
| source: news.source, | ||
| reason: analysis.reason, | ||
| keywords: analysis.keywords, |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The pagination logic doesn't handle the case where the requested page exceeds available pages. If a user requests page=10 but there are only 4 pages of data, an empty array will be returned without any indication that the page is out of bounds.
Consider adding validation before slicing:
if (page >= totalPages && totalPages > 0) {
throw new Error(`Page ${page} exceeds total pages ${totalPages}`);
}Or return an error response from the API route handler.
| const ITEMS_PER_PAGE = 5; | ||
|
|
||
| export const newsPaginationHandler = async (page: number = 0): Promise<PaginatedNewsResponse> => { | ||
| try { |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These type assertions using as are unnecessary and potentially unsafe. Since the success check above will always fail (see previous comment), this code is unreachable. Once the success check is fixed, you can safely remove these assertions since TypeScript can infer the types from the API function return types.
| try { | |
| const analysisData = analysisResponse.data; |
| function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) { | ||
| return ( | ||
| <ProgressPrimitive.Root | ||
| data-slot='progress' | ||
| className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)} | ||
| {...props} | ||
| > | ||
| <ProgressPrimitive.Indicator | ||
| data-slot='progress-indicator' | ||
| className='h-full w-full flex-1 bg-primary transition-all' | ||
| style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | ||
| /> | ||
| </ProgressPrimitive.Root> | ||
| ); |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Progress component is missing an aria-label or aria-labelledby attribute to provide context for screen readers. While the value prop provides the progress value, users with assistive technology need to know what the progress bar represents.
Consider adding an aria-label prop:
function Progress({ className, value, "aria-label": ariaLabel, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
aria-label={ariaLabel}
{...props}
>
📝 요약 (Summary)
AI가 분석한 암호화폐 뉴스를 제공하는 뉴스 페이지를 구현했습니다. 외부 AI News API와 연동하여 뉴스 데이터와 감성 분석 결과를 결합하고, 페이지네이션 기반 무한 스크롤을 위한 API를 구축했으며, Skeleton 로딩 상태를 적용하여 사용자 경험을 개선했습니다.
✅ 주요 변경 사항 (Key Changes)
GET /api/news?page={n})💻 상세 구현 내용 (Implementation Details)
1. API Route Handler (
/api/news)기능
page번호를 받아 페이지네이션된 뉴스 반환APIResponse<T>형식 사용에러 처리
2. News Pagination Handler
핵심 로직
응답 구조:
4. 타입 정의 및 API 통합
NewsItem: 원본 뉴스 데이터 구조AnalysisItem: AI 분석 결과 구조 (newsId 포함)NewsAnalysisItem: 병합된 최종 뉴스 + 분석 데이터PaginatedNewsResponse: API 응답 타입🚀 트러블 슈팅 (Trouble Shooting)
1. API 데이터 구조 불일치 문제
문제
해결
2. 인덱스 기반 매칭의 한계
문제
해결
구현되지 않은 기능
무한 스크롤
useInfiniteQuery활용 필요📸 스크린샷 (Screenshots)
2025-11-23.4.12.43.mov
#️⃣ 관련 이슈 (Related Issues)