Feat: 학습용 데모 및 로또/React Query 기능 추가
package.json - 개발/실행 스크립트 및 의존성(react, next, react-query 등) 추가/업데이트 package-lock.json - 종속성 락 파일 갱신 AGENTS.md - 프로젝트 가이드, 코딩 컨벤션 및 AI 멘토 프롬프트 문서 추가 db.json - json-server용 샘플 유저 데이터 추가 next.config.ts - 불필요한 설정 제거 .tsconfig.json - 경로/JSX 설정 및 형식 정리 .src/app/layout.tsx - QueryProvider 적용으로 React Query 전역 Provider 추가 src/app/page.tsx - 새 홈 페이지 컴포넌트 추가 src/app/lotto/page.tsx - 로또 기능 라우트 추가 src/app/api/lotto/route.ts - 외부 로또 API를 프록시하는 Next.js API 라우트 추가 (CORS 우회, 디버그 옵션 포함) src/app/react-query/*, src/app/zustand/* - React Query 및 Zustand 예제 페이지(목록/상세/생성/로딩) 추가 src/app/globals.css - Tailwind 기반 전역 스타일 및 테마 변수 확장 (다크 모드 포함) src/providers/QueryProvider.tsx - React Query Provider 및 Devtools 추가 src/lib/getQueryClient.ts - 서버/클라이언트용 QueryClient 생성 유틸 추가 src/lib/utils.ts - 클래스 병합 유틸 추가 (cn) src/lib/useDialogDragResize.ts - Radix Dialog용 드래그/리사이즈 훅 추가 src/components/ui/*.tsx - shadcn 스타일 UI 컴포넌트들(버튼, 카드, 배지, dialog, table, pagination, skeleton 등) 추가 src/features/react-query-demo/** - React Query 데모: API 래퍼, 훅(useUsers, useUser, useCreateUser), 컴포넌트(UserList, UserDetail, UserDashboard, UserCreateForm), 타입, 스토어 추가 - 서버 프리패치/하이드레이션 패턴 포함 src/features/lotto/** - 로또 기능: API 래퍼, payload 파서(다양한 포맷 정규화), hooks, store(Zustand), 컴포넌트(LottoDashboard, LottoTable, RecommendationsDialog), 추천 알고리즘 및 유틸(번호 생성, mock 데이터) 추가 src/features/zustand/** - Zustand 예제 스토어 및 컴포넌트 추가 .src/components.json - shadcn 구성 파일 추가 .idea/* - IDE 설정(inspection, vcs mapping) 추가 여러 파일(주로 새로 추가된 라이브러리 관련 파일) - 프로젝트 초기 설정과 데모 실행을 위한 구성 및 의존성 추가
This commit is contained in:
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
131
AGENTS.md
Normal file
131
AGENTS.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 📔 Repository & Teaching Mentor Guidelines
|
||||
|
||||
이 파일은 프로젝트의 구조, 코딩 규칙 및 AI 어시스턴트의 **'교육용 멘토'** 역할을 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Project Structure & Module Organization
|
||||
- `src/app`: Next.js App Router 페이지, 레이아웃, 글로벌 스타일 및 API 라우트 (`src/app/api/...`)
|
||||
- `src/features`: 기능 중심 모듈화 (예: `lotto`, `react-query-demo`). 각 기능 내부에 `components`, `hooks`, `api` 등을 응집도 있게 관리합니다.
|
||||
- `src/components/ui`: shadcn/ui 기반의 공통 UI 컴포넌트.
|
||||
- `src/lib`: 프로젝트 전반에서 공유되는 유틸리티 및 설정 (`utils.ts` 등).
|
||||
- `public`: 이미지, 폰트 등 정적 자산.
|
||||
|
||||
## 💻 Development Commands
|
||||
- `npm run dev`: 로컬 개발 서버 시작 (Next.js)
|
||||
- `npm run dev:turbo`: Turbopack 모드로 더 빠른 개발 서버 시작
|
||||
- `npm run build`: 프로덕션 빌드 생성
|
||||
- `npm run start`: 프로덕션 빌드 실행
|
||||
- `npm run server`: `json-server` 실행 (Port: 3002, `db.json` 참조)
|
||||
|
||||
## 🎨 Coding Style & Conventions
|
||||
- **Language**: TypeScript + React
|
||||
- **Indentation**: 2-space (Prettier/ESLint 설정 기반)
|
||||
- **Naming**:
|
||||
- Components: `PascalCase` (예: `LottoTable.tsx`)
|
||||
- Hooks: `camelCase` (예: `useLottoDraws.ts`)
|
||||
- Utils/API: `camelCase`
|
||||
- **Styling**: Tailwind CSS + `cn(...)` 유틸리티 활용. UI 원형(Primitives)은 `src/components/ui`를 우선 사용합니다.
|
||||
|
||||
## 🔒 Security & API
|
||||
- **CORS 방지**: 외부 API 호출 시 직접 브라우저에서 호출하기보다 Next.js API Routes(`src/app/api`)를 프록시로 활용하여 보안과 CORS 문제를 해결합니다.
|
||||
- **Environment**: 민감한 정보는 `.env`로 관리하며, 클라이언트 환경변수는 `NEXT_PUBLIC_` 접두사를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
# 🧑🏫 AI Teaching Mentor Rule Prompt
|
||||
**"단순히 코드만 짜는 로봇이 아닌, 성장을 돕는 친절한 사수가 되어주세요."**
|
||||
|
||||
### 1. 역할 및 기본 페르소나
|
||||
- 당신은 10년 차 이상의 능숙한 시니어 풀스택 개발자이자 실력이 뛰어난 **교육 멘토**입니다.
|
||||
- 답변 시 항상 **친절하고 격려하는 어조**를 유지하며, 모든 설명과 주석은 **한국어(한글)**로 작성합니다.
|
||||
- 사용자가 코드의 구현 결과뿐만 아니라 **"왜(Why)"**와 **"어떻게(Flow)"**를 이해하도록 돕는 것이 당신의 존재 이유입니다.
|
||||
|
||||
### 2. 코드 생성 시 필수 3요소 (AAA)
|
||||
|
||||
#### A. 상세한 해설 (Detailed Explanation)
|
||||
코드를 제안하기 전(또는 후)에 다음 내용을 반드시 명시합니다.
|
||||
1. **무엇인가?**: 구현하려는 기능의 핵심 요약.
|
||||
2. **왜 이렇게 했는가? (Rationale)**: 사용된 기술(React Query, Zustand 등)의 선택 이유와 이점.
|
||||
3. **데이터의 흐름 (Data Flow)**: 컴포넌트 간, 혹은 클라이언트-서버 간 데이터가 어떻게 이동하는지 상세 설명.
|
||||
|
||||
#### B. 교과서 같은 주석 (Educational Comments)
|
||||
작성하는 모든 코드에는 다음 수준의 주석을 포함합니다.
|
||||
1. **JSDoc**: 함수, 인터페이스 상단에 역할, 매개변수, 반환값 명시.
|
||||
2. **논리적 배경**: "단순히 무엇을 한다"가 아니라 "이 조건문이 왜 필요한지"를 주석으로 표현.
|
||||
3. **기술 스택 포인트**:
|
||||
- **React Query**: `queryKey` 설계 이유, 캐싱 전략.
|
||||
- **Zustand**: 상태 원자성(Atomicity) 및 스토어 구조의 장점.
|
||||
4. **의존성 배열 설명**:
|
||||
- `useEffect`/`useMemo`/`useCallback` 등 훅의 의존성 배열이 **"어떤 값이 바뀌면 다시 실행되는지"**를 한 줄로 설명합니다.
|
||||
- 예: "page/totalPages/setPage 중 하나라도 바뀌면 useEffect가 다시 실행됩니다."
|
||||
- 예시 코드:
|
||||
```tsx
|
||||
// page/totalPages/setPage 중 하나라도 바뀌면 이 effect가 다시 실행됩니다.
|
||||
useEffect(() => {
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, totalPages, setPage]);
|
||||
```
|
||||
- useMemo 예시:
|
||||
```tsx
|
||||
// items/page/pageSize 중 하나라도 바뀌면 계산 결과가 다시 만들어집니다.
|
||||
const pagedItems = useMemo(() => {
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
return items.slice(startIndex, startIndex + pageSize);
|
||||
}, [items, page, pageSize]);
|
||||
```
|
||||
- useCallback 예시:
|
||||
```tsx
|
||||
// totalPages/setPage가 바뀌면 새 콜백을 다시 만듭니다.
|
||||
const handlePageChange = useCallback(
|
||||
(nextPage: number) => {
|
||||
const clamped = Math.min(Math.max(nextPage, 1), totalPages);
|
||||
setPage(clamped);
|
||||
},
|
||||
[totalPages, setPage]
|
||||
);
|
||||
```
|
||||
- React Query `queryKey` 예시:
|
||||
```tsx
|
||||
// userId가 바뀌면 React Query가 다른 캐시 키로 인식해 재요청합니다.
|
||||
const userQuery = useQuery({
|
||||
queryKey: ["users", userId],
|
||||
queryFn: () => getUser(userId),
|
||||
});
|
||||
```
|
||||
- React Query `invalidateQueries` 예시:
|
||||
```tsx
|
||||
// 유저 생성 후 목록 캐시를 무효화해 최신 목록을 다시 가져오게 합니다.
|
||||
const queryClient = useQueryClient();
|
||||
await createUser(payload);
|
||||
queryClient.invalidateQueries({ queryKey: ["users", "list"] });
|
||||
```
|
||||
|
||||
#### C. 다음 단계 가이드 (Next Step)
|
||||
- 현재 구현 완료 후, 학습자가 도전해볼 만한 심화 작업이나 관련 기술 개념을 하나 추천합니다.
|
||||
|
||||
### 3. 답변 템플릿
|
||||
```markdown
|
||||
### 📝 오늘 배울 내용: [기능 이름]
|
||||
[기능에 대한 전반적인 설명과 학습 목표]
|
||||
|
||||
#### 💡 기술적 배경과 의도
|
||||
[이 기술을 선택한 이유와 프로젝트 구조상 이점 설명]
|
||||
|
||||
#### 🔍 주요 코드 흐름
|
||||
1. [흐름 1]
|
||||
2. [흐름 2]
|
||||
|
||||
#### 💻 구현된 소스 코드 (상세 주석 포함)
|
||||
[주석이 가득한 코드 블록]
|
||||
|
||||
#### 🚀 더 나아가기
|
||||
[연관 학습 키워드 또는 다음 과제]
|
||||
```
|
||||
|
||||
### 4. 주의 사항 (Constraints)
|
||||
- 주석 없는 코드는 절대 제공하지 않습니다.
|
||||
- 전문 용어는 가급적 풀어 쓰거나, 필요시 (괄호)를 통해 보충 설명을 덧붙입니다.
|
||||
- 사용자가 질문하지 않은 부분이라도, 실수하기 쉬운 포인트가 있다면 미리 조언해주세요.
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
610
db.json
Normal file
610
db.json
Normal file
@@ -0,0 +1,610 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "김철수",
|
||||
"username": "chulsoo_kim",
|
||||
"email": "chulsoo@example.com"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "이영희",
|
||||
"username": "younghee_lee",
|
||||
"email": "younghee@example.com"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "박지성",
|
||||
"username": "jisung_park",
|
||||
"email": "jisung@example.com"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "최유진",
|
||||
"username": "yujin_choi",
|
||||
"email": "yujin@example.com"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "정범석",
|
||||
"username": "bumseok_jung",
|
||||
"email": "bumseok@example.com"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"name": "강민경",
|
||||
"username": "minkyung_kang",
|
||||
"email": "minkyung@example.com"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"name": "조세호",
|
||||
"username": "seho_cho",
|
||||
"email": "seho@example.com"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"name": "윤하늘",
|
||||
"username": "haneul_yoon",
|
||||
"email": "haneul@example.com"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"name": "임재범",
|
||||
"username": "jaebeom_lim",
|
||||
"email": "jaebeom@example.com"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"name": "한미소",
|
||||
"username": "miso_han",
|
||||
"email": "miso@example.com"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"name": "신동엽",
|
||||
"username": "dongyup_shin",
|
||||
"email": "dongyup@example.com"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"name": "안정환",
|
||||
"username": "junghwan_ahn",
|
||||
"email": "junghwan@example.com"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"name": "손흥민",
|
||||
"username": "hm_son",
|
||||
"email": "son@example.com"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"name": "황희찬",
|
||||
"username": "hc_hwang",
|
||||
"email": "hwang@example.com"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"name": "김연아",
|
||||
"username": "yuna_kim",
|
||||
"email": "yuna@example.com"
|
||||
},
|
||||
{
|
||||
"id": "16",
|
||||
"name": "박보검",
|
||||
"username": "bogum_park",
|
||||
"email": "bogum@example.com"
|
||||
},
|
||||
{
|
||||
"id": "17",
|
||||
"name": "아이유",
|
||||
"username": "iu_lee",
|
||||
"email": "iu@example.com"
|
||||
},
|
||||
{
|
||||
"id": "18",
|
||||
"name": "유재석",
|
||||
"username": "js_yoo",
|
||||
"email": "yoo@example.com"
|
||||
},
|
||||
{
|
||||
"id": "19",
|
||||
"name": "강호동",
|
||||
"username": "hd_kang",
|
||||
"email": "kang@example.com"
|
||||
},
|
||||
{
|
||||
"id": "20",
|
||||
"name": "백종원",
|
||||
"username": "jw_baek",
|
||||
"email": "baek@example.com"
|
||||
},
|
||||
{
|
||||
"id": "21",
|
||||
"name": "심형탁",
|
||||
"username": "ht_shim",
|
||||
"email": "shim@example.com"
|
||||
},
|
||||
{
|
||||
"id": "22",
|
||||
"name": "노홍철",
|
||||
"username": "hc_no",
|
||||
"email": "no@example.com"
|
||||
},
|
||||
{
|
||||
"id": "23",
|
||||
"name": "정형돈",
|
||||
"username": "hd_jung",
|
||||
"email": "jung_hd@example.com"
|
||||
},
|
||||
{
|
||||
"id": "24",
|
||||
"name": "하동훈",
|
||||
"username": "haha",
|
||||
"email": "haha@example.com"
|
||||
},
|
||||
{
|
||||
"id": "25",
|
||||
"name": "박명수",
|
||||
"username": "ms_park",
|
||||
"email": "giant_park@example.com"
|
||||
},
|
||||
{
|
||||
"id": "26",
|
||||
"name": "김종국",
|
||||
"username": "jk_kim",
|
||||
"email": "kkook@example.com"
|
||||
},
|
||||
{
|
||||
"id": "27",
|
||||
"name": "송지효",
|
||||
"username": "jh_song",
|
||||
"email": "jihyo@example.com"
|
||||
},
|
||||
{
|
||||
"id": "28",
|
||||
"name": "이광수",
|
||||
"username": "ks_lee",
|
||||
"email": "giraffe@example.com"
|
||||
},
|
||||
{
|
||||
"id": "29",
|
||||
"name": "지석진",
|
||||
"username": "sj_jee",
|
||||
"email": "nose@example.com"
|
||||
},
|
||||
{
|
||||
"id": "30",
|
||||
"name": "양세찬",
|
||||
"username": "sc_yang",
|
||||
"email": "yang@example.com"
|
||||
},
|
||||
{
|
||||
"id": "31",
|
||||
"name": "공명",
|
||||
"username": "myung_gong",
|
||||
"email": "gong@example.com"
|
||||
},
|
||||
{
|
||||
"id": "32",
|
||||
"name": "차은우",
|
||||
"username": "ew_cha",
|
||||
"email": "cha@example.com"
|
||||
},
|
||||
{
|
||||
"id": "33",
|
||||
"name": "한고은",
|
||||
"username": "ge_han",
|
||||
"email": "han_ge@example.com"
|
||||
},
|
||||
{
|
||||
"id": "34",
|
||||
"name": "서강준",
|
||||
"username": "gj_seo",
|
||||
"email": "seo@example.com"
|
||||
},
|
||||
{
|
||||
"id": "35",
|
||||
"name": "이정재",
|
||||
"username": "jj_lee",
|
||||
"email": "lee_jj@example.com"
|
||||
},
|
||||
{
|
||||
"id": "36",
|
||||
"name": "정우성",
|
||||
"username": "ws_jung",
|
||||
"email": "jung_ws@example.com"
|
||||
},
|
||||
{
|
||||
"id": "37",
|
||||
"name": "황정민",
|
||||
"username": "jm_hwang",
|
||||
"email": "hwang_jm@example.com"
|
||||
},
|
||||
{
|
||||
"id": "38",
|
||||
"name": "이병헌",
|
||||
"username": "bh_lee",
|
||||
"email": "lee_bh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "39",
|
||||
"name": "조승우",
|
||||
"username": "sw_cho",
|
||||
"email": "cho_sw@example.com"
|
||||
},
|
||||
{
|
||||
"id": "40",
|
||||
"name": "공효진",
|
||||
"username": "hj_kong",
|
||||
"email": "kong@example.com"
|
||||
},
|
||||
{
|
||||
"id": "41",
|
||||
"name": "김혜수",
|
||||
"username": "hs_kim",
|
||||
"email": "kim_hs@example.com"
|
||||
},
|
||||
{
|
||||
"id": "42",
|
||||
"name": "손예진",
|
||||
"username": "yj_son",
|
||||
"email": "son_yj@example.com"
|
||||
},
|
||||
{
|
||||
"id": "43",
|
||||
"name": "현빈",
|
||||
"username": "hb_kim",
|
||||
"email": "kim_hb@example.com"
|
||||
},
|
||||
{
|
||||
"id": "44",
|
||||
"name": "김다미",
|
||||
"username": "dm_kim",
|
||||
"email": "kim_dm@example.com"
|
||||
},
|
||||
{
|
||||
"id": "45",
|
||||
"name": "최우식",
|
||||
"username": "ws_choi",
|
||||
"email": "choi_ws@example.com"
|
||||
},
|
||||
{
|
||||
"id": "46",
|
||||
"name": "박서준",
|
||||
"username": "sj_park",
|
||||
"email": "park_sj@example.com"
|
||||
},
|
||||
{
|
||||
"id": "47",
|
||||
"name": "김태리",
|
||||
"username": "tr_kim",
|
||||
"email": "kim_tr@example.com"
|
||||
},
|
||||
{
|
||||
"id": "48",
|
||||
"name": "안효섭",
|
||||
"username": "hs_ahn",
|
||||
"email": "ahn_hs@example.com"
|
||||
},
|
||||
{
|
||||
"id": "49",
|
||||
"name": "김세정",
|
||||
"username": "sj_kim",
|
||||
"email": "kim_sj_god@example.com"
|
||||
},
|
||||
{
|
||||
"id": "50",
|
||||
"name": "로몬",
|
||||
"username": "lomon",
|
||||
"email": "lomon@example.com"
|
||||
},
|
||||
{
|
||||
"id": "51",
|
||||
"name": "장나라",
|
||||
"username": "nr_jang",
|
||||
"email": "jang_nr@example.com"
|
||||
},
|
||||
{
|
||||
"id": "52",
|
||||
"name": "이상윤",
|
||||
"username": "sy_lee",
|
||||
"email": "lee_sy@example.com"
|
||||
},
|
||||
{
|
||||
"id": "53",
|
||||
"name": "송중기",
|
||||
"username": "jk_song",
|
||||
"email": "song_jk@example.com"
|
||||
},
|
||||
{
|
||||
"id": "54",
|
||||
"name": "김태희",
|
||||
"username": "th_kim",
|
||||
"email": "kim_th@example.com"
|
||||
},
|
||||
{
|
||||
"id": "55",
|
||||
"name": "비",
|
||||
"username": "rain_jung",
|
||||
"email": "rain@example.com"
|
||||
},
|
||||
{
|
||||
"id": "56",
|
||||
"name": "전지현",
|
||||
"username": "jh_jun",
|
||||
"email": "jun_jh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "57",
|
||||
"name": "강동원",
|
||||
"username": "dw_kang",
|
||||
"email": "kang_dw@example.com"
|
||||
},
|
||||
{
|
||||
"id": "58",
|
||||
"name": "원빈",
|
||||
"username": "wb_kim",
|
||||
"email": "kim_wb@example.com"
|
||||
},
|
||||
{
|
||||
"id": "59",
|
||||
"name": "고수",
|
||||
"username": "gs_go",
|
||||
"email": "go_gs@example.com"
|
||||
},
|
||||
{
|
||||
"id": "60",
|
||||
"name": "신민아",
|
||||
"username": "ma_shin",
|
||||
"email": "shin_ma@example.com"
|
||||
},
|
||||
{
|
||||
"id": "61",
|
||||
"name": "김우빈",
|
||||
"username": "wb_kim_real",
|
||||
"email": "kim_wb_real@example.com"
|
||||
},
|
||||
{
|
||||
"id": "62",
|
||||
"name": "이종석",
|
||||
"username": "js_lee",
|
||||
"email": "lee_js@example.com"
|
||||
},
|
||||
{
|
||||
"id": "63",
|
||||
"name": "수지",
|
||||
"username": "suzy_bae",
|
||||
"email": "suzy@example.com"
|
||||
},
|
||||
{
|
||||
"id": "64",
|
||||
"name": "남주혁",
|
||||
"username": "jh_nam",
|
||||
"email": "nam_jh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "65",
|
||||
"name": "한주희",
|
||||
"username": "jh_han",
|
||||
"email": "han_jh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "66",
|
||||
"name": "김수현",
|
||||
"username": "sh_kim",
|
||||
"email": "kim_sh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "67",
|
||||
"name": "전도연",
|
||||
"username": "dy_jeon",
|
||||
"email": "jeon_dy@example.com"
|
||||
},
|
||||
{
|
||||
"id": "68",
|
||||
"name": "설경구",
|
||||
"username": "gg_sul",
|
||||
"email": "sul_gg@example.com"
|
||||
},
|
||||
{
|
||||
"id": "69",
|
||||
"name": "하정우",
|
||||
"username": "jw_ha",
|
||||
"email": "ha_jw@example.com"
|
||||
},
|
||||
{
|
||||
"id": "70",
|
||||
"name": "이정은",
|
||||
"username": "je_lee",
|
||||
"email": "lee_je@example.com"
|
||||
},
|
||||
{
|
||||
"id": "71",
|
||||
"name": "조우진",
|
||||
"username": "wj_cho",
|
||||
"email": "cho_wj@example.com"
|
||||
},
|
||||
{
|
||||
"id": "72",
|
||||
"name": "허성태",
|
||||
"username": "st_heo",
|
||||
"email": "heo_st@example.com"
|
||||
},
|
||||
{
|
||||
"id": "73",
|
||||
"name": "이무생",
|
||||
"username": "ms_lee",
|
||||
"email": "lee_ms@example.com"
|
||||
},
|
||||
{
|
||||
"id": "74",
|
||||
"name": "박해준",
|
||||
"username": "hj_park",
|
||||
"email": "park_hj@example.com"
|
||||
},
|
||||
{
|
||||
"id": "75",
|
||||
"name": "김희애",
|
||||
"username": "ha_kim",
|
||||
"email": "kim_ha@example.com"
|
||||
},
|
||||
{
|
||||
"id": "76",
|
||||
"name": "한소희",
|
||||
"username": "sh_han",
|
||||
"email": "han_sh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "77",
|
||||
"name": "안보현",
|
||||
"username": "bh_ahn",
|
||||
"email": "ahn_bh@example.com"
|
||||
},
|
||||
{
|
||||
"id": "78",
|
||||
"name": "박소담",
|
||||
"username": "sd_park",
|
||||
"email": "park_sd@example.com"
|
||||
},
|
||||
{
|
||||
"id": "79",
|
||||
"name": "정해인",
|
||||
"username": "hi_jung",
|
||||
"email": "jung_hi@example.com"
|
||||
},
|
||||
{
|
||||
"id": "80",
|
||||
"name": "지수",
|
||||
"username": "js_blackpink",
|
||||
"email": "jisu@example.com"
|
||||
},
|
||||
{
|
||||
"id": "81",
|
||||
"name": "제니",
|
||||
"username": "jn_blackpink",
|
||||
"email": "jenni@example.com"
|
||||
},
|
||||
{
|
||||
"id": "82",
|
||||
"name": "로제",
|
||||
"username": "rs_blackpink",
|
||||
"email": "rose@example.com"
|
||||
},
|
||||
{
|
||||
"id": "83",
|
||||
"name": "리사",
|
||||
"username": "ls_blackpink",
|
||||
"email": "lisa@example.com"
|
||||
},
|
||||
{
|
||||
"id": "84",
|
||||
"name": "카리나",
|
||||
"username": "karina_aespa",
|
||||
"email": "karina@example.com"
|
||||
},
|
||||
{
|
||||
"id": "85",
|
||||
"name": "윈터",
|
||||
"username": "winter_aespa",
|
||||
"email": "winter@example.com"
|
||||
},
|
||||
{
|
||||
"id": "86",
|
||||
"name": "지젤",
|
||||
"username": "giselle_aespa",
|
||||
"email": "giselle@example.com"
|
||||
},
|
||||
{
|
||||
"id": "87",
|
||||
"name": "닝닝",
|
||||
"username": "ning_aespa",
|
||||
"email": "ning@example.com"
|
||||
},
|
||||
{
|
||||
"id": "88",
|
||||
"name": "장원영",
|
||||
"username": "wy_ive",
|
||||
"email": "wonyoung@example.com"
|
||||
},
|
||||
{
|
||||
"id": "89",
|
||||
"name": "안유진",
|
||||
"username": "yj_ive",
|
||||
"email": "yujin@example.com"
|
||||
},
|
||||
{
|
||||
"id": "90",
|
||||
"name": "레이",
|
||||
"username": "rei_ive",
|
||||
"email": "rei@example.com"
|
||||
},
|
||||
{
|
||||
"id": "91",
|
||||
"name": "가을",
|
||||
"username": "gaeul_ive",
|
||||
"email": "gaeul@example.com"
|
||||
},
|
||||
{
|
||||
"id": "92",
|
||||
"name": "리즈",
|
||||
"username": "liz_ive",
|
||||
"email": "liz@example.com"
|
||||
},
|
||||
{
|
||||
"id": "93",
|
||||
"name": "이서",
|
||||
"username": "leeseo_ive",
|
||||
"email": "leeseo@example.com"
|
||||
},
|
||||
{
|
||||
"id": "94",
|
||||
"name": "민지",
|
||||
"username": "mj_newjeans",
|
||||
"email": "minji@example.com"
|
||||
},
|
||||
{
|
||||
"id": "95",
|
||||
"name": "하니",
|
||||
"username": "hn_newjeans",
|
||||
"email": "hanni@example.com"
|
||||
},
|
||||
{
|
||||
"id": "96",
|
||||
"name": "다니엘",
|
||||
"username": "dn_newjeans",
|
||||
"email": "danielle@example.com"
|
||||
},
|
||||
{
|
||||
"id": "97",
|
||||
"name": "해린",
|
||||
"username": "hr_newjeans",
|
||||
"email": "haerin@example.com"
|
||||
},
|
||||
{
|
||||
"id": "98",
|
||||
"name": "혜인",
|
||||
"username": "hi_newjeans",
|
||||
"email": "hyein@example.com"
|
||||
},
|
||||
{
|
||||
"id": "99",
|
||||
"name": "박진영",
|
||||
"username": "jyp",
|
||||
"email": "jyp@example.com"
|
||||
},
|
||||
{
|
||||
"id": "100",
|
||||
"name": "방시혁",
|
||||
"username": "hitman_bang",
|
||||
"email": "bang@example.com"
|
||||
},
|
||||
{
|
||||
"id": "eb12",
|
||||
"name": "이지훈",
|
||||
"email": "d1d2r3@naver.com",
|
||||
"username": "d1d2r3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1994
package-lock.json
generated
1994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -4,24 +4,46 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:turbo": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"server": "json-server --watch db.json --port 3002"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mescius/spread-sheets": "^19.0.1",
|
||||
"@mescius/spread-sheets-react": "^19.0.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "15.5.9",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-config-next": "15.5.9",
|
||||
"json-server": "^1.0.0-beta.3",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
95
src/app/api/lotto/route.ts
Normal file
95
src/app/api/lotto/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
parseLottoPayload,
|
||||
safeParseJson,
|
||||
} from "@/features/lotto/api/lottoParser";
|
||||
|
||||
const BASE_URL = "https://www.dhlottery.co.kr/lt645/selectPstLt645Info.do";
|
||||
const UPSTREAM_HEADERS = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "application/json,text/html;q=0.9,*/*;q=0.8",
|
||||
Referer: "https://www.dhlottery.co.kr/",
|
||||
};
|
||||
|
||||
function buildUpstreamUrl(searchParams: URLSearchParams): string | null {
|
||||
// 클라이언트 쿼리를 외부 로또 API 규격으로 변환합니다.
|
||||
const mode = searchParams.get("mode") ?? "all";
|
||||
|
||||
if (mode === "all") {
|
||||
return `${BASE_URL}?srchLtEpsd=all`;
|
||||
}
|
||||
|
||||
if (mode === "single") {
|
||||
const round = searchParams.get("round");
|
||||
if (!round) {
|
||||
return null;
|
||||
}
|
||||
return `${BASE_URL}?srchStrLtEpsd=${round}`;
|
||||
}
|
||||
|
||||
if (mode === "range") {
|
||||
const start = searchParams.get("start");
|
||||
const end = searchParams.get("end");
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
return `${BASE_URL}?srchStrLtEpsd=${start}&srchEndLtEpsd=${end}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const upstreamUrl = buildUpstreamUrl(searchParams);
|
||||
|
||||
if (!upstreamUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid query parameters." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 브라우저 CORS 제약을 피하기 위해 서버 라우트가 대신 호출합니다.
|
||||
const response = await fetch(upstreamUrl, {
|
||||
cache: "no-store",
|
||||
headers: UPSTREAM_HEADERS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Upstream API error." },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const isDebug = searchParams.get("debug") === "1";
|
||||
|
||||
if (isDebug) {
|
||||
return NextResponse.json({
|
||||
upstreamUrl,
|
||||
contentType: response.headers.get("content-type"),
|
||||
raw: text,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = safeParseJson(text);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unexpected response format." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize the upstream payload into LottoDraw items.
|
||||
const items = parseLottoPayload(payload);
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
totalCount: items.length,
|
||||
source: "dhlottery",
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,149 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: #f8fafc;
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card: #0f172a;
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #0f172a;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #e2e8f0;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #e2e8f0;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #e2e8f0;
|
||||
--destructive: #f87171;
|
||||
--destructive-foreground: #0f172a;
|
||||
--border: #1e293b;
|
||||
--input: #1e293b;
|
||||
--ring: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import QueryProvider from "@/providers/QueryProvider";
|
||||
|
||||
// Next.js App Router의 공통 레이아웃: 폰트, 전역 스타일, Provider를 설정합니다.
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
@@ -27,7 +29,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
16
src/app/lotto/page.tsx
Normal file
16
src/app/lotto/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { LottoDashboard } from "@/features/lotto/components/LottoDashboard";
|
||||
|
||||
/**
|
||||
* 로또 페이지 진입점입니다.
|
||||
* Next.js App Router에서 `page.tsx`는 해당 라우트의 첫 화면을 담당합니다.
|
||||
*/
|
||||
export default function LottoPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-50 p-6">
|
||||
<div className="w-full max-w-6xl">
|
||||
{/* 페이지 컨테이너는 레이아웃 역할만 담당하고, 실제 UI는 LottoDashboard에 위임합니다. */}
|
||||
<LottoDashboard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,42 @@
|
||||
import Image from "next/image";
|
||||
import { ExampleMenu } from "@/features/home/components/ExampleMenu";
|
||||
import { Rocket } from "lucide-react";
|
||||
|
||||
// 홈 화면: 예제 메뉴와 코스 소개를 보여주는 랜딩 페이지입니다.
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-50 font-sans p-6">
|
||||
<main className="w-full max-w-4xl space-y-12">
|
||||
{/* 히어로 섹션 */}
|
||||
<section className="text-center space-y-4">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-4 py-1.5 text-sm font-semibold text-blue-600 ring-1 ring-inset ring-blue-700/10">
|
||||
<Rocket className="h-4 w-4" />
|
||||
<span>React/Next.js Master Course</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-zinc-900 sm:text-6xl">
|
||||
안티그래비티의 <span className="text-blue-600">학습 연구소</span>
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<p className="text-lg text-zinc-600 max-w-2xl mx-auto">
|
||||
중력은 가볍고 즐거운 코드 실습!
|
||||
<br className="hidden sm:block" />
|
||||
아래 메뉴에서 각 기능을 직접 실행해 보세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 예제 목록 섹션 */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 pb-4">
|
||||
<h2 className="text-2xl font-bold text-zinc-900">학습 예제 목록</h2>
|
||||
</div>
|
||||
<ExampleMenu />
|
||||
</section>
|
||||
|
||||
{/* 푸터 */}
|
||||
<footer className="text-center pt-8 border-t border-zinc-200">
|
||||
<p className="text-sm text-zinc-400">
|
||||
© 2025 Anti-Gravity Coding Lab.
|
||||
<br className="sm:hidden" /> 모든 권리는 원저작자에게 있습니다.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
52
src/app/react-query/[id]/loading.tsx
Normal file
52
src/app/react-query/[id]/loading.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { User, Mail, Hash } from "lucide-react"; // 아이콘 위치를 실제 화면과 맞추기 위해 사용합니다.
|
||||
import { Skeleton } from "@/components/ui/skeleton"; // Shadcn UI 스켈레톤 컴포넌트입니다.
|
||||
|
||||
/**
|
||||
* UserDetailLoading
|
||||
* @description 상세 페이지 로딩 중에 보여줄 스켈레톤 UI입니다.
|
||||
*/
|
||||
export default function UserDetailLoading() {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-10 space-y-6">
|
||||
{/* 상단 이동 영역도 자리만 보여줍니다. */}
|
||||
<Skeleton className="h-5 w-24" />
|
||||
|
||||
{/* 상세 카드 스켈레톤: 실제 레이아웃과 비슷하게 구성합니다. */}
|
||||
<div className="overflow-hidden rounded-2xl bg-white shadow-xl border border-zinc-100">
|
||||
{/* 헤더 스켈레톤 */}
|
||||
<div className="bg-linear-to-r from-blue-600 to-indigo-600 p-6 text-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-full bg-white/20 p-3 backdrop-blur-md">
|
||||
<User className="h-8 w-8 text-white/70" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24 bg-white/20" />
|
||||
<Skeleton className="h-6 w-40 bg-white/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 스켈레톤 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 text-zinc-400">
|
||||
<Hash className="h-5 w-5 text-blue-400" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-zinc-400">
|
||||
<Mail className="h-5 w-5 text-blue-400" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-zinc-400">
|
||||
<User className="h-5 w-5 text-blue-400" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 자리 스켈레톤 */}
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<Skeleton className="h-11 w-full rounded-xl bg-zinc-900/80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/app/react-query/[id]/page.tsx
Normal file
45
src/app/react-query/[id]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { UserDetail } from "@/features/react-query-demo/components/UserDetail";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@/lib/getQueryClient";
|
||||
import { getUser } from "@/features/react-query-demo/api/userApi";
|
||||
import { userKeys } from "@/features/react-query-demo/api/queryKeys";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저 상세 정보를 보여주는 동적 라우트 페이지입니다.
|
||||
* [고급 패턴] 특정 유저 데이터를 서버에서 프리패칭(Prefetching)합니다.
|
||||
*/
|
||||
export default async function UserDetailPage({ params }: PageProps) {
|
||||
// 1. URL 파라미터에서 ID를 가져옵니다.
|
||||
const { id } = await params;
|
||||
const userId = Number(id);
|
||||
|
||||
// 2. 서버 전역 QueryClient를 가져와 프리패칭을 수행합니다.
|
||||
const queryClient = getQueryClient();
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: userKeys.detail(userId),
|
||||
queryFn: () => getUser(userId),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-10 space-y-6">
|
||||
<Link
|
||||
href="/react-query"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold text-zinc-500 hover:text-zinc-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로 돌아가기
|
||||
</Link>
|
||||
|
||||
{/* 3. 서버에서 가져온 데이터를 하이드레이션하여 전달합니다. */}
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<UserDetail userId={userId} />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/react-query/loading.tsx
Normal file
47
src/app/react-query/loading.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"; // Shadcn UI 스켈레톤 컴포넌트입니다.
|
||||
|
||||
/**
|
||||
* ReactQueryListLoading
|
||||
* @description 목록 페이지 전환 중 보여줄 스켈레톤 UI입니다.
|
||||
*/
|
||||
export default function ReactQueryListLoading() {
|
||||
return (
|
||||
<div className="space-y-10 pb-20 max-w-5xl mx-auto">
|
||||
{/* 헤더 영역 스켈레톤 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
|
||||
<div className="text-center sm:text-left">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="mt-3 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-40 rounded-xl" />
|
||||
</div>
|
||||
|
||||
{/* 목록 제목 영역 스켈레톤 */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-5 w-28 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 카드 스켈레톤 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={`list-skeleton-${index}`} // 스켈레톤 리스트를 식별하기 위한 키입니다.
|
||||
className="flex flex-col rounded-xl border border-zinc-200 p-5 bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/app/react-query/new/page.tsx
Normal file
22
src/app/react-query/new/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { UserCreateForm } from "@/features/react-query-demo/components/UserCreateForm";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 신규 유저를 등록하는 페이지입니다.
|
||||
*/
|
||||
export default function UserNewPage() {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-10 space-y-6">
|
||||
<Link
|
||||
href="/react-query"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold text-zinc-500 hover:text-zinc-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로 돌아가기
|
||||
</Link>
|
||||
|
||||
<UserCreateForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/app/react-query/page.tsx
Normal file
13
src/app/react-query/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { UserDashboard } from "@/features/react-query-demo/components/UserDashboard";
|
||||
|
||||
/**
|
||||
* React Query 데모 목록 페이지입니다.
|
||||
* 기본 경로(`/react-query`)에서 유저 대시보드를 보여줍니다.
|
||||
*/
|
||||
export default function ReactQueryPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 px-6 py-12">
|
||||
<UserDashboard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/zustand/page.tsx
Normal file
32
src/app/zustand/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ZustandExample } from "@/features/zustand/components/ZustandExample";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ZustandPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50/50 p-6 md:p-12">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 transition-all hover:ring-zinc-300"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-zinc-500" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900">
|
||||
Zustand 예제
|
||||
</h1>
|
||||
<p className="text-zinc-500">
|
||||
쉽고 가벼운 전역 상태 관리 라이브러리 Zustand를 체험해봅니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<ZustandExample />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
82
src/components/ui/card.tsx
Normal file
82
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
};
|
||||
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
111
src/components/ui/dialog.tsx
Normal file
111
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-2xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
117
src/components/ui/pagination.tsx
Normal file
117
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
size?: "default" | "icon";
|
||||
} & React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
16
src/components/ui/skeleton.tsx
Normal file
16
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils"; // 클래스 병합 유틸리티입니다.
|
||||
|
||||
/**
|
||||
* Skeleton
|
||||
* @description 로딩 중인 자리 표시를 위한 Shadcn UI 스타일 컴포넌트입니다.
|
||||
* @param className - 크기/모양을 지정하는 Tailwind 클래스입니다.
|
||||
*/
|
||||
function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-zinc-100", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
78
src/features/home/components/ExampleMenu.tsx
Normal file
78
src/features/home/components/ExampleMenu.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Database, Rocket, Sparkles, Layers } from "lucide-react";
|
||||
|
||||
// 홈 화면에서 예제 카드 목록을 보여주는 메뉴 컴포넌트입니다.
|
||||
interface ExampleItem {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const examples: ExampleItem[] = [
|
||||
{
|
||||
title: "TanStack Query",
|
||||
description:
|
||||
"서버 상태를 효율적으로 관리하고 캐싱/로딩 흐름을 학습합니다.",
|
||||
href: "/react-query",
|
||||
icon: <Database className="h-6 w-6" />,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
title: "로또 번호 생성기",
|
||||
description: "랜덤 추천과 번호 조회 테이블을 한눈에 확인합니다.",
|
||||
href: "/lotto",
|
||||
icon: <Sparkles className="h-6 w-6" />,
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
title: "Zustand 상태 관리",
|
||||
description: "Zustand를 이용한 간단하고 직관적인 전역 상태 관리를 경험해봅니다.",
|
||||
href: "/zustand",
|
||||
icon: <Layers className="h-6 w-6" />,
|
||||
color: "bg-amber-500",
|
||||
},
|
||||
// 필요 시 이곳에 예제를 추가하세요.
|
||||
];
|
||||
|
||||
export function ExampleMenu() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{examples.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="group relative overflow-hidden rounded-2xl border border-zinc-200 bg-white p-6 transition-all hover:shadow-lg hover:border-transparent active:scale-95"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className={`rounded-xl ${item.color} p-3 text-white shadow-lg shadow-blue-100`}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-zinc-300 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-bold text-zinc-900">{item.title}</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 h-1 w-0 bg-blue-600 transition-all group-hover:w-full" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50/50 p-6 flex flex-col items-center justify-center text-center">
|
||||
<div className="rounded-xl bg-zinc-200 p-3 text-zinc-400">
|
||||
<Rocket className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-sm font-medium text-zinc-400">
|
||||
다음 예제 준비중
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/features/lotto/api/lottoApi.ts
Normal file
80
src/features/lotto/api/lottoApi.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { LottoDraw } from "../types/lotto";
|
||||
|
||||
/**
|
||||
* 로또 API 응답의 공통 형태입니다.
|
||||
* React Query에서 쓰기 좋은 형태로 정규화된 데이터를 담습니다.
|
||||
*/
|
||||
export interface LottoApiResponse {
|
||||
/** 정규화된 로또 회차 목록입니다. */
|
||||
items: LottoDraw[];
|
||||
/** 전체 회차 수(페이지네이션 계산에 활용)입니다. */
|
||||
totalCount: number;
|
||||
/** 데이터 소스(외부 API 또는 mock 등) 표시용 값입니다. */
|
||||
source: string;
|
||||
}
|
||||
|
||||
// Next.js API Route를 프록시로 삼아 브라우저 CORS를 피합니다.
|
||||
const API_BASE = "/api/lotto";
|
||||
|
||||
/**
|
||||
* 로또 API 호출을 감싸는 공통 래퍼입니다.
|
||||
* - 브라우저 CORS를 피하려고 Next.js route handler를 통해 호출합니다.
|
||||
* - 에러 메시지를 정리해서 UI에서 일관되게 보여줍니다.
|
||||
*/
|
||||
async function fetchLotto(url: string): Promise<LottoApiResponse> {
|
||||
// fetch는 기본적으로 캐시 정책을 따르므로, 필요하면 추후 옵션으로 제어할 수 있습니다.
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
// 서버에서 내려준 에러 메시지가 있으면 그대로 노출합니다.
|
||||
let message = "로또 데이터를 불러오지 못했습니다.";
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string };
|
||||
if (body?.error) {
|
||||
message = body.error;
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시하고 기본 메시지를 유지합니다.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 회차 목록을 조회합니다.
|
||||
* @returns {Promise<LottoApiResponse>} 전체 회차 응답입니다.
|
||||
*/
|
||||
export function getAllLottoDraws(): Promise<LottoApiResponse> {
|
||||
// mode=all -> route.ts에서 srchLtEpsd=all로 변환됩니다.
|
||||
return fetchLotto(`${API_BASE}?mode=all`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 회차 1건을 조회합니다.
|
||||
* @param {number} round - 회차 번호입니다.
|
||||
* @returns {Promise<LottoDraw | null>} 해당 회차가 없으면 null입니다.
|
||||
*/
|
||||
export async function getLottoDraw(round: number): Promise<LottoDraw | null> {
|
||||
// 단일 회차는 리스트가 1개일 수 있으므로 첫 번째 항목만 반환합니다.
|
||||
const data = await fetchLotto(`${API_BASE}?mode=single&round=${round}`);
|
||||
return data.items[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회차 범위로 목록을 조회합니다.
|
||||
* @param {number} start - 시작 회차입니다.
|
||||
* @param {number} end - 종료 회차입니다.
|
||||
* @returns {Promise<LottoDraw[]>} 범위에 해당하는 회차 목록입니다.
|
||||
*/
|
||||
export async function getLottoRange(
|
||||
start: number,
|
||||
end: number
|
||||
): Promise<LottoDraw[]> {
|
||||
// 범위 조회는 서버에서 리스트로 반환되므로 그대로 items를 전달합니다.
|
||||
const data = await fetchLotto(
|
||||
`${API_BASE}?mode=range&start=${start}&end=${end}`
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
274
src/features/lotto/api/lottoParser.ts
Normal file
274
src/features/lotto/api/lottoParser.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { LottoDraw } from "../types/lotto";
|
||||
|
||||
// 외부 응답 포맷이 제각각이라 UI가 쓰는 단일 LottoDraw 형태로 정규화합니다.
|
||||
// NUMBER_KEY_GROUPS는 "번호 6개"가 어떤 필드명으로 오는지 모아둔 테이블입니다.
|
||||
const NUMBER_KEY_GROUPS = [
|
||||
["drwtNo1", "drwtNo2", "drwtNo3", "drwtNo4", "drwtNo5", "drwtNo6"],
|
||||
["winNo1", "winNo2", "winNo3", "winNo4", "winNo5", "winNo6"],
|
||||
["no1", "no2", "no3", "no4", "no5", "no6"],
|
||||
["tm1WnNo", "tm2WnNo", "tm3WnNo", "tm4WnNo", "tm5WnNo", "tm6WnNo"],
|
||||
] as const;
|
||||
|
||||
// 회차 식별자 키 후보 목록입니다.
|
||||
const ID_KEYS = ["drwNo", "drawNo", "round", "ltEpsd", "epsd", "id"] as const;
|
||||
// 추첨일/등록일 등 날짜 키 후보 목록입니다.
|
||||
const DATE_KEYS = [
|
||||
"drwNoDate",
|
||||
"drawDate",
|
||||
"drwDate",
|
||||
"winDate",
|
||||
"date",
|
||||
"createdAt",
|
||||
"regDate",
|
||||
"ltRflYmd",
|
||||
] as const;
|
||||
|
||||
// 보너스 번호 키 후보 목록입니다.
|
||||
const BONUS_KEYS = ["bnusNo", "bonusNo", "bonusNumber", "bnsWnNo"] as const;
|
||||
|
||||
/**
|
||||
* 숫자/문자열을 안전하게 숫자로 변환합니다.
|
||||
* @param {unknown} value - 변환할 값입니다.
|
||||
* @returns {number | null} 변환 가능한 숫자면 number, 아니면 null입니다.
|
||||
*/
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 키 목록에서 첫 번째 유효 숫자를 찾습니다.
|
||||
* @param {Record<string, unknown>} raw - 원본 객체입니다.
|
||||
* @param {readonly string[]} keys - 탐색할 키 목록입니다.
|
||||
* @returns {number | null} 찾은 숫자 또는 null입니다.
|
||||
*/
|
||||
function getFirstNumber(
|
||||
raw: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): number | null {
|
||||
for (const key of keys) {
|
||||
const value = toNumber(raw[key]);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 키 목록에서 첫 번째 유효 문자열을 찾습니다.
|
||||
* @param {Record<string, unknown>} raw - 원본 객체입니다.
|
||||
* @param {readonly string[]} keys - 탐색할 키 목록입니다.
|
||||
* @returns {string} 찾은 문자열 또는 빈 문자열입니다.
|
||||
*/
|
||||
function getFirstString(
|
||||
raw: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): string {
|
||||
for (const key of keys) {
|
||||
const value = raw[key];
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 문자열 포맷을 통일합니다.
|
||||
* @param {string} value - 원본 날짜 문자열입니다.
|
||||
* @returns {string} YYYY-MM-DD 형태로 정규화된 문자열입니다.
|
||||
*/
|
||||
function normalizeDate(value: string): string {
|
||||
// YYYYMMDD 형식이면 YYYY-MM-DD로 변환해 표시를 통일합니다.
|
||||
const trimmed = value.trim();
|
||||
if (/^\d{8}$/.test(trimmed)) {
|
||||
return `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다양한 형태의 번호 정보를 추출해 6개 번호 배열로 만듭니다.
|
||||
* @param {Record<string, unknown>} raw - 원본 객체입니다.
|
||||
* @returns {number[] | null} 6개 번호 배열 또는 null입니다.
|
||||
*/
|
||||
function extractNumbers(raw: Record<string, unknown>): number[] | null {
|
||||
// 배열 기반 포맷: numbers가 이미 배열로 오는 경우.
|
||||
const arrayCandidate = raw.numbers ?? raw.nums ?? raw.winNumbers;
|
||||
if (Array.isArray(arrayCandidate)) {
|
||||
const nums = arrayCandidate
|
||||
.map((value) => toNumber(value))
|
||||
.filter((value): value is number => value !== null);
|
||||
if (nums.length >= 6) {
|
||||
// 번호는 오름차순으로 정렬해 UI 표시를 통일합니다.
|
||||
return nums.slice(0, 6).sort((a, b) => a - b);
|
||||
}
|
||||
}
|
||||
|
||||
// 개별 필드 포맷: tm1WnNo~tm6WnNo, drwtNo1~drwtNo6 등.
|
||||
for (const keyGroup of NUMBER_KEY_GROUPS) {
|
||||
const nums = keyGroup.map((key) => toNumber(raw[key]));
|
||||
if (nums.every((value): value is number => value !== null)) {
|
||||
// 개별 필드 포맷도 동일하게 정렬합니다.
|
||||
return (nums as number[]).sort((a, b) => a - b);
|
||||
}
|
||||
}
|
||||
|
||||
// 문자열 포맷: "1, 2, 3, 4, 5, 6" 같은 형태.
|
||||
if (typeof raw.winNums === "string") {
|
||||
const nums = raw.winNums
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => toNumber(value))
|
||||
.filter((value): value is number => value !== null);
|
||||
if (nums.length >= 6) {
|
||||
return nums.slice(0, 6).sort((a, b) => a - b);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 회차 객체를 LottoDraw로 정규화합니다.
|
||||
* @param {Record<string, unknown>} raw - 원본 객체입니다.
|
||||
* @returns {LottoDraw | null} 유효한 데이터면 LottoDraw, 아니면 null입니다.
|
||||
*/
|
||||
function normalizeDraw(raw: Record<string, unknown>): LottoDraw | null {
|
||||
// 회차(id)와 번호 6개가 없으면 유효한 데이터로 보지 않습니다.
|
||||
const id = getFirstNumber(raw, ID_KEYS);
|
||||
const numbers = extractNumbers(raw);
|
||||
|
||||
if (id === null || !numbers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 날짜/보너스는 optional이므로 없어도 통과합니다.
|
||||
const createdAt = getFirstString(raw, DATE_KEYS);
|
||||
const bonusNumber = getFirstNumber(raw, BONUS_KEYS);
|
||||
|
||||
return {
|
||||
id,
|
||||
numbers,
|
||||
bonusNumber: bonusNumber ?? undefined,
|
||||
createdAt: normalizeDate(createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 객체에서 리스트 배열을 안전하게 찾아냅니다.
|
||||
* @param {Record<string, unknown>} raw - 원본 객체입니다.
|
||||
* @returns {unknown[] | null} 리스트 배열 또는 null입니다.
|
||||
*/
|
||||
function extractList(raw: Record<string, unknown>): unknown[] | null {
|
||||
// 다양한 API에서 쓰는 리스트 컨테이너 키를 순차적으로 탐색합니다.
|
||||
const listCandidates = [
|
||||
raw.items,
|
||||
raw.list,
|
||||
raw.data,
|
||||
raw.rows,
|
||||
raw.result,
|
||||
raw.lotto,
|
||||
raw.lottoList,
|
||||
];
|
||||
|
||||
for (const candidate of listCandidates) {
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// dhlottery 포맷: { data: { list: [...] } }
|
||||
if (raw.data && typeof raw.data === "object") {
|
||||
const nested = raw.data as Record<string, unknown>;
|
||||
if (Array.isArray(nested.list)) {
|
||||
return nested.list;
|
||||
}
|
||||
}
|
||||
|
||||
// 일부 API는 { result: { list: [...] } } 형태를 사용합니다.
|
||||
if (raw.result && typeof raw.result === "object") {
|
||||
const nested = raw.result as Record<string, unknown>;
|
||||
if (Array.isArray(nested.list)) {
|
||||
return nested.list;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다양한 로또 API 응답을 LottoDraw 배열로 정규화합니다.
|
||||
* @param {unknown} payload - 원본 응답입니다.
|
||||
* @returns {LottoDraw[]} 정규화된 회차 목록입니다.
|
||||
*/
|
||||
export function parseLottoPayload(payload: unknown): LottoDraw[] {
|
||||
// payload가 배열이면 항목마다 normalize 합니다.
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((item) =>
|
||||
typeof item === "object" && item !== null
|
||||
? normalizeDraw(item as Record<string, unknown>)
|
||||
: null
|
||||
)
|
||||
.filter((item): item is LottoDraw => item !== null)
|
||||
// 최신 회차가 위로 오도록 id 내림차순 정렬합니다.
|
||||
.sort((a, b) => b.id - a.id);
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object") {
|
||||
const raw = payload as Record<string, unknown>;
|
||||
const list = extractList(raw);
|
||||
|
||||
// 내부에 리스트가 있으면 그 리스트를 정규화합니다.
|
||||
if (list) {
|
||||
return list
|
||||
.map((item) =>
|
||||
typeof item === "object" && item !== null
|
||||
? normalizeDraw(item as Record<string, unknown>)
|
||||
: null
|
||||
)
|
||||
.filter((item): item is LottoDraw => item !== null)
|
||||
// 리스트가 중첩되어 있어도 정렬 규칙은 동일하게 유지합니다.
|
||||
.sort((a, b) => b.id - a.id);
|
||||
}
|
||||
|
||||
// 단일 객체 payload는 1개짜리 리스트로 변환합니다.
|
||||
const single = normalizeDraw(raw);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 파싱을 안전하게 시도합니다.
|
||||
* @param {string} text - JSON 문자열입니다.
|
||||
* @returns {unknown | null} 파싱 성공 시 결과, 실패 시 null입니다.
|
||||
*/
|
||||
export function safeParseJson(text: string): unknown | null {
|
||||
// JSON.parse 실패 시 문자열 안의 JSON 덩어리를 다시 시도합니다.
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const match = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(match[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/features/lotto/api/queryKeys.ts
Normal file
24
src/features/lotto/api/queryKeys.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* React Query queryKey를 중앙에서 관리합니다.
|
||||
* 키를 일관되게 유지하면 캐시 공유와 무효화가 쉬워집니다.
|
||||
*/
|
||||
export const lottoKeys = {
|
||||
// 모든 로또 관련 캐시의 최상위 네임스페이스입니다.
|
||||
all: ["lotto"] as const,
|
||||
/**
|
||||
* 전체 목록 조회용 키입니다.
|
||||
* 같은 리스트는 같은 캐시를 공유하도록 고정합니다.
|
||||
*/
|
||||
lists: () => [...lottoKeys.all, "list"] as const,
|
||||
/**
|
||||
* 단일 회차 조회용 키입니다.
|
||||
* round가 바뀌면 다른 캐시로 분리됩니다.
|
||||
*/
|
||||
single: (round: number) => [...lottoKeys.all, "single", round] as const,
|
||||
/**
|
||||
* 범위 조회용 키입니다.
|
||||
* start/end가 바뀌면 다른 캐시로 분리됩니다.
|
||||
*/
|
||||
range: (start: number, end: number) =>
|
||||
[...lottoKeys.all, "range", start, end] as const,
|
||||
};
|
||||
130
src/features/lotto/components/LottoDashboard.tsx
Normal file
130
src/features/lotto/components/LottoDashboard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useLottoDraws } from "../hooks/useLottoDraws";
|
||||
import { useLottoStore } from "../store/useLottoStore";
|
||||
import { generateLottoNumbers } from "../utils/generateLottoNumbers";
|
||||
import { LottoRecommendationsDialog } from "./LottoRecommendationsDialog";
|
||||
import { LottoTable } from "./LottoTable";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const RECOMMENDATION_COUNT = 10;
|
||||
|
||||
/**
|
||||
* 로또 기능의 메인 대시보드입니다.
|
||||
* 서버 데이터(React Query)와 UI 상태(Zustand)를 연결해 화면을 구성합니다.
|
||||
*
|
||||
* 데이터 흐름 요약:
|
||||
* 1) Zustand: page/dialogOpen/recommendations/condition 상태를 단일 출처로 관리
|
||||
* 2) React Query: page/pageSize를 기준으로 전체 회차 데이터를 가져옴
|
||||
* 3) UI: LottoTable(목록) + LottoRecommendationsDialog(추천)로 상태 전달
|
||||
*/
|
||||
export function LottoDashboard() {
|
||||
// Zustand store로 UI 상태를 중앙에서 관리해 컴포넌트 간 공유를 쉽게 합니다.
|
||||
const page = useLottoStore((state) => state.page);
|
||||
const setPage = useLottoStore((state) => state.setPage);
|
||||
const dialogOpen = useLottoStore((state) => state.dialogOpen);
|
||||
const setDialogOpen = useLottoStore((state) => state.setDialogOpen);
|
||||
const recommendations = useLottoStore((state) => state.recommendations);
|
||||
const setRecommendations = useLottoStore(
|
||||
(state) => state.setRecommendations
|
||||
);
|
||||
|
||||
// 조건 플래그 상태
|
||||
const condition1 = useLottoStore((state) => state.condition1);
|
||||
const condition2 = useLottoStore((state) => state.condition2);
|
||||
const condition3 = useLottoStore((state) => state.condition3);
|
||||
|
||||
// React Query 훅으로 서버 데이터를 가져오고, 페이지네이션 정보를 계산합니다.
|
||||
// 이 시점에서 items/totalCount/totalPages가 UI로 흘러갑니다.
|
||||
const {
|
||||
items,
|
||||
recentDraws,
|
||||
totalCount,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage,
|
||||
} = useLottoDraws({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
// 번호 생성 로직은 utils 함수로 분리해 알고리즘을 쉽게 교체합니다.
|
||||
const createRecommendations = () =>
|
||||
Array.from({ length: RECOMMENDATION_COUNT }, () =>
|
||||
generateLottoNumbers({
|
||||
recentDraws,
|
||||
useCondition1: condition1,
|
||||
useCondition2: condition2,
|
||||
useCondition3: condition3,
|
||||
})
|
||||
);
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
// 다이얼로그 열기 전에 추천 번호를 준비해 첫 화면을 즉시 채웁니다.
|
||||
setRecommendations(createRecommendations());
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
// 추천 번호만 교체하여 기존 다이얼로그 상태는 유지합니다.
|
||||
setRecommendations(createRecommendations());
|
||||
};
|
||||
|
||||
|
||||
// 페이지 이동은 범위를 벗어나지 않도록 clamp 처리합니다.
|
||||
const handlePageChange = (nextPage: number) => {
|
||||
const clamped = Math.min(Math.max(nextPage, 1), totalPages);
|
||||
setPage(clamped);
|
||||
};
|
||||
|
||||
// 데이터가 줄어든 경우 현재 페이지를 안전 범위로 맞춥니다.
|
||||
useEffect(() => {
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, totalPages, setPage]);
|
||||
|
||||
return (
|
||||
<section className="space-y-8 pb-16">
|
||||
{/* 1) 헤더: 현재 기능의 목적과 화면 설명 */}
|
||||
<div className="space-y-3 text-center sm:text-left">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mx-auto w-fit bg-emerald-50 text-emerald-700 sm:mx-0"
|
||||
>
|
||||
Lotto Lab
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-foreground sm:text-4xl">
|
||||
로또 번호 생성기
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
로또 번호를 모아보고, 필요할 때 추천 번호를 바로 생성하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2) 목록 영역: React Query 결과 + 페이지네이션 상태를 전달 */}
|
||||
<LottoTable
|
||||
draws={items}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onOpenGenerator={handleOpenDialog}
|
||||
status={isLoading ? "loading" : isError ? "error" : "ready"}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
|
||||
{/* 3) 다이얼로그: Zustand 상태를 controlled 방식으로 연결 */}
|
||||
{/* shadcn/ui Dialog는 controlled(open/onOpenChange) 방식으로 상태를 연결합니다. */}
|
||||
<LottoRecommendationsDialog
|
||||
open={dialogOpen}
|
||||
recommendations={recommendations}
|
||||
onOpenChange={setDialogOpen}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
182
src/features/lotto/components/LottoRecommendationsDialog.tsx
Normal file
182
src/features/lotto/components/LottoRecommendationsDialog.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useDialogDragResize } from "@/lib/useDialogDragResize";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useLottoStore } from "../store/useLottoStore";
|
||||
|
||||
/**
|
||||
* 추천 번호 다이얼로그에 전달되는 Props입니다.
|
||||
* @property {boolean} open - 다이얼로그 열림 상태입니다.
|
||||
* @property {number[][]} recommendations - 추천 번호 목록입니다.
|
||||
* @property {(open: boolean) => void} onOpenChange - 열림 상태 변경 콜백입니다.
|
||||
* @property {() => void} onRegenerate - 추천 번호 재생성 콜백입니다.
|
||||
*/
|
||||
interface LottoRecommendationsDialogProps {
|
||||
open: boolean;
|
||||
recommendations: number[][];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 번호 목록을 모달로 보여주는 컴포넌트입니다.
|
||||
* shadcn/ui Dialog를 사용해 접근성과 상태 제어를 함께 가져갑니다.
|
||||
*/
|
||||
export function LottoRecommendationsDialog({
|
||||
open,
|
||||
recommendations,
|
||||
onOpenChange,
|
||||
onRegenerate,
|
||||
}: LottoRecommendationsDialogProps) {
|
||||
// 공용 훅으로 드래그/리사이즈 로직을 연결합니다.
|
||||
const {
|
||||
contentStyle,
|
||||
contentClassName,
|
||||
dragHandleProps,
|
||||
dragHandleClassName,
|
||||
resizeHandleProps,
|
||||
resizeHandleClassName,
|
||||
isResizable,
|
||||
} = useDialogDragResize({
|
||||
open,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
});
|
||||
|
||||
// Zustand store에서 조건 플래그 상태를 가져옵니다.
|
||||
const condition1 = useLottoStore((state) => state.condition1);
|
||||
const condition2 = useLottoStore((state) => state.condition2);
|
||||
const condition3 = useLottoStore((state) => state.condition3);
|
||||
const setCondition1 = useLottoStore((state) => state.setCondition1);
|
||||
const setCondition2 = useLottoStore((state) => state.setCondition2);
|
||||
const setCondition3 = useLottoStore((state) => state.setCondition3);
|
||||
|
||||
return (
|
||||
// shadcn/ui Dialog는 Radix 기반이므로 open/onOpenChange로 상태를 제어합니다.
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn("flex flex-col gap-0 p-0 overflow-hidden", contentClassName)}
|
||||
style={contentStyle}
|
||||
>
|
||||
<DialogHeader
|
||||
className={cn("px-6 py-4 border-b shrink-0", dragHandleClassName)}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="w-fit gap-2 bg-emerald-50 text-emerald-700"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
10줄 추천
|
||||
</Badge>
|
||||
<DialogTitle className="text-2xl">로또 번호 추천</DialogTitle>
|
||||
<DialogDescription>
|
||||
랜덤으로 생성된 10줄 번호를 확인하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 조건 토글 체크박스 영역 */}
|
||||
<div className="flex flex-wrap items-center gap-4 px-6 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="condition1"
|
||||
checked={condition1}
|
||||
onCheckedChange={(checked) => setCondition1(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="condition1" className="text-sm cursor-pointer">
|
||||
조건 1: 최근 번호 제외
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="condition2"
|
||||
checked={condition2}
|
||||
onCheckedChange={(checked) => setCondition2(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="condition2" className="text-sm cursor-pointer">
|
||||
조건 2: 보너스 번호 제외
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="condition3"
|
||||
checked={condition3}
|
||||
onCheckedChange={(checked) => setCondition3(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="condition3" className="text-sm cursor-pointer">
|
||||
조건 3: Hot & Due 적용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-3">
|
||||
{recommendations.map((numbers, index) => (
|
||||
<div
|
||||
key={`${numbers.join("-")}-${index}`}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3"
|
||||
>
|
||||
<Badge variant="outline" className="text-xs font-semibold">
|
||||
추천 {index + 1}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{numbers.map((value) => (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="outline"
|
||||
className="border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0 gap-2 sm:gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
다시 생성
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button">닫기</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
|
||||
{/* 모서리 핸들로 크기 조절을 제공합니다. */}
|
||||
{isResizable && (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-label="다이얼로그 크기 조절"
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 h-4 w-4 rounded-sm border border-emerald-200 bg-emerald-50",
|
||||
resizeHandleClassName
|
||||
)}
|
||||
{...resizeHandleProps}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
285
src/features/lotto/components/LottoTable.tsx
Normal file
285
src/features/lotto/components/LottoTable.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { LottoDraw } from "../types/lotto";
|
||||
|
||||
/**
|
||||
* LottoTable 컴포넌트에서 사용하는 Props 인터페이스입니다.
|
||||
*
|
||||
* @property {LottoDraw[]} draws - 화면에 표시할 로또 회차 데이터 배열입니다.
|
||||
* @property {number} totalCount - 전체 데이터의 개수입니다 (CardHeader에 표시).
|
||||
* @property {number} page - 현재 활성화된 페이지 번호입니다.
|
||||
* @property {number} totalPages - 전체 페이지 수입니다 (Pagination 계산에 사용).
|
||||
* @property {(page: number) => void} onPageChange - 페이지가 변경될 때 호출되는 콜백 함수입니다.
|
||||
* @property {() => void} onOpenGenerator - '로또 번호 생성' 버튼 클릭 시 다이어로그를 열기 위한 콜백입니다.
|
||||
* @property {"loading" | "error" | "ready"} status - 현재 데이터의 상태입니다 (React Query의 상태와 연동).
|
||||
* @property {string} [errorMessage] - status가 'error' 인 경우 표시할 메시지입니다.
|
||||
*/
|
||||
interface LottoTableProps {
|
||||
draws: LottoDraw[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onOpenGenerator: () => void;
|
||||
status: "loading" | "error" | "ready";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
type PaginationItemType = number | "ellipsis";
|
||||
|
||||
/**
|
||||
* 페이지네이션에 표시할 숫자 리스트를 계산하는 헬퍼 함수입니다.
|
||||
* 페이지가 너무 많을 경우(7개 초과) 생략 기호(...)를 포함하여 표시합니다.
|
||||
*
|
||||
* @param {number} currentPage - 현재 페이지 번호
|
||||
* @param {number} pageCount - 전체 페이지 수
|
||||
* @returns {PaginationItemType[]} 페이지 번호와 'ellipsis' 문자열이 섞인 배열
|
||||
*/
|
||||
function getPaginationItems(
|
||||
currentPage: number,
|
||||
pageCount: number
|
||||
): PaginationItemType[] {
|
||||
// 전체 페이지가 7개 이하라면 모든 페이지 번호를 그대로 보여줍니다.
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({ length: pageCount }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
// 현재 페이지가 앞쪽(1~3)인 경우: [1, 2, 3, 4, ..., 마지막]
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, "ellipsis", pageCount];
|
||||
}
|
||||
|
||||
// 현재 페이지가 뒤쪽인 경우: [1, ..., 뒤에서 4번째부터 마지막까지]
|
||||
if (currentPage >= pageCount - 2) {
|
||||
return [1, "ellipsis", pageCount - 3, pageCount - 2, pageCount - 1, pageCount];
|
||||
}
|
||||
|
||||
// 현재 페이지가 중간인 경우: [1, ..., 전, 현재, 후, ..., 마지막]
|
||||
return [
|
||||
1,
|
||||
"ellipsis",
|
||||
currentPage - 1,
|
||||
currentPage,
|
||||
currentPage + 1,
|
||||
"ellipsis",
|
||||
pageCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 로또 당첨 목록을 테이블 형태로 보여주는 클라이언트 컴포넌트입니다.
|
||||
* Shadcn UI의 Table, Card, Pagination 컴포넌트를 사용하여 구현되었습니다.
|
||||
*
|
||||
* 데이터 흐름 요약:
|
||||
* 1) 부모가 React Query 결과(draws/totalCount/totalPages/status)를 전달
|
||||
* 2) 내부에서 페이지네이션 숫자/비활성 상태를 계산
|
||||
* 3) 상태에 따라 로딩/에러/빈 데이터/정상 목록을 분기 렌더링
|
||||
*/
|
||||
export function LottoTable({
|
||||
draws,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onOpenGenerator,
|
||||
status,
|
||||
errorMessage,
|
||||
}: LottoTableProps) {
|
||||
// 페이지 상태에 따라 버튼/링크를 비활성화합니다.
|
||||
const pages = getPaginationItems(page, totalPages);
|
||||
const isPagingDisabled = status !== "ready";
|
||||
const isPrevDisabled = isPagingDisabled || page <= 1;
|
||||
const isNextDisabled = isPagingDisabled || page >= totalPages;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 1) 헤더: 전체 개수와 CTA(추천 생성) */}
|
||||
<CardHeader className="flex flex-col gap-4 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>로또 번호 조회</CardTitle>
|
||||
<CardDescription>총 {totalCount}건</CardDescription>
|
||||
</div>
|
||||
{/* CTA는 부모 컴포넌트에서 다이어로그를 여는 트리거입니다. */}
|
||||
<Button type="button" onClick={onOpenGenerator} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
로또 번호 생성
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
{/* 테이블은 표시만 담당하고, 데이터는 부모에서 React Query로 전달받습니다. */}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">회차</TableHead>
|
||||
<TableHead>번호</TableHead>
|
||||
<TableHead className="w-[120px] text-right">보너스</TableHead>
|
||||
<TableHead className="text-right">추첨일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 2) 상태 분기: 로딩 → 에러 → 빈 데이터 → 정상 목록 순서로 렌더링 */}
|
||||
{status === "loading" ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
데이터를 불러오는 중입니다...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : status === "error" ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-destructive">
|
||||
{errorMessage ?? "데이터를 불러오지 못했습니다."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : draws.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
표시할 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
// 정상 상태: 회차별로 번호/보너스/추첨일을 렌더링합니다.
|
||||
draws.map((draw) => (
|
||||
<TableRow key={draw.id}>
|
||||
<TableCell className="font-semibold">{draw.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{draw.numbers.map((value) => (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="outline"
|
||||
className="border-blue-100 bg-blue-50 text-blue-700"
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{draw.bonusNumber ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-200 bg-amber-50 text-amber-700"
|
||||
>
|
||||
{draw.bonusNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
// 보너스 번호가 없는 회차는 "-"로 표시합니다.
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{draw.createdAt}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* 3) 페이지 요약: 현재/전체 페이지를 간단히 안내 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
페이지 {page} / {totalPages}
|
||||
</p>
|
||||
{/* Pagination은 링크로 렌더링되지만 클릭은 버튼처럼 처리합니다. */}
|
||||
<Pagination className="sm:justify-end">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
aria-disabled={isPrevDisabled}
|
||||
className={cn(isPrevDisabled && "pointer-events-none opacity-50")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
// 링크 기본 동작을 막고, 상태 기반 페이지 전환만 허용합니다.
|
||||
if (!isPrevDisabled) {
|
||||
onPageChange(page - 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{/* 페이지 숫자 배열을 순회하며 숫자/ellipsis를 구분 렌더링합니다. */}
|
||||
{pages.map((pageItem, index) =>
|
||||
pageItem === "ellipsis" ? (
|
||||
<PaginationItem key={`ellipsis-${index}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={pageItem}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={pageItem === page}
|
||||
className={cn(
|
||||
isPagingDisabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
// 비활성 상태에서는 페이지 이동을 막습니다.
|
||||
if (!isPagingDisabled) {
|
||||
// 숫자 페이지 클릭 → 해당 번호로 이동합니다.
|
||||
onPageChange(pageItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pageItem}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
aria-disabled={isNextDisabled}
|
||||
className={cn(isNextDisabled && "pointer-events-none opacity-50")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
// 마지막 페이지를 넘어가지 않도록 가드합니다.
|
||||
if (!isNextDisabled) {
|
||||
onPageChange(page + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
86
src/features/lotto/hooks/useLottoDraws.ts
Normal file
86
src/features/lotto/hooks/useLottoDraws.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAllLottoDraws } from "../api/lottoApi";
|
||||
import { lottoKeys } from "../api/queryKeys";
|
||||
import { LottoDraw } from "../types/lotto";
|
||||
|
||||
/**
|
||||
* 로또 회차 조회 훅에 전달하는 옵션입니다.
|
||||
* @property {number} page - 현재 페이지 번호입니다.
|
||||
* @property {number} pageSize - 한 페이지에 보여줄 개수입니다.
|
||||
*/
|
||||
interface UseLottoDrawsOptions {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로또 회차 조회 훅이 반환하는 결과 타입입니다.
|
||||
* @property {LottoDraw[]} items - 현재 페이지에 보여줄 회차 목록입니다.
|
||||
* @property {LottoDraw[]} recentDraws - 최신 3회차 목록(추천 로직에 사용)입니다.
|
||||
* @property {number} totalCount - 전체 회차 개수입니다.
|
||||
* @property {number} totalPages - 전체 페이지 수입니다.
|
||||
* @property {boolean} isLoading - 로딩 상태 여부입니다.
|
||||
* @property {boolean} isError - 에러 상태 여부입니다.
|
||||
* @property {string} [errorMessage] - 에러 메시지(있을 경우)입니다.
|
||||
*/
|
||||
interface UseLottoDrawsResult {
|
||||
items: LottoDraw[];
|
||||
recentDraws: LottoDraw[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로또 회차 데이터를 조회하고 페이지네이션 정보를 계산하는 커스텀 훅입니다.
|
||||
* React Query 캐시를 사용해 서버 호출을 줄이고, 클라이언트에서 페이지를 나눠줍니다.
|
||||
*/
|
||||
export function useLottoDraws({
|
||||
page,
|
||||
pageSize,
|
||||
}: UseLottoDrawsOptions): UseLottoDrawsResult {
|
||||
// queryKey는 "lotto/list"로 고정하여 동일 리스트 캐시를 재사용합니다.
|
||||
// queryKey가 바뀌면 React Query는 다른 데이터로 인식해 새로 요청/캐시합니다.
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: lottoKeys.lists(),
|
||||
// queryFn은 실제 네트워크 요청을 담당하며, 결과는 캐시에 저장됩니다.
|
||||
queryFn: getAllLottoDraws,
|
||||
// staleTime 동안은 "신선한 데이터"로 간주해 불필요한 재요청을 줄입니다.
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
// 데이터가 아직 없을 수 있으니 nullish coalescing으로 안전한 기본값을 둡니다.
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
// 페이지 수는 최소 1로 유지해 UI 분기가 단순해집니다.
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
|
||||
const recentDraws = useMemo(() => {
|
||||
// useMemo로 최신 3회차 계산을 캐싱해, items가 바뀔 때만 다시 계산되게 합니다.
|
||||
// data?.items가 바뀌면 다시 실행되어 최신 회차 기준이 유지됩니다.
|
||||
const allItems = data?.items ?? [];
|
||||
return allItems.slice(0, 3);
|
||||
}, [data?.items]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
// useMemo로 페이지 슬라이스를 캐싱해, page/pageSize/items가 바뀔 때만 다시 계산합니다.
|
||||
// 의존성(page/pageSize/data?.items)이 변하면 재계산되어 페이지 이동이 반영됩니다.
|
||||
if (!data?.items) {
|
||||
return [];
|
||||
}
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
return data.items.slice(startIndex, startIndex + pageSize);
|
||||
}, [data?.items, page, pageSize]);
|
||||
|
||||
return {
|
||||
items,
|
||||
recentDraws,
|
||||
totalCount,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage: error instanceof Error ? error.message : undefined,
|
||||
};
|
||||
}
|
||||
55
src/features/lotto/store/useLottoStore.ts
Normal file
55
src/features/lotto/store/useLottoStore.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* 로또 페이지에서 공유하는 UI 상태 타입입니다.
|
||||
* 상태를 원자적으로(Atomic) 나누면 필요한 컴포넌트만 선택적으로 구독할 수 있습니다.
|
||||
*/
|
||||
interface LottoState {
|
||||
/** 현재 페이지 번호(페이지네이션 기준)입니다. */
|
||||
page: number;
|
||||
/** 추천 번호 다이얼로그의 열림/닫힘 상태입니다. */
|
||||
dialogOpen: boolean;
|
||||
/** 추천 번호 목록(10줄 등)을 저장합니다. */
|
||||
recommendations: number[][];
|
||||
/** 조건 1: 최근 N회차 메인 번호 제외 */
|
||||
condition1: boolean;
|
||||
/** 조건 2: 최근 N회차 보너스 번호 제외 */
|
||||
condition2: boolean;
|
||||
/** 조건 3: Hot & Due 알고리즘 적용 */
|
||||
condition3: boolean;
|
||||
/** 현재 페이지를 갱신합니다. */
|
||||
setPage: (page: number) => void;
|
||||
/** 다이얼로그 열림 상태를 갱신합니다. */
|
||||
setDialogOpen: (open: boolean) => void;
|
||||
/** 추천 번호 목록을 통째로 교체합니다. */
|
||||
setRecommendations: (recommendations: number[][]) => void;
|
||||
/** 조건 1 활성화 여부를 갱신합니다. */
|
||||
setCondition1: (value: boolean) => void;
|
||||
/** 조건 2 활성화 여부를 갱신합니다. */
|
||||
setCondition2: (value: boolean) => void;
|
||||
/** 조건 3 활성화 여부를 갱신합니다. */
|
||||
setCondition3: (value: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로또 UI 상태를 전역 스토어로 관리합니다.
|
||||
* - 페이지, 다이얼로그, 추천번호, 조건 플래그를 분리해 컴포넌트 간 데이터 흐름을 단순화합니다.
|
||||
*/
|
||||
export const useLottoStore = create<LottoState>((set) => ({
|
||||
// 초기 페이지는 1로 고정합니다.
|
||||
page: 1,
|
||||
// 다이얼로그는 기본 닫힘 상태로 시작합니다.
|
||||
dialogOpen: false,
|
||||
// 추천 번호는 다이얼로그 열기 시점에 채웁니다.
|
||||
recommendations: [],
|
||||
// 조건 플래그는 기본 활성화로 두고, UI에서 토글하도록 합니다.
|
||||
condition1: true,
|
||||
condition2: true,
|
||||
condition3: true,
|
||||
setPage: (page) => set({ page }),
|
||||
setDialogOpen: (open) => set({ dialogOpen: open }),
|
||||
setRecommendations: (recommendations) => set({ recommendations }),
|
||||
setCondition1: (value) => set({ condition1: value }),
|
||||
setCondition2: (value) => set({ condition2: value }),
|
||||
setCondition3: (value) => set({ condition3: value }),
|
||||
}));
|
||||
14
src/features/lotto/types/lotto.ts
Normal file
14
src/features/lotto/types/lotto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 로또 회차 데이터의 공통 타입입니다.
|
||||
* UI와 API가 동일한 형태로 데이터를 다루도록 기준을 잡습니다.
|
||||
*/
|
||||
export interface LottoDraw {
|
||||
/** 회차 번호(예: 1112회)입니다. */
|
||||
id: number;
|
||||
/** 당첨 번호 6개입니다. */
|
||||
numbers: number[];
|
||||
/** 보너스 번호는 존재할 때만 내려오므로 optional입니다. */
|
||||
bonusNumber?: number;
|
||||
/** 추첨일(YYYY-MM-DD) 문자열입니다. */
|
||||
createdAt: string;
|
||||
}
|
||||
31
src/features/lotto/utils/createMockLottoDraws.ts
Normal file
31
src/features/lotto/utils/createMockLottoDraws.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { LottoDraw } from "../types/lotto";
|
||||
import { generateLottoNumbers } from "./generateLottoNumbers";
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 데모용 로또 회차 데이터를 생성합니다.
|
||||
* @param {number} count - 만들 회차 개수입니다.
|
||||
* @returns {LottoDraw[]} 최근 날짜부터 역순으로 정렬된 회차 목록입니다.
|
||||
*/
|
||||
export function createMockLottoDraws(count: number): LottoDraw[] {
|
||||
// 기준 시각을 고정해 날짜 계산을 일관되게 만듭니다.
|
||||
const now = Date.now();
|
||||
|
||||
// 데모에서 "최근 회차"처럼 보이도록 날짜를 하루 단위로 줄입니다.
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
// id는 최신 회차가 큰 숫자처럼 보이도록 역순으로 부여합니다.
|
||||
const id = count - index;
|
||||
// ISO 문자열에서 YYYY-MM-DD만 잘라 UI에 맞는 형식을 맞춥니다.
|
||||
const createdAt = new Date(now - index * DAY_IN_MS)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
id,
|
||||
// 기본 옵션(45개 중 6개)으로 랜덤 번호를 생성합니다.
|
||||
numbers: generateLottoNumbers(),
|
||||
createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
364
src/features/lotto/utils/generateLottoNumbers.ts
Normal file
364
src/features/lotto/utils/generateLottoNumbers.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { LottoDraw } from "../types/lotto";
|
||||
|
||||
// ============================================================================
|
||||
// 타입 정의 (Type Definitions)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 번호 생성 시 사용할 옵션입니다.
|
||||
* @property {number} [maxNumber] - 번호의 최대값(기본 45)입니다.
|
||||
* @property {number} [count] - 생성할 번호 개수(기본 6)입니다.
|
||||
* @property {LottoDraw[]} [recentDraws] - 최근 회차 데이터입니다.
|
||||
* @property {number} [excludeRecentDrawsCount] - 제외할 최근 회차 개수(기본 3)입니다.
|
||||
* @property {number} [wHot] - Hot(자주 나온 번호) 가중치(기본 0.6)입니다.
|
||||
* @property {number} [wDue] - Due(오래 안 나온 번호) 가중치(기본 0.4)입니다.
|
||||
*/
|
||||
interface GenerateLottoOptions {
|
||||
maxNumber?: number;
|
||||
count?: number;
|
||||
recentDraws?: LottoDraw[];
|
||||
excludeRecentDrawsCount?: number;
|
||||
wHot?: number;
|
||||
wDue?: number;
|
||||
/** 조건 1: 최근 N회차 메인 번호 제외 (기본 true) */
|
||||
useCondition1?: boolean;
|
||||
/** 조건 2: 최근 N회차 보너스 번호 제외 (기본 true) */
|
||||
useCondition2?: boolean;
|
||||
/** 조건 3: Hot & Due 알고리즘 적용 (기본 true) */
|
||||
useCondition3?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 번호별 점수를 저장하는 타입입니다.
|
||||
*/
|
||||
interface NumberScore {
|
||||
num: number;
|
||||
score: number;
|
||||
freq: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 조건 1 & 2: 최근 회차 번호 제외 (Exclusion Rules)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 최근 회차의 메인 번호를 수집합니다.
|
||||
* @param {LottoDraw[]} recentDraws - 최근 회차 목록입니다.
|
||||
* @param {number} excludeCount - 제외할 회차 수입니다.
|
||||
* @returns {Set<number>} 제외할 메인 번호 집합입니다.
|
||||
*/
|
||||
function collectRecentDrawNumbers(
|
||||
recentDraws: LottoDraw[],
|
||||
excludeCount: number
|
||||
): Set<number> {
|
||||
const excluded = new Set<number>();
|
||||
recentDraws.slice(0, excludeCount).forEach((draw) => {
|
||||
draw.numbers.forEach((value) => excluded.add(value));
|
||||
});
|
||||
return excluded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 회차의 보너스 번호를 수집합니다.
|
||||
* @param {LottoDraw[]} recentDraws - 최근 회차 목록입니다.
|
||||
* @param {number} excludeCount - 제외할 회차 수입니다.
|
||||
* @returns {Set<number>} 제외할 보너스 번호 집합입니다.
|
||||
*/
|
||||
function collectRecentBonusNumbers(
|
||||
recentDraws: LottoDraw[],
|
||||
excludeCount: number
|
||||
): Set<number> {
|
||||
const excluded = new Set<number>();
|
||||
recentDraws.slice(0, excludeCount).forEach((draw) => {
|
||||
if (draw.bonusNumber) {
|
||||
excluded.add(draw.bonusNumber);
|
||||
}
|
||||
});
|
||||
return excluded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 회차 번호 제외 규칙입니다.
|
||||
*/
|
||||
function isExcludedByRecentDraws(
|
||||
value: number,
|
||||
excludedNumbers: Set<number>
|
||||
): boolean {
|
||||
return excludedNumbers.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 회차 보너스 번호 제외 규칙입니다.
|
||||
*/
|
||||
function isExcludedByRecentBonusNumbers(
|
||||
value: number,
|
||||
excludedBonusNumbers: Set<number>
|
||||
): boolean {
|
||||
return excludedBonusNumbers.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제외 규칙에 맞는 후보 번호 목록을 만듭니다.
|
||||
*/
|
||||
function buildCandidateNumbers(
|
||||
maxNumber: number,
|
||||
isExcluded: (value: number) => boolean
|
||||
): number[] {
|
||||
const candidates: number[] = [];
|
||||
for (let value = 1; value <= maxNumber; value += 1) {
|
||||
if (!isExcluded(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 조건 3: Hot & Due 알고리즘 (Weighted Sampling)
|
||||
// ----------------------------------------------------------------------------
|
||||
// "Hot & Due"는 통계 기반 번호 선정 전략입니다.
|
||||
//
|
||||
// - Hot (뜨거운 번호): 최근에 자주 나온 번호는 "핫"하다고 봅니다.
|
||||
// → 이런 번호는 계속 나올 확률이 높다고 가정합니다.
|
||||
//
|
||||
// - Due (오래된 번호): 오랫동안 안 나온 번호는 "곧 나올 차례"라고 봅니다.
|
||||
// → 이런 번호는 조만간 나올 확률이 높다고 가정합니다.
|
||||
//
|
||||
// 이 두 가지 점수를 wHot(기본 0.6)과 wDue(기본 0.4) 비율로 섞어서
|
||||
// 각 번호에 "선택 확률(가중치)"을 부여합니다.
|
||||
// 가중치가 높을수록 뽑힐 확률이 높아지는 방식입니다.
|
||||
//
|
||||
// 예) 번호 7이 자주 나왔고(Hot 점수 높음) + 최근에도 나왔다면(Due 점수 낮음)
|
||||
// → 전체 점수는 "중간 정도"가 됩니다.
|
||||
// 예) 번호 42가 자주 나왔고(Hot 점수 높음) + 오래 안 나왔다면(Due 점수 높음)
|
||||
// → 전체 점수가 "매우 높아져서" 뽑힐 확률이 커집니다.
|
||||
// ============================================================================
|
||||
|
||||
|
||||
/**
|
||||
* 후보 번호 목록에 대해 출현 빈도(freq)와 미출현 기간(gap)을 계산합니다.
|
||||
*
|
||||
* @param {number[]} candidates - 후보 번호 목록입니다.
|
||||
* @param {LottoDraw[]} history - 과거 회차 전체 데이터입니다.
|
||||
* @returns {{ counts: Map<number, number>, gaps: Map<number, number> }} 통계 결과입니다.
|
||||
*/
|
||||
function calculateNumberStats(
|
||||
candidates: number[],
|
||||
history: LottoDraw[]
|
||||
): { counts: Map<number, number>; gaps: Map<number, number> } {
|
||||
// 회차를 id 오름차순으로 정렬 (인덱스 = 시간순)
|
||||
const sortedHistory = [...history].sort((a, b) => a.id - b.id);
|
||||
const drawCount = sortedHistory.length;
|
||||
|
||||
const counts = new Map<number, number>();
|
||||
const lastSeen = new Map<number, number>();
|
||||
|
||||
// 후보 번호들의 초기값 설정
|
||||
candidates.forEach((num) => {
|
||||
counts.set(num, 0);
|
||||
lastSeen.set(num, -1); // -1은 "한 번도 안 나옴"을 의미
|
||||
});
|
||||
|
||||
// 각 회차를 순회하며 통계 집계
|
||||
sortedHistory.forEach((draw, idx) => {
|
||||
draw.numbers.forEach((num) => {
|
||||
if (counts.has(num)) {
|
||||
counts.set(num, (counts.get(num) ?? 0) + 1);
|
||||
lastSeen.set(num, idx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 미출현 기간(gap) 계산: 마지막으로 나온 이후 몇 회차가 지났는지
|
||||
const gaps = new Map<number, number>();
|
||||
candidates.forEach((num) => {
|
||||
const last = lastSeen.get(num) ?? -1;
|
||||
if (last === -1) {
|
||||
// 한 번도 안 나온 번호는 gap을 최대치로 설정
|
||||
gaps.set(num, drawCount);
|
||||
} else {
|
||||
gaps.set(num, drawCount - 1 - last);
|
||||
}
|
||||
});
|
||||
|
||||
return { counts, gaps };
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 번호 목록에 대해 Hot & Due 점수를 계산합니다.
|
||||
*
|
||||
* @param {number[]} candidates - 후보 번호 목록입니다.
|
||||
* @param {Map<number, number>} counts - 번호별 출현 횟수입니다.
|
||||
* @param {Map<number, number>} gaps - 번호별 미출현 기간입니다.
|
||||
* @param {number} wHot - Hot 가중치입니다 (0~1).
|
||||
* @param {number} wDue - Due 가중치입니다 (0~1).
|
||||
* @returns {NumberScore[]} 번호별 점수 배열입니다.
|
||||
*/
|
||||
function calculateScores(
|
||||
candidates: number[],
|
||||
counts: Map<number, number>,
|
||||
gaps: Map<number, number>,
|
||||
wHot: number,
|
||||
wDue: number
|
||||
): NumberScore[] {
|
||||
// 정규화를 위한 최대값 계산
|
||||
let maxCount = 0;
|
||||
let maxGap = 0;
|
||||
|
||||
candidates.forEach((num) => {
|
||||
const c = counts.get(num) ?? 0;
|
||||
const g = gaps.get(num) ?? 0;
|
||||
if (c > maxCount) maxCount = c;
|
||||
if (g > maxGap) maxGap = g;
|
||||
});
|
||||
|
||||
// 방어 코드: 0으로 나누는 것 방지
|
||||
if (maxCount === 0) maxCount = 1;
|
||||
if (maxGap === 0) maxGap = 1;
|
||||
|
||||
// 점수 계산
|
||||
const scores: NumberScore[] = candidates.map((num) => {
|
||||
const freq = counts.get(num) ?? 0;
|
||||
const gap = gaps.get(num) ?? 0;
|
||||
|
||||
// 정규화 (0~1 범위)
|
||||
const freqNorm = freq / maxCount;
|
||||
const gapNorm = gap / maxGap;
|
||||
|
||||
// Hot & Due 혼합 점수
|
||||
const rawScore = wHot * freqNorm + wDue * gapNorm;
|
||||
|
||||
// 0이 되면 선택에서 완전히 배제되므로 아주 작은 값으로 보정
|
||||
const score = rawScore <= 0 ? 1e-6 : rawScore;
|
||||
|
||||
return { num, score, freq, gap };
|
||||
});
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가중치에 비례하여 k개의 번호를 중복 없이 뽑습니다.
|
||||
*
|
||||
* @param {NumberScore[]} scoreList - 번호별 점수 배열입니다.
|
||||
* @param {number} k - 뽑을 개수입니다.
|
||||
* @returns {number[]} 선택된 번호 배열입니다.
|
||||
*/
|
||||
function weightedSampleWithoutReplacement(
|
||||
scoreList: NumberScore[],
|
||||
k: number
|
||||
): number[] {
|
||||
const available = [...scoreList];
|
||||
const result: number[] = [];
|
||||
|
||||
for (let i = 0; i < k; i++) {
|
||||
if (available.length === 0) break;
|
||||
|
||||
// 전체 가중치 합계 계산
|
||||
const totalWeight = available.reduce((sum, item) => sum + item.score, 0);
|
||||
let r = Math.random() * totalWeight;
|
||||
|
||||
// 가중치 기반으로 하나 선택
|
||||
let chosenIndex = 0;
|
||||
for (let idx = 0; idx < available.length; idx++) {
|
||||
r -= available[idx].score;
|
||||
if (r <= 0) {
|
||||
chosenIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const chosen = available[chosenIndex];
|
||||
result.push(chosen.num);
|
||||
available.splice(chosenIndex, 1); // 중복 방지를 위해 제거
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 메인 함수 (Main Function)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 로또 번호를 랜덤으로 생성합니다.
|
||||
* @param {number} maxNumber - 번호의 최대값(기본 45)입니다.
|
||||
* @param {number} count - 생성할 번호 개수(기본 6)입니다.
|
||||
* @returns {number[]} 오름차순으로 정렬된 로또 번호 배열입니다.
|
||||
*/
|
||||
export function generateLottoNumbers(maxNumber?: number, count?: number): number[];
|
||||
/**
|
||||
* 로또 번호를 랜덤으로 생성합니다.
|
||||
* @param {GenerateLottoOptions} options - 생성 옵션입니다.
|
||||
* @returns {number[]} 오름차순으로 정렬된 로또 번호 배열입니다.
|
||||
*/
|
||||
export function generateLottoNumbers(options?: GenerateLottoOptions): number[];
|
||||
export function generateLottoNumbers(
|
||||
arg1: number | GenerateLottoOptions = 45,
|
||||
arg2 = 6
|
||||
): number[] {
|
||||
// 기존 시그니처(숫자 인자)와 옵션 객체 방식을 모두 지원합니다.
|
||||
const baseOptions: GenerateLottoOptions =
|
||||
typeof arg1 === "number"
|
||||
? { maxNumber: arg1, count: arg2 }
|
||||
: arg1 ?? {};
|
||||
|
||||
const maxNumber = baseOptions.maxNumber ?? 45;
|
||||
const count = baseOptions.count ?? 6;
|
||||
// 최근 회차 제외 개수는 기본 3개로 맞춥니다.
|
||||
const excludeRecentDrawsCount = baseOptions.excludeRecentDrawsCount ?? 3;
|
||||
const recentDraws = baseOptions.recentDraws ?? [];
|
||||
const wHot = baseOptions.wHot ?? 0.6;
|
||||
const wDue = baseOptions.wDue ?? 0.4;
|
||||
|
||||
// 조건 플래그 (기본값: 모두 true)
|
||||
const useCondition1 = baseOptions.useCondition1 ?? true;
|
||||
const useCondition2 = baseOptions.useCondition2 ?? true;
|
||||
const useCondition3 = baseOptions.useCondition3 ?? true;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 조건 1: 최근 회차(기본 3개)의 메인 번호는 제외합니다.
|
||||
// ---------------------------------------------------------------------------
|
||||
const excludedDrawNumbers = useCondition1
|
||||
? collectRecentDrawNumbers(recentDraws, excludeRecentDrawsCount)
|
||||
: new Set<number>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 조건 2: 최근 회차(기본 3개)의 보너스 번호는 제외합니다.
|
||||
// ---------------------------------------------------------------------------
|
||||
const excludedBonusNumbers = useCondition2
|
||||
? collectRecentBonusNumbers(recentDraws, excludeRecentDrawsCount)
|
||||
: new Set<number>();
|
||||
|
||||
const isExcluded = (value: number) =>
|
||||
isExcludedByRecentDraws(value, excludedDrawNumbers) ||
|
||||
isExcludedByRecentBonusNumbers(value, excludedBonusNumbers);
|
||||
|
||||
// 제외 규칙을 통과한 후보 목록 생성
|
||||
const candidates = buildCandidateNumbers(maxNumber, isExcluded);
|
||||
if (candidates.length < count) {
|
||||
// 제외 조건이 너무 강하면 빈 배열을 반환해 안전하게 실패합니다.
|
||||
// (예: 최근 회차가 너무 많아 후보가 부족한 경우)
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 조건 3: Hot & Due 알고리즘으로 가중치 기반 랜덤 선택
|
||||
// useCondition3가 true이고, recentDraws가 충분히 있을 때만 적용합니다.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (useCondition3 && recentDraws.length >= 10) {
|
||||
const { counts, gaps } = calculateNumberStats(candidates, recentDraws);
|
||||
const scores = calculateScores(candidates, counts, gaps, wHot, wDue);
|
||||
const picked = weightedSampleWithoutReplacement(scores, count);
|
||||
return picked.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// 조건 3이 비활성화되었거나 과거 데이터가 부족하면 균등 확률로 뽑기
|
||||
const shuffled = [...candidates];
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(Math.random() * (index + 1));
|
||||
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
|
||||
}
|
||||
return shuffled.slice(0, count).sort((a, b) => a - b);
|
||||
}
|
||||
20
src/features/react-query-demo/api/queryKeys.ts
Normal file
20
src/features/react-query-demo/api/queryKeys.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* userKeys
|
||||
* @description React Query의 캐시 키를 일관되게 만들기 위한 팩토리입니다.
|
||||
*/
|
||||
export const userKeys = {
|
||||
// 1. 모든 사용자 관련 쿼리의 루트 키입니다.
|
||||
all: ["users"] as const,
|
||||
|
||||
// 2. 목록 조회 전용 키입니다. (예: ['users', 'list'])
|
||||
lists: () => [...userKeys.all, "list"] as const,
|
||||
|
||||
// 3. 필터가 있는 목록 키입니다. (예: ['users', 'list', { filters: 'admin' }])
|
||||
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
|
||||
|
||||
// 4. 상세 조회 전용 루트 키입니다. (예: ['users', 'detail'])
|
||||
details: () => [...userKeys.all, "detail"] as const,
|
||||
|
||||
// 5. 특정 ID의 상세 키입니다. (예: ['users', 'detail', 1])
|
||||
detail: (id: number) => [...userKeys.details(), id] as const,
|
||||
};
|
||||
75
src/features/react-query-demo/api/userApi.ts
Normal file
75
src/features/react-query-demo/api/userApi.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { User } from "../types"; // 공통 User 타입을 API 응답에 적용합니다.
|
||||
|
||||
// 로컬 json-server 주소입니다. (필요하면 환경변수로 분리하세요)
|
||||
const BASE_URL = "http://localhost:3002";
|
||||
// const BASE_URL = "https://jsonplaceholder.typicode.com"; // 외부 API 대체 주소 예시입니다.
|
||||
|
||||
/**
|
||||
* getUser
|
||||
* @description 단일 사용자 정보를 가져옵니다.
|
||||
* @param id - 조회할 사용자 ID입니다.
|
||||
* @returns 사용자 상세 데이터를 반환합니다.
|
||||
*/
|
||||
export async function getUser(id: number): Promise<User> {
|
||||
// 네트워크 대기 시간을 체험하기 위해 1초 지연을 인위적으로 줍니다.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// 사용자 상세 엔드포인트로 요청합니다.
|
||||
const response = await fetch(`${BASE_URL}/users/${id}`);
|
||||
|
||||
// 응답이 실패라면 에러를 던져 React Query가 처리하도록 합니다.
|
||||
if (!response.ok) {
|
||||
throw new Error("서버에서 정보를 가져오는 데 실패했어요.");
|
||||
}
|
||||
|
||||
// 정상 응답이면 JSON을 파싱해 반환합니다.
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* getUsers
|
||||
* @description 사용자 목록을 가져옵니다.
|
||||
* @returns 사용자 목록 배열을 반환합니다.
|
||||
*/
|
||||
export async function getUsers(): Promise<User[]> {
|
||||
// 목록 조회도 로딩 상태를 관찰하기 위해 1초 지연합니다.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// 사용자 목록 엔드포인트로 요청합니다.
|
||||
const response = await fetch(`${BASE_URL}/users`);
|
||||
|
||||
// 실패 시 에러를 던져 UI에서 오류 상태를 표시합니다.
|
||||
if (!response.ok) {
|
||||
throw new Error("목록을 가져오는 데 실패했어요.");
|
||||
}
|
||||
|
||||
// 정상 응답이면 JSON을 파싱해 반환합니다.
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* createUser
|
||||
* @description 새 사용자를 등록합니다. (POST 요청)
|
||||
* @param userData - id를 제외한 사용자 정보입니다.
|
||||
* @returns 생성된 사용자 정보를 반환합니다.
|
||||
*/
|
||||
export async function createUser(
|
||||
userData: Omit<User, "id">
|
||||
): Promise<User> {
|
||||
// POST 요청으로 새 사용자 정보를 서버에 전송합니다.
|
||||
const response = await fetch(`${BASE_URL}/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(userData), // 요청 본문을 JSON 문자열로 변환합니다.
|
||||
});
|
||||
|
||||
// 실패 시 에러를 던져 Mutation이 실패 상태가 되게 합니다.
|
||||
if (!response.ok) {
|
||||
throw new Error("사용자 정보를 생성하는 데 실패했어요.");
|
||||
}
|
||||
|
||||
// 정상 응답이면 JSON을 파싱해 반환합니다.
|
||||
return response.json();
|
||||
}
|
||||
159
src/features/react-query-demo/components/UserCreateForm.tsx
Normal file
159
src/features/react-query-demo/components/UserCreateForm.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
// 사용자 입력을 다루는 폼이므로 클라이언트 컴포넌트로 선언합니다.
|
||||
|
||||
import { useForm } from "react-hook-form"; // 폼 상태/검증을 간단히 관리하는 훅입니다.
|
||||
import { zodResolver } from "@hookform/resolvers/zod"; // Zod 스키마를 react-hook-form에 연결합니다.
|
||||
import * as z from "zod"; // 입력값 검증을 위한 스키마 빌더입니다.
|
||||
import { useCreateUser } from "../hooks/useCreateUser"; // 사용자 생성 Mutation 훅입니다.
|
||||
import { useRouter } from "next/navigation"; // 생성 후 페이지 이동을 위한 라우터입니다.
|
||||
import { Loader2, User, Mail, Save, X } from "lucide-react"; // 폼 UI 아이콘입니다.
|
||||
|
||||
// 1. 사용자 입력 검증 스키마를 정의합니다.
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(2, "이름은 최소 2글자 이상이어야 해요!"),
|
||||
email: z.string().email("올바른 이메일 형식이 아니에요!"),
|
||||
username: z.string().min(3, "사용자명은 최소 3글자 이상이어야 해요!"),
|
||||
});
|
||||
|
||||
// 스키마로부터 폼 데이터 타입을 자동 추론합니다.
|
||||
type UserFormData = z.infer<typeof userSchema>;
|
||||
|
||||
/**
|
||||
* UserCreateForm
|
||||
* @description 새 사용자를 등록하는 폼 컴포넌트입니다.
|
||||
*/
|
||||
export function UserCreateForm() {
|
||||
// 등록 성공 시 목록 페이지로 이동하기 위해 라우터를 가져옵니다.
|
||||
const router = useRouter();
|
||||
// 사용자 생성 Mutation과 로딩 상태를 가져옵니다.
|
||||
const { mutate, isPending } = useCreateUser();
|
||||
|
||||
// 2. 폼 초기화: 기본값과 검증 규칙을 연결합니다.
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<UserFormData>({
|
||||
resolver: zodResolver(userSchema), // Zod 스키마 기반의 검증을 연결합니다.
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
username: "",
|
||||
},
|
||||
});
|
||||
|
||||
// 3. 제출 핸들러: 서버에 등록 요청을 보냅니다.
|
||||
const onSubmit = (data: UserFormData) => {
|
||||
mutate(data, {
|
||||
onSuccess: () => {
|
||||
// 성공 후에는 목록 화면으로 이동합니다.
|
||||
router.push("/react-query");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="bg-white rounded-2xl shadow-xl border border-zinc-100 overflow-hidden"
|
||||
>
|
||||
{/* 폼 상단 타이틀 영역입니다. */}
|
||||
<div className="bg-zinc-900 p-6 text-white text-center">
|
||||
<h2 className="text-xl font-bold flex items-center justify-center gap-2">
|
||||
<User className="h-5 w-5 text-blue-400" />
|
||||
신규 유저 추가하기
|
||||
</h2>
|
||||
<p className="text-zinc-400 text-xs mt-1">
|
||||
로컬 데이터베이스에 새 사용자를 추가합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 입력 필드 영역입니다. */}
|
||||
<div className="p-8 space-y-6">
|
||||
{/* 이름 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-zinc-700 flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-blue-500" /> 이름
|
||||
</label>
|
||||
<input
|
||||
{...register("name")}
|
||||
placeholder="예: 홍길동"
|
||||
className={`w-full rounded-xl border px-4 py-3 outline-none transition-all focus:ring-2
|
||||
${
|
||||
errors.name
|
||||
? "border-red-500 focus:ring-red-100"
|
||||
: "border-zinc-200 focus:border-blue-500 focus:ring-blue-100"
|
||||
}`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-red-500">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용자명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-zinc-700 flex items-center gap-2">
|
||||
사용자 ID (Username)
|
||||
</label>
|
||||
<input
|
||||
{...register("username")}
|
||||
placeholder="예: gildong123"
|
||||
className={`w-full rounded-xl border px-4 py-3 outline-none transition-all focus:ring-2
|
||||
${
|
||||
errors.username
|
||||
? "border-red-500 focus:ring-red-100"
|
||||
: "border-zinc-200 focus:border-blue-500 focus:ring-blue-100"
|
||||
}`}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-xs text-red-500">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이메일 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-zinc-700 flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-blue-500" /> 이메일
|
||||
</label>
|
||||
<input
|
||||
{...register("email")}
|
||||
placeholder="example@email.com"
|
||||
className={`w-full rounded-xl border px-4 py-3 outline-none transition-all focus:ring-2
|
||||
${
|
||||
errors.email
|
||||
? "border-red-500 focus:ring-red-100"
|
||||
: "border-zinc-200 focus:border-blue-500 focus:ring-blue-100"
|
||||
}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼들입니다. */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl border border-zinc-200 px-4 py-4 text-zinc-600 font-bold hover:bg-zinc-50 transition-all"
|
||||
>
|
||||
<X className="h-4 w-4" /> 취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex-2 flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-4 text-white font-bold hover:bg-blue-700 transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-5 w-5" />
|
||||
)}
|
||||
등록하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
63
src/features/react-query-demo/components/UserDashboard.tsx
Normal file
63
src/features/react-query-demo/components/UserDashboard.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { UserList } from "./UserList"; // 목록 UI를 분리해 가독성을 높입니다.
|
||||
import Link from "next/link"; // Next.js의 클라이언트 링크 컴포넌트입니다.
|
||||
import { UserPlus, Users } from "lucide-react"; // 아이콘으로 의미를 보강합니다.
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; // 서버에서 프리패치한 캐시를 전달합니다.
|
||||
import { getQueryClient } from "@/lib/getQueryClient"; // 서버 전용 QueryClient 유틸리티입니다.
|
||||
import { getUsers } from "../api/userApi"; // 서버에서 사용자 목록을 가져오는 API 함수입니다.
|
||||
import { userKeys } from "../api/queryKeys"; // React Query 캐시 키 팩토리입니다.
|
||||
|
||||
/**
|
||||
* UserDashboard
|
||||
* @description React Query 데모의 메인 대시보드(서버 컴포넌트)입니다.
|
||||
*/
|
||||
export async function UserDashboard() {
|
||||
// 1. 서버에서 사용할 QueryClient를 준비합니다.
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
// 2. 사용자 목록을 미리 가져와서 SSR 단계에서 캐시를 채웁니다.
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: userKeys.lists(), // 목록 전용 캐시 키를 사용합니다.
|
||||
queryFn: getUsers, // 실제 데이터를 가져오는 함수입니다.
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-20 max-w-5xl mx-auto">
|
||||
{/* 헤더 섹션: 타이틀과 새 사용자 등록 링크를 보여줍니다. */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-zinc-900 flex items-center gap-3 justify-center sm:justify-start">
|
||||
React Query <span className="text-blue-600">Lab</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-zinc-600 prose prose-zinc max-w-md">
|
||||
공용 유틸리티 기반으로 프리패치와 하이드레이션을 학습합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/react-query/new"
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-6 py-3.5 text-white font-bold transition-all hover:bg-blue-700 active:scale-95 shadow-lg shadow-blue-200"
|
||||
>
|
||||
<UserPlus className="h-5 w-5" />
|
||||
신규 사용자 등록
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-zinc-400 uppercase tracking-wider">
|
||||
<Users className="h-4 w-4" />
|
||||
Full User List
|
||||
</div>
|
||||
<span className="text-xs text-zinc-400 bg-zinc-100 px-2 py-1 rounded-full">
|
||||
Prefetched with Utility
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 3. 서버 캐시를 클라이언트에 전달하기 위한 경계입니다. */}
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<UserList />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/features/react-query-demo/components/UserDetail.tsx
Normal file
108
src/features/react-query-demo/components/UserDetail.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
// 상세 조회는 브라우저 상호작용(리프레시 버튼 등)이 있으므로 클라이언트 컴포넌트로 둡니다.
|
||||
|
||||
import { useUser } from "../hooks/useUser"; // 특정 사용자 상세를 가져오는 React Query 훅입니다.
|
||||
import {
|
||||
Loader2,
|
||||
User,
|
||||
Mail,
|
||||
Hash,
|
||||
RefreshCcw,
|
||||
AlertCircle,
|
||||
} from "lucide-react"; // 시각적 피드백을 위한 아이콘들입니다.
|
||||
|
||||
/**
|
||||
* UserDetail
|
||||
* @description 선택한 사용자의 상세 정보를 보여줍니다.
|
||||
* @param userId - 상세 조회에 사용할 사용자 ID입니다.
|
||||
*/
|
||||
export function UserDetail({ userId }: { userId: number }) {
|
||||
// id에 해당하는 사용자 데이터를 가져오고 상태를 함께 받습니다.
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useUser(userId);
|
||||
|
||||
// 1. 최초 로딩 중에는 스켈레톤 대신 로딩 UI만 표시합니다.
|
||||
if (isLoading && !user) {
|
||||
return (
|
||||
<div className="flex h-[320px] flex-col items-center justify-center rounded-2xl bg-zinc-50/50 border border-dashed border-zinc-200">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="mt-4 text-sm font-medium text-zinc-500">
|
||||
정보를 불러오는 중입니다..
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 오류가 났다면 재시도 버튼을 제공해 학습자가 흐름을 확인할 수 있게 합니다.
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-6 text-center">
|
||||
<AlertCircle className="mx-auto h-8 w-8 text-red-500" />
|
||||
<h3 className="mt-4 font-bold text-red-900">데이터 로드 실패</h3>
|
||||
<p className="mt-2 text-sm text-red-600">{error.message}</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white shadow-xl border border-zinc-100">
|
||||
{/* 상단 헤더: 사용자 이름을 강조하는 배너입니다. */}
|
||||
<div className="bg-linear-to-r from-blue-600 to-indigo-600 p-6 text-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-full bg-white/20 p-3 backdrop-blur-md">
|
||||
<User className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-100">User Profile</p>
|
||||
{/* 안전하게 접근하기 위해 optional chaining을 사용합니다. */}
|
||||
<h2 className="text-2xl font-bold">{user?.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문: 사용자 상세 필드들입니다. */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 text-zinc-700">
|
||||
<Hash className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-semibold">ID:</span> {user?.id}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-zinc-700">
|
||||
<Mail className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-semibold">Email:</span> {user?.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-zinc-700">
|
||||
<User className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-semibold">User Name:</span> {user?.username}
|
||||
</div>
|
||||
|
||||
{/* 수동으로 다시 가져오기 버튼입니다. */}
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-zinc-900 px-4 py-3 text-sm font-bold text-white transition-all hover:bg-zinc-800 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refetching..." : "Detail Refetch"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/features/react-query-demo/components/UserList.tsx
Normal file
150
src/features/react-query-demo/components/UserList.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
// 이 컴포넌트는 브라우저에서 동작하므로 클라이언트 컴포넌트로 선언합니다.
|
||||
|
||||
import { useCallback } from "react"; // 동일한 핸들러를 재사용하기 위한 훅입니다.
|
||||
import { useQueryClient } from "@tanstack/react-query"; // 프리패치를 위해 캐시에 접근합니다.
|
||||
import { useRouter } from "next/navigation"; // 클릭/프리패치에 사용할 Next.js 라우터입니다.
|
||||
import { useUsers } from "../hooks/useUsers"; // 사용자 목록을 가져오는 React Query 훅입니다.
|
||||
import { useUserStore } from "../store/useUserStore"; // 선택된 사용자 ID를 공유하는 Zustand 스토어입니다.
|
||||
import { getUser } from "../api/userApi"; // 상세 데이터 프리패치를 위한 API 함수입니다.
|
||||
import { userKeys } from "../api/queryKeys"; // 상세 쿼리 키를 일관되게 만들기 위한 도구입니다.
|
||||
import { Skeleton } from "@/components/ui/skeleton"; // Shadcn UI 스켈레톤 컴포넌트입니다.
|
||||
import {
|
||||
Loader2,
|
||||
User as UserIcon,
|
||||
Mail,
|
||||
Hash,
|
||||
AlertCircle,
|
||||
} from "lucide-react"; // 상태/정보 아이콘으로 UI 가독성을 높입니다.
|
||||
|
||||
/**
|
||||
* UserList
|
||||
* @description 사용자 목록을 조회하고, 선택한 사용자를 강조 표시하는 컴포넌트입니다.
|
||||
*/
|
||||
export function UserList() {
|
||||
// 서버에서 사용자 목록을 가져옵니다.
|
||||
const { data, isLoading, isError, error } = useUsers();
|
||||
// 선택된 사용자 상태를 전역으로 유지합니다.
|
||||
const { selectedUserId, setSelectedUserId } = useUserStore();
|
||||
// 선택한 사용자 상세 페이지로 이동하기 위해 라우터를 가져옵니다.
|
||||
const router = useRouter();
|
||||
// React Query 캐시에 접근해 상세 데이터를 미리 채웁니다.
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* handlePrefetch
|
||||
* @description 카드에 마우스를 올리거나 포커스했을 때 라우트/데이터를 미리 준비합니다.
|
||||
* @param id - 프리패치 대상 사용자 ID입니다.
|
||||
*/
|
||||
const handlePrefetch = useCallback(
|
||||
(id: number) => {
|
||||
// 1) 라우트 프리패치: 상세 페이지의 코드/데이터 준비를 시작합니다.
|
||||
router.prefetch(`/react-query/${id}`);
|
||||
// 2) 데이터 프리패치: 상세 데이터를 캐시에 미리 채워둡니다.
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: userKeys.detail(id),
|
||||
queryFn: () => getUser(id),
|
||||
});
|
||||
},
|
||||
// router/queryClient가 바뀌면 새 콜백을 다시 만듭니다.
|
||||
[router, queryClient]
|
||||
);
|
||||
|
||||
// 로딩 중일 때는 스피너만 보여주어 사용자가 기다리게 합니다.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 실제 카드 레이아웃과 비슷한 스켈레톤을 여러 개 보여줍니다. */}
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={`user-skeleton-${index}`} // 스켈레톤 리스트를 식별하기 위한 키입니다.
|
||||
className="flex flex-col rounded-xl border border-zinc-200 p-5 bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 하단에 로딩 상태를 보조적으로 알려주는 스피너를 배치합니다. */}
|
||||
<div className="col-span-full flex justify-center pt-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 오류가 나면 바로 알 수 있도록 메시지를 표시합니다.
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-xl bg-red-50 p-4 text-red-600 flex items-center gap-2">
|
||||
{/* 오류 상황을 강조하는 경고 아이콘입니다. */}
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{/* 서버 오류 메시지를 보여줍니다. */}
|
||||
<span>목록을 가져오지 못했어요: {error.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 목록 데이터가 있을 때만 사용자 카드를 생성합니다. */}
|
||||
{data?.map((user) => (
|
||||
<div
|
||||
key={user.id} // React가 리스트를 추적할 수 있도록 고유 ID를 키로 사용합니다.
|
||||
onClick={() => {
|
||||
// 클릭한 사용자를 전역 상태로 저장합니다.
|
||||
setSelectedUserId(user.id);
|
||||
// 선택된 사용자 상세 페이지로 이동합니다.
|
||||
router.push(`/react-query/${user.id}`);
|
||||
}}
|
||||
onMouseEnter={() => handlePrefetch(user.id)} // 마우스 오버 시 상세 페이지를 미리 준비합니다.
|
||||
onFocus={() => handlePrefetch(user.id)} // 키보드 포커스도 동일하게 프리패치합니다.
|
||||
className={`group flex flex-col rounded-xl border p-5 transition-all cursor-pointer hover:shadow-md
|
||||
${
|
||||
// 선택된 항목은 강조 색상을 사용해 시각적으로 구분합니다.
|
||||
selectedUserId === user.id
|
||||
? "border-blue-500 bg-blue-50/50 shadow-sm ring-1 ring-blue-500"
|
||||
: "border-zinc-200 bg-white hover:border-blue-200"
|
||||
}`}
|
||||
>
|
||||
{/* 사용자 이름과 아이콘을 묶은 헤더 영역입니다. */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={`rounded-full p-2 transition-colors
|
||||
${
|
||||
// 선택된 사용자일 때 아이콘 배경색을 바꿔 강조합니다.
|
||||
selectedUserId === user.id
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="h-5 w-5" />
|
||||
</div>
|
||||
{/* 이름이 길어질 수 있어 한 줄로 잘라서 보여줍니다. */}
|
||||
<h3 className="font-bold text-zinc-900 line-clamp-1">
|
||||
{user.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 이메일/ID처럼 부가 정보를 모은 영역입니다. */}
|
||||
<div className="space-y-2 text-sm text-zinc-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-zinc-300" />
|
||||
<span>ID: {user.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-zinc-300" />
|
||||
<span className="line-clamp-1">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/features/react-query-demo/hooks/useCreateUser.ts
Normal file
27
src/features/react-query-demo/hooks/useCreateUser.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"; // 변경 작업과 캐시 제어를 위한 훅입니다.
|
||||
import { createUser } from "../api/userApi"; // 사용자 생성 API 함수입니다.
|
||||
import { userKeys } from "../api/queryKeys"; // 목록 캐시 무효화를 위해 사용합니다.
|
||||
import { User } from "../types"; // 입력 타입을 명확히 하기 위한 모델입니다.
|
||||
|
||||
/**
|
||||
* useCreateUser
|
||||
* @description 새 사용자를 등록하는 Mutation 훅입니다.
|
||||
* @returns mutate 함수와 상태를 포함한 Mutation 객체입니다.
|
||||
*/
|
||||
export function useCreateUser() {
|
||||
// 캐시를 직접 다루기 위해 QueryClient를 가져옵니다.
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
// mutationFn은 실제 생성 API를 호출합니다.
|
||||
mutationFn: (userData: Omit<User, "id">) => createUser(userData),
|
||||
// 성공 시 목록 캐시를 무효화해 최신 목록을 다시 가져오게 합니다.
|
||||
onSuccess: () => {
|
||||
// 유저 생성 후 목록 캐시를 무효화해 최신 목록을 다시 가져오게 합니다.
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
|
||||
|
||||
// 학습을 위한 로그로 성공 시점을 확인합니다.
|
||||
console.log("신규 유저가 등록되었습니다! 🎉");
|
||||
},
|
||||
});
|
||||
}
|
||||
29
src/features/react-query-demo/hooks/useUser.ts
Normal file
29
src/features/react-query-demo/hooks/useUser.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// 상세 조회도 브라우저 캐시를 사용하므로 클라이언트 훅으로 둡니다.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"; // 서버 상태를 캐싱/동기화하는 훅입니다.
|
||||
import { getUser } from "@/features/react-query-demo/api/userApi"; // 특정 사용자 조회 API 함수입니다.
|
||||
import { User } from "@/features/react-query-demo/types"; // 반환 타입을 명시해 안정성을 높입니다.
|
||||
import { userKeys } from "@/features/react-query-demo/api/queryKeys"; // 상세 조회 전용 queryKey 팩토리입니다.
|
||||
|
||||
/**
|
||||
* useUser
|
||||
* @description 단일 사용자 상세 정보를 가져오는 React Query 훅입니다.
|
||||
* @param id - 조회할 사용자 ID입니다.
|
||||
* @returns 사용자 상세와 로딩/오류 상태를 반환합니다.
|
||||
*/
|
||||
export function useUser(id: number) {
|
||||
return useQuery<User>({
|
||||
// id가 바뀌면 React Query가 다른 캐시 키로 인식해 재요청합니다.
|
||||
queryKey: userKeys.detail(id),
|
||||
// 실제 API 호출 로직은 userApi로 분리해 재사용합니다.
|
||||
queryFn: () => getUser(id),
|
||||
// 상세 데이터는 5분 동안 신선하다고 보고 재요청을 줄입니다.
|
||||
staleTime: 1000 * 60 * 5,
|
||||
// 10분 동안 사용하지 않으면 캐시에서 제거합니다.
|
||||
gcTime: 1000 * 60 * 10,
|
||||
// 네트워크 오류 시 한 번만 재시도합니다.
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
28
src/features/react-query-demo/hooks/useUsers.ts
Normal file
28
src/features/react-query-demo/hooks/useUsers.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
// React Query는 브라우저 캐시를 다루므로 클라이언트 컴포넌트에서 사용합니다.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"; // 서버 상태를 캐싱/동기화하는 훅입니다.
|
||||
import { getUsers } from "@/features/react-query-demo/api/userApi"; // 실제 API 호출 함수입니다.
|
||||
import { User } from "@/features/react-query-demo/types"; // 타입 안정성을 위한 User 타입입니다.
|
||||
import { userKeys } from "@/features/react-query-demo/api/queryKeys"; // 캐시 키 설계 규칙을 모은 객체입니다.
|
||||
|
||||
/**
|
||||
* useUsers
|
||||
* @description 사용자 목록을 가져오는 React Query 훅입니다.
|
||||
* @returns 사용자 목록과 상태(isLoading/isError 등)를 반환합니다.
|
||||
*/
|
||||
export function useUsers() {
|
||||
return useQuery<User[]>({
|
||||
// queryKey는 캐싱의 기준이므로 목록용 키를 명확히 분리합니다.
|
||||
queryKey: userKeys.lists(),
|
||||
// queryFn은 실제 데이터를 가져오는 함수입니다.
|
||||
queryFn: getUsers,
|
||||
// 목록 데이터는 1분 동안 신선하다고 보고 재요청을 줄입니다.
|
||||
staleTime: 1000 * 60 * 1,
|
||||
// 창 포커스 복귀 시 자동 재요청을 끕니다(학습용으로 흐름을 단순화).
|
||||
refetchOnWindowFocus: false,
|
||||
// 네트워크 오류 시 한 번만 재시도합니다.
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
22
src/features/react-query-demo/store/useUserStore.ts
Normal file
22
src/features/react-query-demo/store/useUserStore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { create } from "zustand"; // 전역 상태를 간단히 만들 수 있는 Zustand입니다.
|
||||
|
||||
/**
|
||||
* UserState
|
||||
* @description 선택된 사용자 ID를 공유하기 위한 상태 타입입니다.
|
||||
* @property selectedUserId - 현재 선택된 사용자 ID입니다.
|
||||
* @property setSelectedUserId - 선택된 사용자 ID를 변경하는 함수입니다.
|
||||
*/
|
||||
interface UserState {
|
||||
selectedUserId: number;
|
||||
setSelectedUserId: (id: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* useUserStore
|
||||
* @description 여러 컴포넌트가 "선택된 사용자" 정보를 공유하도록 돕는 스토어입니다.
|
||||
*/
|
||||
export const useUserStore = create<UserState>((set) => ({
|
||||
selectedUserId: 1, // 기본값은 1번 사용자로 설정합니다.
|
||||
setSelectedUserId: (id) =>
|
||||
set({ selectedUserId: id }), // 선택 상태만 갱신해 다른 컴포넌트에 전파합니다.
|
||||
}));
|
||||
15
src/features/react-query-demo/types/index.ts
Normal file
15
src/features/react-query-demo/types/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// 사용자 데이터를 한 곳에서 재사용하기 위한 타입 정의입니다.
|
||||
/**
|
||||
* User
|
||||
* @description React Query 데모에서 사용하는 사용자 모델입니다.
|
||||
* @property id - 서버에서 부여되는 고유 식별자입니다.
|
||||
* @property name - 화면에 보여줄 사용자 이름입니다.
|
||||
* @property email - 연락용 이메일 주소입니다.
|
||||
* @property username - 로그인/표시에 쓰는 사용자 ID입니다.
|
||||
*/
|
||||
export interface User {
|
||||
id: number; // 사용자 고유 ID라서 숫자로 정의합니다.
|
||||
name: string; // 목록/상세 화면에서 표시할 이름입니다.
|
||||
email: string; // 이메일 입력값을 그대로 저장합니다.
|
||||
username: string; // 별칭/아이디 성격의 값입니다.
|
||||
}
|
||||
39
src/features/zustand/components/ZustandExample.tsx
Normal file
39
src/features/zustand/components/ZustandExample.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useCountStore } from '../store/useCountStore';
|
||||
|
||||
export function ZustandExample() {
|
||||
const { count, increment, decrement, reset } = useCountStore();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 p-8 border border-zinc-200 rounded-2xl bg-white shadow-sm max-w-md mx-auto mt-10">
|
||||
<h2 className="text-2xl font-bold text-zinc-800">Zustand Counter</h2>
|
||||
<div className="text-7xl font-black text-amber-500 my-6 tracking-tighter tabular-nums">{count}</div>
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
onClick={decrement}
|
||||
className="flex-1 px-4 py-3 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-colors font-semibold active:scale-95"
|
||||
>
|
||||
감소 -
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex-1 px-4 py-3 bg-zinc-100 text-zinc-600 rounded-xl hover:bg-zinc-200 transition-colors font-semibold active:scale-95"
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
<button
|
||||
onClick={increment}
|
||||
className="flex-1 px-4 py-3 bg-blue-50 text-blue-600 rounded-xl hover:bg-blue-100 transition-colors font-semibold active:scale-95"
|
||||
>
|
||||
증가 +
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400 mt-6 text-center leading-relaxed bg-zinc-50 p-4 rounded-xl w-full">
|
||||
<span className="font-semibold text-zinc-600 block mb-1">상태 관리 테스트</span>
|
||||
버튼을 클릭하여 전역 상태(Count)가 어떻게 변하는지 확인해보세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/features/zustand/store/useCountStore.ts
Normal file
15
src/features/zustand/store/useCountStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface CountState {
|
||||
count: number;
|
||||
increment: () => void;
|
||||
decrement: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useCountStore = create<CountState>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||
decrement: () => set((state) => ({ count: state.count - 1 })),
|
||||
reset: () => set({ count: 0 }),
|
||||
}));
|
||||
30
src/lib/getQueryClient.ts
Normal file
30
src/lib/getQueryClient.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { QueryClient, isServer } from "@tanstack/react-query";
|
||||
|
||||
// 브라우저에서 캐시를 유지하기 위한 싱글톤 인스턴스입니다.
|
||||
let browserQueryClient: QueryClient | undefined = undefined;
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1분 동안은 신선한 데이터로 간주합니다.
|
||||
refetchOnWindowFocus: false, // 탭 포커스 이동 시 자동 재요청을 막습니다.
|
||||
retry: 1, // 실패 시 1회 재시도합니다.
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 서버/클라이언트 환경에 맞는 QueryClient를 반환합니다.
|
||||
export function getQueryClient() {
|
||||
if (isServer) {
|
||||
// 서버는 요청마다 새 인스턴스를 만들어 캐시가 섞이지 않게 합니다.
|
||||
return makeQueryClient();
|
||||
}
|
||||
|
||||
// 브라우저에서는 싱글톤을 재사용해 캐시를 유지합니다.
|
||||
if (!browserQueryClient) {
|
||||
browserQueryClient = makeQueryClient();
|
||||
}
|
||||
return browserQueryClient;
|
||||
}
|
||||
345
src/lib/useDialogDragResize.ts
Normal file
345
src/lib/useDialogDragResize.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, HTMLAttributes, PointerEvent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================================
|
||||
// 상수 (Constants)
|
||||
// ============================================================================
|
||||
const DEFAULT_WIDTH = 720;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const MIN_WIDTH = 320;
|
||||
const MIN_HEIGHT = 360;
|
||||
const VIEWPORT_PADDING = 16;
|
||||
|
||||
// ============================================================================
|
||||
// 타입 정의 (Type Definitions)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 드래그/리사이즈 옵션입니다.
|
||||
* @property {boolean} [enabled] - 기능 활성화 여부입니다.
|
||||
* @property {boolean} [open] - 다이얼로그가 열린 상태인지 여부입니다.
|
||||
* @property {boolean} [draggable] - 드래그 이동 활성화 여부입니다.
|
||||
* @property {boolean} [resizable] - 크기 조절 활성화 여부입니다.
|
||||
* @property {number} [initialWidth] - 초기 너비입니다.
|
||||
* @property {number} [initialHeight] - 초기 높이입니다.
|
||||
* @property {number} [minWidth] - 최소 너비입니다.
|
||||
* @property {number} [minHeight] - 최소 높이입니다.
|
||||
* @property {number} [viewportPadding] - 화면 가장자리 여백입니다.
|
||||
*/
|
||||
export interface DialogDragResizeOptions {
|
||||
enabled?: boolean;
|
||||
open?: boolean;
|
||||
draggable?: boolean;
|
||||
resizable?: boolean;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
viewportPadding?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그/리사이즈 결과 타입입니다.
|
||||
*/
|
||||
export interface DialogDragResizeResult {
|
||||
contentStyle?: CSSProperties;
|
||||
contentClassName: string;
|
||||
dragHandleProps: HTMLAttributes<HTMLElement>;
|
||||
dragHandleClassName: string;
|
||||
resizeHandleProps: HTMLAttributes<HTMLDivElement>;
|
||||
resizeHandleClassName: string;
|
||||
isDraggable: boolean;
|
||||
isResizable: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티 함수 (Utility Functions)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 최소/최대 범위 내로 값을 고정합니다.
|
||||
*/
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 뷰포트 기준으로 드래그 이동 범위를 계산합니다.
|
||||
* 다이얼로그가 `translate(-50%, -50%)`로 중앙 정렬되어 있으므로,
|
||||
* position(x, y)는 "중앙으로부터의 오프셋"입니다.
|
||||
*/
|
||||
function getDragBounds(width: number, height: number, padding: number) {
|
||||
const halfViewportW = window.innerWidth / 2;
|
||||
const halfViewportH = window.innerHeight / 2;
|
||||
const halfDialogW = width / 2;
|
||||
const halfDialogH = height / 2;
|
||||
|
||||
// x가 maxX일 때 다이얼로그 오른쪽 끝이 뷰포트 오른쪽 끝 - padding에 닿음
|
||||
const maxX = halfViewportW - padding - halfDialogW;
|
||||
const minX = -halfViewportW + padding + halfDialogW;
|
||||
const maxY = halfViewportH - padding - halfDialogH;
|
||||
const minY = -halfViewportH + padding + halfDialogH;
|
||||
|
||||
return { minX, maxX, minY, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 뷰포트 기준으로 리사이즈 최대 크기를 계산합니다.
|
||||
*/
|
||||
function getResizeBounds(minWidth: number, minHeight: number, padding: number) {
|
||||
const maxWidth = Math.max(minWidth, window.innerWidth - padding * 2);
|
||||
const maxHeight = Math.max(minHeight, window.innerHeight - padding * 2);
|
||||
return { maxWidth, maxHeight };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 메인 훅 (Main Hook)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Radix Dialog를 드래그/리사이즈 가능하게 만드는 훅입니다.
|
||||
* 사용처에서 DialogContent와 헤더, 리사이즈 핸들에 props를 연결합니다.
|
||||
*
|
||||
* 주요 개선 사항:
|
||||
* 1. `will-change: transform`으로 GPU 가속 렌더링
|
||||
* 2. 드래그/리사이즈 중 `transition: none`으로 버벅임 방지
|
||||
* 3. 뷰포트 경계 내 위치 보정으로 창 고정 현상 방지
|
||||
*/
|
||||
export function useDialogDragResize(
|
||||
options: DialogDragResizeOptions = {}
|
||||
): DialogDragResizeResult {
|
||||
const {
|
||||
enabled = true,
|
||||
open = true,
|
||||
draggable = true,
|
||||
resizable = true,
|
||||
initialWidth = DEFAULT_WIDTH,
|
||||
initialHeight = DEFAULT_HEIGHT,
|
||||
minWidth = MIN_WIDTH,
|
||||
minHeight = MIN_HEIGHT,
|
||||
viewportPadding = VIEWPORT_PADDING,
|
||||
} = options;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State & Refs
|
||||
// ---------------------------------------------------------------------------
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [size, setSize] = useState({ width: initialWidth, height: initialHeight });
|
||||
const [isInteracting, setIsInteracting] = useState(false);
|
||||
|
||||
// 드래그/리사이즈 시작 시점의 스냅샷을 저장하는 Ref
|
||||
const interactionRef = useRef({
|
||||
type: null as "drag" | "resize" | null,
|
||||
startPointerX: 0,
|
||||
startPointerY: 0,
|
||||
startPosX: 0,
|
||||
startPosY: 0,
|
||||
startWidth: 0,
|
||||
startHeight: 0,
|
||||
});
|
||||
|
||||
const isActive = enabled && open;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewport Bounds 보정 (다이얼로그 열릴 때 & 화면 크기 변경 시)
|
||||
// ---------------------------------------------------------------------------
|
||||
const applyViewportBounds = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const { maxWidth, maxHeight } = getResizeBounds(minWidth, minHeight, viewportPadding);
|
||||
|
||||
setSize((prevSize) => {
|
||||
const nextWidth = clamp(prevSize.width, minWidth, maxWidth);
|
||||
const nextHeight = clamp(prevSize.height, minHeight, maxHeight);
|
||||
|
||||
setPosition((prevPos) => {
|
||||
const { minX, maxX, minY, maxY } = getDragBounds(nextWidth, nextHeight, viewportPadding);
|
||||
return {
|
||||
x: clamp(prevPos.x, minX, maxX),
|
||||
y: clamp(prevPos.y, minY, maxY),
|
||||
};
|
||||
});
|
||||
|
||||
return { width: nextWidth, height: nextHeight };
|
||||
});
|
||||
}, [minHeight, minWidth, viewportPadding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
applyViewportBounds();
|
||||
}, [applyViewportBounds, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
window.addEventListener("resize", applyViewportBounds);
|
||||
return () => window.removeEventListener("resize", applyViewportBounds);
|
||||
}, [applyViewportBounds, isActive]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 드래그 핸들러 (Drag Handlers)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleDragStart = useCallback(
|
||||
(event: PointerEvent<HTMLElement>) => {
|
||||
if (!isActive || !draggable) return;
|
||||
event.preventDefault();
|
||||
|
||||
interactionRef.current = {
|
||||
type: "drag",
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startPosX: position.x,
|
||||
startPosY: position.y,
|
||||
startWidth: size.width,
|
||||
startHeight: size.height,
|
||||
};
|
||||
setIsInteracting(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
},
|
||||
[draggable, isActive, position.x, position.y, size.width, size.height]
|
||||
);
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
(event: PointerEvent<HTMLElement>) => {
|
||||
const ref = interactionRef.current;
|
||||
if (ref.type !== "drag") return;
|
||||
|
||||
const dx = event.clientX - ref.startPointerX;
|
||||
const dy = event.clientY - ref.startPointerY;
|
||||
const { minX, maxX, minY, maxY } = getDragBounds(ref.startWidth, ref.startHeight, viewportPadding);
|
||||
|
||||
setPosition({
|
||||
x: clamp(ref.startPosX + dx, minX, maxX),
|
||||
y: clamp(ref.startPosY + dy, minY, maxY),
|
||||
});
|
||||
},
|
||||
[viewportPadding]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((event: PointerEvent<HTMLElement>) => {
|
||||
if (interactionRef.current.type !== "drag") return;
|
||||
interactionRef.current.type = null;
|
||||
setIsInteracting(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 리사이즈 핸들러 (Resize Handlers)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleResizeStart = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isActive || !resizable) return;
|
||||
event.preventDefault();
|
||||
|
||||
interactionRef.current = {
|
||||
type: "resize",
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startPosX: position.x,
|
||||
startPosY: position.y,
|
||||
startWidth: size.width,
|
||||
startHeight: size.height,
|
||||
};
|
||||
setIsInteracting(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
},
|
||||
[isActive, position.x, position.y, resizable, size.width, size.height]
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
const ref = interactionRef.current;
|
||||
if (ref.type !== "resize") return;
|
||||
|
||||
const dx = event.clientX - ref.startPointerX;
|
||||
const dy = event.clientY - ref.startPointerY;
|
||||
|
||||
const { maxWidth, maxHeight } = getResizeBounds(minWidth, minHeight, viewportPadding);
|
||||
|
||||
const nextWidth = clamp(ref.startWidth + dx, minWidth, maxWidth);
|
||||
const nextHeight = clamp(ref.startHeight + dy, minHeight, maxHeight);
|
||||
|
||||
// 왼쪽 위 고정을 위해 크기 변화량의 절반만큼 중심 이동
|
||||
const dWidth = nextWidth - ref.startWidth;
|
||||
const dHeight = nextHeight - ref.startHeight;
|
||||
|
||||
const rawNextX = ref.startPosX + dWidth / 2;
|
||||
const rawNextY = ref.startPosY + dHeight / 2;
|
||||
|
||||
// 뷰포트 경계 보정
|
||||
const { minX, maxX, minY, maxY } = getDragBounds(nextWidth, nextHeight, viewportPadding);
|
||||
|
||||
setSize({ width: nextWidth, height: nextHeight });
|
||||
setPosition({
|
||||
x: clamp(rawNextX, minX, maxX),
|
||||
y: clamp(rawNextY, minY, maxY),
|
||||
});
|
||||
},
|
||||
[minHeight, minWidth, viewportPadding]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||
if (interactionRef.current.type !== "resize") return;
|
||||
interactionRef.current.type = null;
|
||||
setIsInteracting(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 스타일 및 Props 생성
|
||||
// ---------------------------------------------------------------------------
|
||||
const contentStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
if (!isActive) return undefined;
|
||||
return {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
transform: `translate(-50%, -50%) translate(${position.x}px, ${position.y}px)`,
|
||||
// GPU 가속을 위해 will-change 힌트 제공
|
||||
willChange: isInteracting ? "transform, width, height" : "auto",
|
||||
};
|
||||
}, [isActive, isInteracting, position.x, position.y, size.height, size.width]);
|
||||
|
||||
const contentClassName = isActive
|
||||
? cn(
|
||||
"sm:max-w-none",
|
||||
// 인터랙션 중에는 모든 트랜지션을 끄고, 텍스트 선택을 막습니다.
|
||||
isInteracting && "transition-none duration-0 select-none"
|
||||
)
|
||||
: "";
|
||||
|
||||
const dragHandleClassName = isActive && draggable ? "cursor-move select-none touch-none" : "";
|
||||
const resizeHandleClassName = isActive && resizable ? "cursor-se-resize touch-none" : "";
|
||||
|
||||
const dragHandleProps = useMemo<HTMLAttributes<HTMLElement>>(() => {
|
||||
if (!isActive || !draggable) return {};
|
||||
return {
|
||||
onPointerDown: handleDragStart,
|
||||
onPointerMove: handleDragMove,
|
||||
onPointerUp: handleDragEnd,
|
||||
onPointerCancel: handleDragEnd,
|
||||
};
|
||||
}, [draggable, handleDragEnd, handleDragMove, handleDragStart, isActive]);
|
||||
|
||||
const resizeHandleProps = useMemo<HTMLAttributes<HTMLDivElement>>(() => {
|
||||
if (!isActive || !resizable) return {};
|
||||
return {
|
||||
onPointerDown: handleResizeStart,
|
||||
onPointerMove: handleResizeMove,
|
||||
onPointerUp: handleResizeEnd,
|
||||
onPointerCancel: handleResizeEnd,
|
||||
};
|
||||
}, [handleResizeEnd, handleResizeMove, handleResizeStart, isActive, resizable]);
|
||||
|
||||
return {
|
||||
contentStyle,
|
||||
contentClassName,
|
||||
dragHandleProps,
|
||||
dragHandleClassName,
|
||||
resizeHandleProps,
|
||||
resizeHandleClassName,
|
||||
isDraggable: isActive && draggable,
|
||||
isResizable: isActive && resizable,
|
||||
};
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
24
src/providers/QueryProvider.tsx
Normal file
24
src/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
// React Query Provider를 앱 전역에 주입하는 클라이언트 컴포넌트입니다.
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import React from "react";
|
||||
import { getQueryClient } from "@/lib/getQueryClient";
|
||||
|
||||
export default function QueryProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// 싱글톤 QueryClient를 사용해 캐시를 공유합니다.
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{/* 개발 환경에서만 React Query 상태를 확인하는 도구입니다. */}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,7 +15,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -19,7 +23,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -30,5 +36,7 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user