KIM-JINO5 2 meses atrás
pai
commit
67d313fe27
6 arquivos alterados com 268 adições e 40 exclusões
  1. 134 0
      CLAUDE.md
  2. 15 0
      app/api/news/[...path]/route.ts
  3. 1 6
      app/news/page.tsx
  4. 27 5
      app/news/style.scss
  5. 71 29
      app/news/view.tsx
  6. 20 0
      types/response/news.ts

+ 134 - 0
CLAUDE.md

@@ -0,0 +1,134 @@
+# CLAUDE.md
+
+## Project Overview
+
+bitForum - Next.js 15 App Router 기반 커뮤니티 프론트엔드 (https://bitforum.io)
+
+- **Framework**: Next.js 15.3+ (App Router, Server Components)
+- **React**: 19.1+
+- **Language**: TypeScript 5
+- **Backend API**: https://localhost:4000 (ASP.NET Core Minimal API)
+
+## Project Structure
+
+```
+app/                → Next.js App Router (페이지, 레이아웃, API 라우트)
+  ├── (account)/    → 계정 관리 (프로필, 비밀번호 변경 등)
+  ├── (auth)/       → 인증 (로그인, 회원가입, 비밀번호 찾기)
+  ├── (forum)/      → 게시판 (게시글, 댓글, 글쓰기)
+  ├── api/          → Route Handler (백엔드 API 프록시)
+  ├── component/    → 공통 레이아웃 컴포넌트 (Layout, PopupModal 등)
+  ├── support/      → 고객지원 (FAQ, 공지, 이용안내, 제휴문의)
+  ├── news/         → 뉴스
+  └── docs/         → 문서 페이지
+components/ui/      → shadcn/ui 컴포넌트 (dialog, accordion 등)
+contexts/           → React Context Provider (Auth, Member, Config, SignalR)
+hooks/              → 커스텀 훅 (useAuth, useDragScroll 등)
+lib/
+  ├── api/          → 서버 사이드 API 호출 함수 (Server Actions)
+  └── utils/
+      ├── client.ts → 클라이언트 유틸 (fetchApi, cn, formatDate, throwError)
+      └── server.ts → 서버 유틸 (fetchJson, getAccessToken, checkPermission)
+types/              → TypeScript 타입 정의 (response/, request/, forum/, account/)
+constants/          → 상수 정의
+middleware.ts       → 인증 미들웨어 (토큰 검증, 리다이렉트)
+```
+
+## Code Style Rules
+
+- 인덴트: 탭 사용
+- PK/ID 변수명: `memberID`, `postID` (camelCase + 대문자 ID)
+- 클라이언트 컴포넌트: 파일 최상단에 `'use client'` 선언
+- 서버 전용 함수: `'use server'` 선언 (Server Actions, lib/utils/server.ts)
+- 컴포넌트 파일명: PascalCase (`PopupModal.tsx`, `Layout.tsx`)
+- 페이지/라우트: 소문자 kebab-case 디렉토리 (`support/faq/`)
+- 스타일: 각 페이지 디렉토리에 `style.scss` 파일
+- 파일 내용의 마지막 줄 제거 (빈 줄 없이 끝남)
+
+## Architecture Rules
+
+- **서버 컴포넌트가 기본** — `'use client'` 없으면 RSC
+- **클라이언트 상태**: React Context (AuthProvider, MemberProvider, ConfigProvider)
+- **API 프록시 패턴**: 클라이언트 → `app/api/[도메인]/[...path]/route.ts` → 백엔드
+  - 클라이언트에서 직접 백엔드 호출 안 함 (CORS, 쿠키 처리)
+- **서버 데이터 흐름**: Server Component → `fetchJson()` → 백엔드 직접 호출
+- **인증**: JWT Bearer 토큰 (accessToken/refreshToken, httpOnly 쿠키)
+- **실시간**: SignalR WebSocket (암호화폐 시세, 채팅)
+
+## Code Conventions
+
+### 사용하는 패턴
+- `fetchApi<T>()` — 클라이언트에서 Route Handler 호출 (`lib/utils/client.ts`)
+- `fetchJson<T>()` — 서버에서 백엔드 직접 호출 (`lib/utils/server.ts`)
+- `ResultDto<T>` — 모든 API 응답 래퍼 (`{ success, status, message, data, errors }`)
+- `throwError(res)` — 에러 응답 시 예외 발생
+- `cn()` — Tailwind 클래스 병합 (clsx + tailwind-merge)
+- `dangerouslySetInnerHTML` — 서버에서 받은 HTML 콘텐츠 렌더링
+- Route Group — `(auth)`, `(account)`, `(forum)` 으로 관련 페이지 그룹화
+- `notFound()` — 데이터 없을 때 404 처리
+
+### 사용하지 않는 패턴 (제안 금지)
+- Redux, Zustand (Context + 로컬 상태 사용)
+- GraphQL (REST API만 사용)
+- 클라이언트에서 백엔드 직접 호출 (반드시 Route Handler 프록시 경유)
+- CSS-in-JS (Tailwind + SCSS 사용)
+- React Query / SWR (직접 fetch 사용)
+
+## Styling
+
+- **Tailwind CSS** — 기본 스타일링
+- **SCSS** — 컴포넌트별 `style.scss`, 글로벌 `globals.scss`
+- **CSS Modules** — `common.module.scss` (레이아웃 그리드)
+- **shadcn/ui** — new-york 스타일, Radix UI 기반 (`components/ui/`)
+- **CSS Variables** — HSL 형식 테마 변수 (`--background`, `--foreground` 등)
+- **Font Awesome** — 아이콘 (@fortawesome 패키지)
+- **Lucide React** — shadcn/ui 아이콘
+
+## Provider 구조 (Root Layout)
+
+```
+SignalRProvider → AuthProvider → MemberProvider → ConfigProvider → {children}
+```
+
+- `ConfigProvider`: `initialConfig` prop으로 서버에서 설정 전달 (React.cache)
+- `AuthProvider`: 로그인 상태 관리, 토큰 갱신
+- `MemberProvider`: 현재 사용자 정보
+- `SignalRProvider`: WebSocket 연결 (암호화폐, 채팅)
+
+## API Route Handler 패턴
+
+```typescript
+// app/api/{도메인}/[...path]/route.ts
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+    const { path } = await params;
+    const endpoint = `/api/${path.join('/')}`;
+    const res: ResultDto = await fetchJson(endpoint, {
+        method: 'POST',
+        body: await request.arrayBuffer(),
+        headers: { 'Content-Type': request.headers.get('content-type') || '' }
+    });
+    return NextResponse.json(res);
+}
+```
+
+## Build & Run
+
+```bash
+# 개발 서버 (HTTPS, 포트 3000)
+npm run dev
+
+# 프로덕션 빌드
+npm run build
+
+# 프로덕션 실행
+npm run start
+
+# 린트
+npm run lint
+```
+
+## 사용 Domain
+
+- https://bitforum.io → 사용자 프론트엔드
+- https://api.bitforum.io → 백엔드 API
+- https://admin.bitforum.io → 관리자 패널

+ 15 - 0
app/api/news/[...path]/route.ts

@@ -0,0 +1,15 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/news/${path.join('/')}`;
+	const url = new URL(request.url);
+
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, {
+		method: 'GET'
+	});
+
+	return NextResponse.json(res);
+}

+ 1 - 6
app/news/page.tsx

@@ -1,11 +1,6 @@
-'use server';
-
 import View from './view';
 
-import { redirect } from 'next/navigation'
-export default async function News() {
-	// 여기서 뉴스 목록을 호출
- redirect('/');
+export default function News() {
 	return (
 		<View />
 	);

+ 27 - 5
app/news/style.scss

@@ -9,9 +9,17 @@
 		font-weight: 600;
 		font-size: 1.375rem;
 		margin-bottom: 1.25rem;
+
+		&.empty {
+			text-align: center;
+			padding: 3rem 0;
+			color: var(--muted-foreground);
+			font-weight: 400;
+			font-size: 1rem;
+		}
 	}
 
-	> article {
+	> div > article {
 		position: relative;
 		overflow: hidden;
 		padding: 1rem 0;
@@ -25,7 +33,7 @@
 				display: block;
 				padding-right: 1rem;
 				width: clamp(8.75rem, 20%, 12.125rem);
-			
+
 				> img {
 					display: block;
 					width: 100%;
@@ -61,24 +69,38 @@
 					}
 				}
 
-				// 기사 작성일시
+				// 기사 소스명 + 작성일시
 				> span {
 					flex: 0 0 auto;
 					white-space: nowrap;
-					display: block;
+					display: flex;
+					align-items: center;
+					gap: 0.5rem;
 					font-size: 0.938rem;
 					font-weight: 300;
 					margin-top: 0.25rem;
 					margin-bottom: 0.625rem;
+
+					> em {
+						font-style: normal;
+						font-weight: 500;
+						color: var(--primary);
+						font-size: 0.813rem;
+					}
 				}
 			}
 
 			// 기사 내용
-			&:nth-of-type(3) {
+			&.desc {
 				font-size: 0.875rem;
 				font-weight: 300;
 				text-align: justify;
 				overflow-wrap: break-word;
+				display: -webkit-box;
+				-webkit-line-clamp: 2;
+				-webkit-box-orient: vertical;
+				overflow: hidden;
+				text-overflow: ellipsis;
 			}
 		}
 	}

+ 71 - 29
app/news/view.tsx

@@ -2,50 +2,92 @@
 
 import './style.scss';
 import { useState, useEffect } from 'react';
-import Image from 'next/image';
-import Link from 'next/link';
+import { fetchApi, throwError, getDateTime } from '@/lib/utils/client';
+import type { NewsArticlesResponse } from '@/types/response/news';
+import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
 
+function stripHtml(html: string | null): string {
+	if (!html) {
+		return '';
+	}
+
+	const doc = new DOMParser().parseFromString(html, 'text/html');
+	return doc.body.textContent || '';
+}
+
 export default function View() {
 	const [error, setError] = useState<string>('');
 	const [loading, setLoading] = useState<boolean>(true);
-
-	const [total, setTotal] = useState<number>(0);
 	const [page, setPage] = useState<number>(1);
-	// const [logs, setLogs] = useState<LoginLog[]>([]);
-
+	const [data, setData] = useState<NewsArticlesResponse>({
+		total: 0,
+		list: []
+	});
 
 	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
 
-	}, []);
+	useEffect(() => {
+		setLoading(true);
+		fetchApi<NewsArticlesResponse>(`/api/news/articles?page=${page}&perPage=20`).then((res) => {
+			throwError(res);
+			setData(res.data!);
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+	}, [page]);
 
 	return (
-		<>
-			{/* NEWS */}
-			<section id='news'>
-				<p>NEWS</p>
+		<section id='news'>
+			{ loading && <Loading /> }
 
-				<hr />
+			<p>NEWS</p>
 
-				<article>
-					<div>
-						<img src='/resources/no-image.png' alt='news' />
-					</div>
-					<div>
-						<Link href='#'>Bitcoin Dominance At Risk Of Crash To 40%, Why This Is Good For Ethereum, XRP, And Altcoins</Link>
-						<span>2025.04.20 12:52</span>
-					</div>
-					<div>
-						The Bitcoin dominance in the cryptocurrency market is inching dangerously close to a long-term resistance level that has triggered major reversals in the past. This resistance level is highlighted on the weekly BTC.D candlestick timeframe chart.  Each time the dominance taps this descending trendli...
-					</div>
-				</article>
+			<hr />
 
-				<hr />
-				<br />
+			{data.list.length > 0 ? (
+				data.list.map((row) => (
+					<div key={row.id}>
+						<article>
+							<div>
+								<img
+									src={row.imageUrl || '/resources/no-image.png'}
+									alt={row.title}
+									onError={(e) => { (e.target as HTMLImageElement).src = '/resources/no-image.png'; }}
+								/>
+							</div>
+							<div>
+								<a href={row.link || '#'} target='_blank' rel='noopener noreferrer'>
+									{row.title}
+								</a>
+								<span>
+									{/*<em>{row.sourceName || row.feedSourceName}</em>*/}
+									{getDateTime(row.publishedAt)}
+								</span>
+							</div>
+							<div className='desc'>
+								{stripHtml(row.description)}
+							</div>
+						</article>
+						<hr />
+					</div>
+				))
+			) : (
+				!loading && <p className='empty'>뉴스가 없습니다.</p>
+			)}
 
-				<Pagination total={total} page={page} onPageChange={setPage} />
+			<br />
 
-			</section>
-		</>
+			{data.list.length > 0 && (
+				<Pagination total={data.total} page={page} perPage={20} onChange={setPage} />
+			)}
+		</section>
 	);
 }

+ 20 - 0
types/response/news.ts

@@ -0,0 +1,20 @@
+export interface NewsArticlesResponse {
+	total: number;
+	list: NewsArticle[];
+}
+
+export interface NewsArticle {
+	id: number;
+	rssFeedSourceID: number;
+	feedSourceName: string;
+	title: string;
+	link: string | null;
+	author: string | null;
+	description: string | null;
+	imageUrl: string | null;
+	sourceName: string | null;
+	categories: string | null;
+	commentCount: number | null;
+	publishedAt: string | null;
+	createdAt: string;
+}