diff --git a/.gitignore b/.gitignore
index b583fb3d..c155892e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
-.env
+.env.local
.env.local
# vercel
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..98791984
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# 디폴트 무시된 파일
+/shelf/
+/workspace.xml
+# 쿼리 파일을 포함한 무시된 디폴트 폴더
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# 에디터 기반 HTTP 클라이언트 요청
+/httpRequests/
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 00000000..932f7d1b
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 00000000..79ee123c
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..03d9549e
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..47478b91
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..0e293e75
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
new file mode 100644
index 00000000..b0c1c68f
--- /dev/null
+++ b/.idea/prettier.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/study-platform-client.iml b/.idea/study-platform-client.iml
new file mode 100644
index 00000000..d6ebd480
--- /dev/null
+++ b/.idea/study-platform-client.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 289592f6..8a8f4274 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,8 +6,8 @@ WORKDIR /app
COPY . .
-# 빌드시점에 .env 파일 복사
-COPY .env .env
+# 빌드시점에 .env.local 파일 복사
+COPY .env.local .env
RUN yarn install && yarn build
@@ -22,7 +22,7 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
-# 런타임시점에도 .env 파일 복사
+# 런타임시점에도 .env.local 파일 복사
COPY --from=builder /app/.env .env
# devDependencies는 설치하지 않고 dependencies만 설치
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 84b453b0..9eb4a8f1 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -13,5 +13,5 @@ services:
ports:
- '3000:3000'
env_file:
- - .env
+ - .env.local
restart: unless-stopped
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 49097f14..92029bfd 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -13,5 +13,5 @@ services:
ports:
- '3000:3000'
env_file:
- - .env
+ - .env.local
restart: unless-stopped
diff --git a/docs/archive-refactor-actions-plan.md b/docs/archive-refactor-actions-plan.md
new file mode 100644
index 00000000..d960806a
--- /dev/null
+++ b/docs/archive-refactor-actions-plan.md
@@ -0,0 +1,40 @@
+# 아카이브 리팩토링 계획 (행동 단위 훅 도입)
+
+작성일: 2026-01-30
+
+## 목표
+
+- UI가 “행동 판단/순서/부수효과”를 직접 처리하지 않도록 분리
+- Admin처럼 **의미 있는 행동 단위 훅**으로 책임 이동
+
+## 문제점 (현재)
+
+- UI에서 직접 API 훅 호출 + 순서 결정
+ - 예: 링크 열기 → 조회수 기록
+- UI가 도메인 행동의 오케스트레이션까지 담당
+
+## 계획
+
+### 1) 행동 훅 추가
+
+- `features/archive/model/use-archive-actions.ts`
+- 제공 기능
+ - `toggleLike(id)`
+ - `toggleBookmark(id)`
+ - `openAndRecordView(item)` (window.open + view 기록)
+ - (옵션) `hide(item)`는 TODO 자리 유지
+
+### 2) UI 수정
+
+- `archive-tab-client.tsx`에서 개별 mutation 훅 제거
+- 대신 `useArchiveActions`로 통합
+
+### 3) 책임 정리
+
+- UI는 이벤트 발생만 전달
+- 순서/부수효과/실패 처리 정책은 훅 내부
+
+## 완료 기준
+
+- UI에서 `useToggleArchiveLike/Bookmark/useRecordArchiveView` 직접 호출 제거
+- 행동 훅을 통한 단일 인터페이스 사용
diff --git a/docs/archive-refactor-plan.md b/docs/archive-refactor-plan.md
new file mode 100644
index 00000000..f65aa06d
--- /dev/null
+++ b/docs/archive-refactor-plan.md
@@ -0,0 +1,132 @@
+# 아카이브 리팩토링 계획 (Admin 구조 기준 반성 포인트 정리)
+
+작성일: 2026-01-30
+
+## 배경
+
+Admin 영역은 **서버/클라이언트 경계**, **API 분리**, **모델/타입/상수 정리**가 비교적 명확함.
+반면 아카이브는 기능/데이터/상태가 한 파일에 뭉쳐 있고, 서버 프리패치와 클라이언트 상호작용의 경계도 흐림.
+
+이 문서는 **Admin 구조를 기준으로 아카이브에서 반성할 포인트**를 추리고, 구체적인 리팩토링 계획을 정의한다.
+
+---
+
+## 1. Admin 구조에서 배워야 할 기준 (반성 포인트)
+
+### A. 서버/클라이언트 분리 원칙
+
+- Admin은 `*.server.ts` API와 클라이언트 API가 분리되어 있음.
+- 아카이브도 `get-archive.server.ts`가 있지만, **UI/모델에서 초기 데이터 주입 전략이 불명확**함.
+
+### B. 모델 레이어의 역할 분리
+
+- Admin은 Query/Mutation 훅이 **목적별 파일**로 분리되어 있음.
+- 아카이브는 목록/북마크/좋아요/조회수가 각각 흩어져 있고, **UI에서 직접 조합하는 책임이 과도**함.
+
+### C. 상수/타입 분리
+
+- Admin은 `const/member.ts`로 옵션/매핑을 분리.
+- 아카이브는 옵션/필터 값, 정렬 타입, UI 상태 등이 **컴포넌트 내부에 고정**됨.
+
+### D. 컴포넌트 분해 수준
+
+- Admin은 테이블/모달/필터 UI가 **작게 분해**되어 있음.
+- 아카이브는 `archive-tab-client.tsx` 단일 파일에 UI/상태/이벤트/데이터 로직이 함께 있음.
+
+---
+
+## 2. 현재 아카이브 문제점 (구체)
+
+### 1) UI + 데이터 + 상태가 단일 파일에 집중
+
+- `src/features/archive/ui/archive-tab-client.tsx`가
+ - 필터/검색/정렬 상태
+ - API 훅 호출
+ - 테이블/카드 뷰 렌더
+ - 이벤트 처리 로직
+ 모두 포함하고 있음.
+
+### 2) 서버 프리패치가 구조적으로 반영되지 않음
+
+- 서버 래퍼에서 initialData를 주입은 했지만
+ - 조건 분기 로직이 컴포넌트 내부에 단편적으로 흩어져 있음
+ - 향후 param 증가 시 유지보수 어려움
+
+### 3) 도메인 규칙이 하드코딩됨
+
+- 정렬 옵션, 페이지 크기, UI 문구가 직접 박혀 있어
+ - 변경 시 여러 위치 수정 필요
+ - 테스트/재사용 어려움
+
+---
+
+## 3. 리팩토링 목표
+
+1. **Admin 수준의 구조 분리 달성**
+2. **서버-클라이언트 경계 명확화**
+3. **도메인 규칙(옵션/상수) 분리**
+4. **UI 컴포넌트 재사용 가능 단위로 분해**
+
+---
+
+## 4. 리팩토링 상세 계획
+
+### Step 1) UI 구조 분리
+
+- `archive-tab-client.tsx`를 아래 구성으로 분해
+
+```
+features/archive/ui/
+ archive-tab-client.tsx // 컨테이너 (상태/데이터/조합)
+ archive-header.tsx // 상단 타이틀/관리자 토글
+ archive-filters.tsx // 필터/검색/정렬 UI
+ archive-grid.tsx // 카드 뷰
+ archive-list.tsx // 리스트 뷰
+```
+
+### Step 2) 상수/옵션 분리
+
+- `features/archive/const/archive.ts` 신설
+ - 정렬 옵션 리스트
+ - 기본 page size
+ - UI 텍스트 상수
+
+### Step 3) 모델 레이어 정리
+
+- `use-archive-query.ts`는 유지하되
+ - 파라미터 생성 로직을 **hooks/util로 분리**
+- Mutations도 목적별로 **use-xxx-mutation.ts**에 명확한 책임 유지
+
+### Step 4) 서버 프리패치 표준화
+
+- 서버 래퍼에서 `initialParams` 계산/보존
+- 클라이언트는 `params` 변경 시에만 재조회하도록
+ - 비교 함수를 util로 분리
+
+### Step 5) 타입베이스드 정리
+
+- 현재 `src/types/archive.ts`가 존재하므로
+ - Archive 전용 타입은 여기로 유지
+ - API layer에서는 `ArchiveResponse`만 사용
+
+---
+
+## 5. 체크리스트
+
+- [ ] `archive-tab-client.tsx` 분해 완료
+- [ ] `features/archive/const/archive.ts` 추가
+- [ ] 서버 초기 데이터 주입 흐름 문서화
+- [ ] UI/데이터 로직 분리된 상태 확인
+
+---
+
+## 6. 결과 기대 효과
+
+- 변경 범위가 줄어듦 (UI/데이터 분리)
+- 유지보수 용이
+- 서버 프리패치 일관성 확보
+- Admin과 유사한 코드 품질 체계 확보
+
+---
+
+필요하면 위 계획에 맞춰 바로 리팩토링 진행 가능.
diff --git a/docs/backend-api-change-request-study-history.md b/docs/backend-api-change-request-study-history.md
new file mode 100644
index 00000000..da425001
--- /dev/null
+++ b/docs/backend-api-change-request-study-history.md
@@ -0,0 +1,212 @@
+# 백엔드 API 명세 변경 요청서
+
+## 요청 일자
+
+2024년 (작성 시점 기준)
+
+## 요청 항목
+
+1대1 스터디 기록 조회 API (`GET /api/v1/study/daily/history`) 관련 명세 확인 및 변경 요청
+
+---
+
+## 1. 변경 배경
+
+프론트엔드에서 "나의 1대1 스터디 기록" 페이지의 UI 개선을 위해 다음 변경이 필요합니다:
+
+- 테이블 컬럼명 변경: "출석" → "역할수행여부"
+- 테이블에 "진행상태" 컬럼 추가
+
+---
+
+## 2. 현재 API 사용 현황
+
+### 2.1 API 엔드포인트
+
+```
+GET /api/v1/study/daily/history
+```
+
+### 2.2 요청 파라미터
+
+```typescript
+{
+ page?: number; // 페이지 번호 (0부터 시작)
+ size?: number; // 페이지 크기
+ startDate?: string; // 시작 날짜 (YYYY-MM-DD)
+ endDate?: string; // 종료 날짜 (YYYY-MM-DD)
+ sort?: string; // 정렬 기준 (예: "createdAt,desc")
+}
+```
+
+### 2.3 현재 응답 구조 (프론트엔드 기대값)
+
+```typescript
+{
+ statusCode: number;
+ timestamp: string;
+ content: {
+ content: StudyHistoryContent[];
+ totalElements: number;
+ totalPages: number;
+ // ... 기타 페이지네이션 정보
+ };
+ message: string;
+}
+
+interface StudyHistoryContent {
+ studyId: number;
+ title: string;
+ scheduledAt: string; // ISO Date String
+ status: 'COMPLETE' | 'IN_PROGRESS' | 'PENDING'; // 스터디 진행 상태
+ studyLink: string | null;
+ participation: {
+ role: 'INTERVIEWER' | 'INTERVIEWEE'; // 내 역할
+ attendance: 'PRESENT' | 'PENDING' | 'ABSENT'; // 역할 수행 여부
+ };
+ partner: {
+ memberId: number;
+ nickname: string;
+ profileImageUrl: string | null;
+ };
+}
+```
+
+---
+
+## 3. 요청 사항
+
+### 3.1 필드명 및 값 정의 확인 요청
+
+현재 프론트엔드에서 사용 중인 필드들의 명확한 정의를 확인하고 싶습니다:
+
+#### 3.1.1 `participation.attendance` 필드
+
+- **현재 사용 값**: `PRESENT`, `PENDING`, `ABSENT`
+- **요청 사항**:
+ - 각 값의 정확한 의미 확인
+ - `PRESENT`: 역할을 수행했는지 여부를 나타내는가?
+ - `PENDING`: 아직 진행 전인 상태인가?
+ - `ABSENT`: 불참 상태인가?
+- **프론트엔드 표시**:
+ - `PRESENT` → "역할수행" (성공 아이콘)
+ - `PENDING` 또는 `ABSENT` → "미진행" (경고 아이콘)
+
+#### 3.1.2 `status` 필드
+
+- **현재 사용 값**: `COMPLETE`, `IN_PROGRESS`, `PENDING`
+- **요청 사항**:
+ - 각 값의 정확한 의미 확인
+ - `COMPLETE`: 스터디가 완전히 종료된 상태인가?
+ - `IN_PROGRESS`: 스터디가 진행 중인 상태인가?
+ - `PENDING`: 스터디가 아직 시작되지 않은 상태인가?
+- **프론트엔드 표시**:
+ - `COMPLETE` → "완료" (성공 아이콘)
+ - `IN_PROGRESS` → "진행중" (로딩 아이콘)
+ - `PENDING` → "대기중" (시계 아이콘) - 필요시 추가
+
+### 3.2 API 명세 문서화 요청
+
+현재 `GET /api/v1/study/daily/history` API가 OpenAPI 스펙에 포함되어 있지 않은 것으로 보입니다.
+다음 내용을 포함한 명세 문서화를 요청합니다:
+
+1. **엔드포인트 정보**
+ - 경로: `/api/v1/study/daily/history`
+ - 메서드: `GET`
+ - 인증: Bearer Token 필요
+
+2. **요청 파라미터 상세**
+ - 각 파라미터의 타입, 필수 여부, 설명
+ - 기본값 (있는 경우)
+
+3. **응답 스키마 상세**
+ - 각 필드의 타입 및 설명
+ - Enum 값들의 의미
+ - 예시 응답
+
+4. **에러 응답**
+ - 가능한 에러 코드 및 메시지
+
+---
+
+## 4. 프론트엔드 변경 사항
+
+### 4.1 UI 변경
+
+- ✅ 테이블 헤더: "출석" → "역할수행여부"
+- ✅ 테이블 행: "출석" 텍스트 → "역할수행" 텍스트
+- ✅ 테이블에 "진행상태" 컬럼 추가
+
+### 4.2 데이터 매핑
+
+```typescript
+// API 응답 → UI 표시
+attendance: 'PRESENT' → "역할수행" (성공 아이콘)
+attendance: 'PENDING' | 'ABSENT' → "미진행" (경고 아이콘)
+
+status: 'COMPLETE' → "완료" (성공 아이콘)
+status: 'IN_PROGRESS' → "진행중" (로딩 아이콘)
+status: 'PENDING' → "대기중" (시계 아이콘) - 필요시
+```
+
+---
+
+## 5. 확인 필요 사항
+
+1. **`attendance`와 `status`의 차이점**
+ - `attendance`: 사용자가 자신의 역할을 수행했는지 여부
+ - `status`: 스터디 전체의 진행 상태
+ - 이 이해가 맞는지 확인 필요
+
+2. **값의 일관성**
+ - `attendance`의 `PENDING`과 `status`의 `PENDING`이 같은 의미인지?
+ - 각 필드가 언제 어떤 값으로 설정되는지?
+
+3. **추가 필드 필요 여부**
+ - 현재 응답 구조로 UI 요구사항을 충족할 수 있는지 확인
+ - 추가로 필요한 필드가 있는지 검토
+
+---
+
+## 6. 예상 일정
+
+- 프론트엔드 UI 변경: ✅ 완료
+- 백엔드 API 명세 확인 및 문서화: 백엔드 팀 협의 필요
+- 통합 테스트: API 명세 확인 후 진행
+
+---
+
+## 7. 문의 사항
+
+백엔드 팀에 다음 사항을 확인 요청드립니다:
+
+1. `GET /api/v1/study/daily/history` API의 정확한 명세서 제공
+2. `attendance`와 `status` 필드의 값 및 의미 명확화
+3. OpenAPI 스펙에 해당 API 추가 여부
+4. 추가로 필요한 필드나 변경 사항이 있는지 확인
+
+---
+
+## 8. 참고 자료
+
+### 프론트엔드 파일 위치
+
+- 컴포넌트: `src/components/study-history/study-history-row.tsx`
+- 탭 컴포넌트: `src/components/home/tabs/study-history-tab.tsx`
+- 타입 정의: `src/types/study-history.ts`
+- API 호출: `src/features/study/history/api/get-my-study-history.ts`
+
+### 현재 사용 중인 타입 정의
+
+```typescript
+// src/types/study-history.ts
+export type StudyRole = 'INTERVIEWER' | 'INTERVIEWEE';
+export type AttendanceStatus = 'PRESENT' | 'PENDING' | 'ABSENT';
+export type StudyStatus = 'COMPLETE' | 'IN_PROGRESS' | 'PENDING';
+```
+
+---
+
+## 작성자
+
+프론트엔드 개발팀
diff --git a/docs/server-first-home-tabs-plan.md b/docs/server-first-home-tabs-plan.md
new file mode 100644
index 00000000..6a97518b
--- /dev/null
+++ b/docs/server-first-home-tabs-plan.md
@@ -0,0 +1,85 @@
+# 서버 우선 렌더링 전환 계획 (홈 탭: 밸런스게임/나의스터디기록/명예의전당/아카이브)
+
+작성일: 2026-01-30
+
+## 목표
+
+- **데이터 fetch·계산·조합은 서버에서 완료**
+- 브라우저에는 **UI 껍데기 + 인터랙션만** 전달
+- App Router의 서버 경계를 명확히 유지 (`page.tsx`는 서버 컴포넌트)
+
+## 현재 문제 요약
+
+- `app` 영역에서 `use client`가 상위에 위치해 **서버 컴포넌트 이점 상실**
+- 홈 탭(밸런스게임/나의스터디기록/명예의전당/아카이브)의 데이터 fetch가 **클라이언트 훅 중심**
+- 결과적으로 SSR/SEO/초기 로딩 이점이 약화
+
+## 변경 방향
+
+1. **`app` 영역은 서버 컴포넌트 유지**
+2. **클라이언트 컴포넌트는 UI/인터랙션 전용**
+3. **서버에서 초기 데이터 fetch 후 props로 전달**
+4. 클라이언트는 **상호작용(필터, 무한스크롤)만 담당**
+
+## 단계별 작업 계획
+
+### 1) 홈 탭 렌더 경계 정리 (완료)
+
+- `src/app/(service)/home/page.tsx`에서 `searchParams`로 activeTab 결정
+- `src/app/(service)/home/home-content.tsx`를 서버 컴포넌트로 변경
+- 클라이언트 로직은 `TabNavigation` 등 UI로 제한
+
+### 2) 밸런스게임(community) 서버 프리패치 구조로 전환
+
+- **서버 컴포넌트**: 초기 목록 fetch
+- **클라이언트 컴포넌트**: 필터/무한스크롤/모달/작성 동작 담당
+- 작업 항목
+ - 서버용 API 함수 분리 (`features/balance-game/api/*.server.ts`)
+ - `BalanceGamePage`에 `initialData` props 적용
+ - `useBalanceGameListQuery`에 `initialData` 주입
+
+### 3) 나의 스터디 기록(server first)
+
+- 서버에서 조회 후 리스트/캘린더 초기 데이터 렌더
+- 클라이언트는 viewMode/페이지 변경만 처리
+- 작업 항목
+ - `features/study/history/api/*.server.ts`
+ - `StudyHistoryTab`를 서버 wrapper + client view로 분리
+
+### 4) 명예의 전당(server first)
+
+- 서버에서 랭킹 데이터 fetch
+- 클라이언트는 탭/필터 전환만 담당
+- 작업 항목
+ - `features/hall-of-fame/api/*.server.ts`
+ - `HallOfFameTab` 분리 (Server wrapper + Client view)
+
+### 5) 아카이브(server first)
+
+- 서버에서 초기 리스트 fetch
+- 클라이언트는 정렬/검색/북마크/좋아요 인터랙션 담당
+- 작업 항목
+ - `features/archive/api/*.server.ts`
+ - `ArchiveTab` 분리 (Server wrapper + Client view)
+
+## 완료 기준
+
+- `app` 내부 페이지에 `use client` 없음
+- 초기 렌더에 필요한 데이터는 서버에서 준비
+- 클라이언트 fetch는 **추가 상호작용에 한정**
+
+## 체크리스트
+
+- [ ] `rg "use client" src/app` 에서 해당 섹션 page.tsx 제거 확인
+- [ ] 홈 탭 첫 진입 시 SSR 데이터 렌더 확인
+- [ ] 탭 전환/필터/무한스크롤 정상 동작
+- [ ] 네트워크 탭에서 초기 fetch가 서버에서 수행되는지 확인
+
+## 참고 파일(현재 구조)
+
+- `src/app/(service)/home/page.tsx`
+- `src/app/(service)/home/home-content.tsx`
+- `src/components/home/tabs/community-tab.tsx`
+- `src/components/home/tabs/study-history-tab.tsx`
+- `src/components/home/tabs/hall-of-fame-tab.tsx`
+- `src/components/home/tabs/archive-tab.tsx`
diff --git a/docs/type-based-migration-plan-home-features.md b/docs/type-based-migration-plan-home-features.md
new file mode 100644
index 00000000..6cae839d
--- /dev/null
+++ b/docs/type-based-migration-plan-home-features.md
@@ -0,0 +1,116 @@
+# Type-based 정리 계획 (밸런스게임/나의스터디기록/명예의전당/아카이브)
+
+작성일: 2026-01-30
+
+## 0. 현재 코드 상태 요약 (다른 팀원 코드 패턴)
+
+- 타입베이스드로 이미 쓰는 패턴
+ - `src/components/home/tabs/archive-tab.tsx` → `src/types/archive`
+ - `src/components/home/tabs/study-history-tab.tsx` → `src/types/study-history`
+- 아직 feature 타입을 쓰는 패턴
+ - `src/components/home/tabs/community-tab.tsx` → `src/features/balance-game/types`
+ - `src/components/home/tabs/hall-of-fame-tab.tsx` → `src/features/hall-of-fame/types`
+
+즉, **팀 코드도 현재 혼재 상태**이며, 타입베이스드 기준으로는 Balance Game + Hall of Fame 쪽이 정리 대상임.
+
+## 1. 목표
+
+- 홈 탭 관련 주요 도메인에서 **타입 정의를 `src/types`로 통일**
+- 동일 의미 타입의 중복 정의 제거
+- import 경로 일관화
+
+## 2. 범위
+
+- 밸런스게임 (`home?tab=community`, `insights/weekly` 포함)
+- 나의 스터디 기록 (`home?tab=history`)
+- 명예의 전당 (`home?tab=hall-of-fame`)
+- 아카이브 (`home?tab=archive`)
+
+## 3. 작업 원칙
+
+- 타입은 기능 폴더가 아닌 **프로젝트 공용 타입으로 취급**
+- UI/Model/API 어디서든 동일 타입 재사용
+- 타입명 충돌 시 **의미 중심 명명**으로 통일
+
+## 4. 세부 작업 계획
+
+### A. Balance Game 타입 정리
+
+현재:
+
+- `src/features/balance-game/types.ts`
+- `src/types/voting.ts` (투표 관련 타입 존재)
+
+선택지 (둘 중 하나 결정 필요):
+
+1. `src/types/balance-game.ts` 신설 및 이동
+ - 기존 `src/features/balance-game/types.ts` 내용을 복사/이동
+ - 기존 `src/types/voting.ts`는 유지 (단, 의미 분리)
+2. `src/types/voting.ts`로 통합
+ - Balance Game 타입을 voting 기준으로 통합
+ - 중복/이름 불일치 정리 필요
+
+**추천:** 1)로 분리하여 명확히 구분 (BalanceGame vs Voting 개념 혼재 방지)
+
+작업 항목:
+
+- `src/features/balance-game/types.ts` → `src/types/balance-game.ts`
+- 아래 사용처 import 변경
+ - `src/components/home/tabs/community-tab.tsx`
+ - `src/app/(service)/insights/weekly/page.tsx`
+ - `src/components/card/voting-card.tsx`
+ - `src/components/voting/voting-edit-modal.tsx`
+ - `src/components/voting/voting-detail-view.tsx`
+ - `src/components/discussion/comment-list.tsx` (BalanceGameComment 사용 시)
+- `src/features/balance-game/api/balance-game-api.ts` 내부 타입 import도 변경
+- 기존 feature 타입 파일 삭제
+
+### B. Hall of Fame 타입 정리
+
+현재:
+
+- `src/features/hall-of-fame/types.ts`
+
+작업 항목:
+
+- `src/features/hall-of-fame/types.ts` → `src/types/hall-of-fame.ts`
+- 사용처 import 변경
+ - `src/components/home/tabs/hall-of-fame-tab.tsx`
+ - `src/features/hall-of-fame/api/hall-of-fame-api.ts`
+ - `src/features/hall-of-fame/model/use-hall-of-fame-query.ts`
+- 기존 feature 타입 파일 삭제
+
+### C. Study History / Archive 확인
+
+- 이미 `src/types` 사용 중
+- 변경 필요 없음
+- 단, 신규 타입이 생길 때는 `src/types`로 합류하는지 체크
+
+## 5. 검증 체크리스트
+
+- `rg "features/(balance-game|hall-of-fame)/types" src` 결과 0건
+- `rg "@/types/(balance-game|hall-of-fame)" src` 사용처 정상
+- 빌드/타입체크 통과
+- 홈 탭 화면 동작 확인
+ - community (밸런스게임 생성/리스트)
+ - history (기록 리스트/캘린더)
+ - hall-of-fame (랭킹/팀)
+ - archive (리스트/북마크/좋아요)
+
+## 6. 위험 요소 / 주의사항
+
+- BalanceGame vs Voting 네이밍 혼재
+ - 이미 `src/types/voting.ts`가 있음 → 통합 여부 결정 필요
+- 타입 중복 정의로 인해 API 응답 형태가 달라 보일 수 있음
+ - `ApiResponse` 같은 래퍼 타입은 한 곳에서만 관리 권장
+
+## 7. 예상 작업 순서
+
+1. Balance Game 타입 이동 + import 정리
+2. Hall of Fame 타입 이동 + import 정리
+3. `rg`로 feature 타입 잔존 여부 확인
+4. 기본 화면 동작 수동 확인
+
+---
+
+필요하면 위 계획을 기준으로 바로 작업 진행 가능합니다.
diff --git a/package.json b/package.json
index c6c9358f..234db76c 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"embla-carousel-react": "^8.6.0",
+ "framer-motion": "^12.27.1",
"googleapis": "^164.1.0",
"lucide-react": "^0.475.0",
"next": "15.2.8",
@@ -106,8 +107,7 @@
"typescript-eslint": "^8.24.0",
"vitest": "^3.1.1"
},
- "resolutions": {
+ "resolutions": {
"strip-ansi": "6.0.1"
}
-
}
diff --git a/src/app/(service)/home/home-content.tsx b/src/app/(service)/home/home-content.tsx
new file mode 100644
index 00000000..b20f2849
--- /dev/null
+++ b/src/app/(service)/home/home-content.tsx
@@ -0,0 +1,45 @@
+import { Suspense } from 'react';
+import TabNavigation from '@/components/home/tab-navigation';
+import ArchiveTab from '@/features/study/one-to-one/archive/ui/archive-tab';
+import CommunityTab from '@/features/study/one-to-one/balance-game/ui/community-tab';
+import HallOfFameTab from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab';
+import StudyHistoryTab from '@/features/study/one-to-one/history/ui/study-history-tab';
+import StudyTab from '@/features/study/one-to-one/schedule/ui/home-study-tab';
+
+interface HomeContentProps {
+ activeTab: string;
+}
+
+export default function HomeContent({ activeTab }: HomeContentProps) {
+ const renderTabContent = () => {
+ switch (activeTab) {
+ case 'study':
+ return ;
+ case 'history':
+ return ;
+ case 'ranking':
+ return ;
+ case 'archive':
+ return ;
+ case 'community':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+ <>
+
+
+ 로딩 중...
+
+ }
+ >
+ {renderTabContent()}
+
+ >
+ );
+}
diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx
index 0e2b8815..e9164fc8 100644
--- a/src/app/(service)/home/page.tsx
+++ b/src/app/(service)/home/page.tsx
@@ -1,8 +1,7 @@
import { Metadata } from 'next';
-import StudyCard from '@/features/study/schedule/ui/study-card';
import { generateMetadata as generateSEOMetadata } from '@/utils/seo';
-import Banner from '@/widgets/home/banner';
import Sidebar from '@/widgets/home/sidebar';
+import HomeContent from './home-content';
export const metadata: Metadata = generateSEOMetadata({
title: '홈 - ZERO-ONE',
@@ -13,15 +12,22 @@ export const metadata: Metadata = generateSEOMetadata({
canonicalUrl: 'https://www.zeroone.it.kr/home',
});
-export default async function Home() {
+export default async function Home({
+ searchParams,
+}: {
+ searchParams?: Promise<{ tab?: string }>;
+}) {
+ const resolvedSearchParams = await searchParams;
+ const activeTab = resolvedSearchParams?.tab || 'study';
+
return (
-
-
+ {/* 기존 기능을 100% 보존하면서 새로운 탭 시스템 추가 */}
+
-
diff --git a/src/app/(service)/insights/page.tsx b/src/app/(service)/insights/page.tsx
index c725e109..77399877 100644
--- a/src/app/(service)/insights/page.tsx
+++ b/src/app/(service)/insights/page.tsx
@@ -92,6 +92,17 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
{category.name}
))}
+
+
+ 위클리
+
+ NEW
+
+
+
{/* 아티클 목록 */}
diff --git a/src/app/(service)/insights/weekly/[id]/page.tsx b/src/app/(service)/insights/weekly/[id]/page.tsx
new file mode 100644
index 00000000..24c45459
--- /dev/null
+++ b/src/app/(service)/insights/weekly/[id]/page.tsx
@@ -0,0 +1,12 @@
+import VotingDetailPageClient from '@/features/study/one-to-one/balance-game/ui/voting-detail-page-client';
+
+export default async function VotingDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const votingId = Number(id);
+
+ return ;
+}
diff --git a/src/app/(service)/insights/weekly/page.tsx b/src/app/(service)/insights/weekly/page.tsx
new file mode 100644
index 00000000..0b380169
--- /dev/null
+++ b/src/app/(service)/insights/weekly/page.tsx
@@ -0,0 +1,5 @@
+import BalanceGamePage from '@/features/study/one-to-one/balance-game/ui/balance-game-page';
+
+export default function VotingPage() {
+ return ;
+}
diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx
index 8954c45e..41b1a551 100644
--- a/src/app/(service)/layout.tsx
+++ b/src/app/(service)/layout.tsx
@@ -42,9 +42,9 @@ export default function ServiceLayout({
-
+
- {children}
+ {children}
diff --git a/src/app/(service)/one-on-one/page.tsx b/src/app/(service)/one-on-one/page.tsx
new file mode 100644
index 00000000..8facbe95
--- /dev/null
+++ b/src/app/(service)/one-on-one/page.tsx
@@ -0,0 +1,5 @@
+import OneOnOnePage from '@/features/study/one-to-one/ui/one-on-one-page';
+
+export default function OneOnOnePageRoute() {
+ return
;
+}
diff --git a/src/components/card/discussion-card.tsx b/src/components/card/discussion-card.tsx
new file mode 100644
index 00000000..b16283b1
--- /dev/null
+++ b/src/components/card/discussion-card.tsx
@@ -0,0 +1,135 @@
+import { formatDistanceToNow } from 'date-fns';
+import { ko } from 'date-fns/locale';
+import { MessageCircle, ThumbsUp, ThumbsDown, Eye, Clock } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { TOPIC_LABELS } from '@/mocks/discussion-mock-data';
+import { Discussion } from '@/types/discussion';
+
+interface DiscussionCardProps {
+ discussion: Discussion;
+ onClick?: () => void;
+}
+
+export default function DiscussionCard({
+ discussion,
+ onClick,
+}: DiscussionCardProps) {
+ const timeAgo = formatDistanceToNow(new Date(discussion.lastActivityAt), {
+ addSuffix: true,
+ locale: ko,
+ });
+
+ return (
+
+ {/* 헤더: 작성자 정보 & 주제 */}
+
+
+ {/* 아바타 & 닉네임 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {discussion.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 시간 */}
+
+
+
+ {/* 주제 배지 */}
+
+ {TOPIC_LABELS[discussion.topic]}
+
+
+
+ {/* 제목 */}
+
+ {discussion.title}
+
+
+ {/* 요약 */}
+
+ {discussion.summary}
+
+
+ {/* 태그 */}
+ {discussion.tags.length > 0 && (
+
+ {discussion.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* 하단 메타 정보 */}
+
+
+ {/* 찬성 */}
+
+
+ {discussion.vote.agreeCount}
+
+
+ {/* 반대 */}
+
+
+ {discussion.vote.disagreeCount}
+
+
+ {/* 댓글 */}
+
+
+ {discussion.commentCount}
+
+
+ {/* 조회수 */}
+
+
+ {discussion.viewCount.toLocaleString()}
+
+
+
+
+ );
+}
diff --git a/src/components/card/voting-card.tsx b/src/components/card/voting-card.tsx
new file mode 100644
index 00000000..81b2b836
--- /dev/null
+++ b/src/components/card/voting-card.tsx
@@ -0,0 +1,133 @@
+import { MessageCircle, Users } from 'lucide-react';
+import Link from 'next/link';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { BalanceGame } from '@/types/balance-game';
+import VoteTimer from '../voting/vote-timer';
+
+interface VotingCardProps {
+ voting: BalanceGame;
+ onClick?: () => void;
+}
+
+export default function VotingCard({ voting, onClick }: VotingCardProps) {
+ const topOption = voting.options.reduce((prev, current) =>
+ prev.percentage > current.percentage ? prev : current,
+ );
+
+ // myVote can be null or number (optionId)
+ const hasVoted = voting.myVote !== undefined && voting.myVote !== null;
+
+ const cardContent = (
+ {
+ e.preventDefault();
+ onClick();
+ }
+ : undefined
+ }
+ >
+ {/* 헤더: 작성자 & 상태 */}
+
+ {/* 작성자 정보 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {voting.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 타이머 표시 */}
+
+
+
+ {/* 제목 */}
+
+ {voting.title}
+
+
+ {/* 태그 - 제목 바로 아래에 표시 */}
+ {voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && (
+
+ {voting.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* 설명 (있으면) */}
+ {voting.description && (
+
+ {voting.description}
+
+ )}
+
+ {/* 간단한 투표 결과 미리보기 (투표했을 때만) */}
+ {hasVoted && (
+
+
+ 현재 1위
+
+
+
+ {topOption.label}
+
+
+ {topOption.percentage.toFixed(1)}%
+
+
+
+ )}
+
+ {/* 하단 메타 정보 */}
+
+ {/* 총 투표 수 */}
+
+
+ {voting.totalVotes.toLocaleString()}
+
+
+ {/* 댓글 수 */}
+
+
+ {voting.commentCount || 0}
+
+
+
+ );
+
+ // onClick이 있으면 Link 없이 렌더링, 없으면 Link로 감싸기
+ if (onClick) {
+ return cardContent;
+ }
+
+ return {cardContent};
+}
diff --git a/src/components/discussion/comment-form.tsx b/src/components/discussion/comment-form.tsx
new file mode 100644
index 00000000..df2f9271
--- /dev/null
+++ b/src/components/discussion/comment-form.tsx
@@ -0,0 +1,104 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Send, Loader2 } from 'lucide-react';
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { CommentFormSchema, CommentFormData } from '@/types/schemas/zod-schema';
+
+interface CommentFormProps {
+ onSubmit: (data: CommentFormData) => void | Promise;
+ isSubmitting?: boolean;
+ placeholder?: string;
+ autoFocus?: boolean;
+ initialValue?: string;
+ onCancel?: () => void;
+}
+
+export default function CommentForm({
+ onSubmit,
+ isSubmitting = false,
+ placeholder = '댓글을 입력하세요...',
+ autoFocus = false,
+ initialValue = '',
+ onCancel,
+}: CommentFormProps) {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ reset,
+ } = useForm({
+ resolver: zodResolver(CommentFormSchema),
+ defaultValues: {
+ content: initialValue,
+ },
+ });
+
+ const handleFormSubmit = async (data: CommentFormData) => {
+ await onSubmit(data);
+ reset();
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/discussion/comment-list.tsx b/src/components/discussion/comment-list.tsx
new file mode 100644
index 00000000..b44eff35
--- /dev/null
+++ b/src/components/discussion/comment-list.tsx
@@ -0,0 +1,211 @@
+import { formatDistanceToNow } from 'date-fns';
+import { ko } from 'date-fns/locale';
+import { MoreVertical, Trash2, Edit, CheckCircle2 } from 'lucide-react';
+import React from 'react';
+import CommentForm from '@/components/discussion/comment-form';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { BalanceGameComment } from '@/types/balance-game';
+import { DiscussionComment } from '@/types/discussion';
+import { CommentFormData } from '@/types/schemas/zod-schema';
+import { VotingComment, VotingOption } from '@/types/voting';
+
+interface CommentListProps {
+ comments: (DiscussionComment | VotingComment | BalanceGameComment)[];
+ onDelete?: (commentId: number) => void;
+ onEdit?: (commentId: number, content: string) => void;
+ votingOptions?: VotingOption[]; // 투표 옵션 목록 (색상 매칭용)
+ editingCommentId?: number | null;
+ editingCommentContent?: string;
+ onUpdateComment?: (data: CommentFormData) => void | Promise;
+ onCancelEdit?: () => void;
+}
+
+// 옵션별 색상 정의
+const OPTION_BADGE_COLORS = [
+ { bg: 'bg-blue-100', text: 'text-blue-600', icon: 'text-blue-500' },
+ { bg: 'bg-green-100', text: 'text-green-600', icon: 'text-green-500' },
+ { bg: 'bg-purple-100', text: 'text-purple-600', icon: 'text-purple-500' },
+ { bg: 'bg-orange-100', text: 'text-orange-600', icon: 'text-orange-500' },
+ { bg: 'bg-pink-100', text: 'text-pink-600', icon: 'text-pink-500' },
+];
+
+export default function CommentList({
+ comments,
+ onDelete,
+ onEdit,
+ votingOptions,
+ editingCommentId,
+ editingCommentContent,
+ onUpdateComment,
+ onCancelEdit,
+}: CommentListProps) {
+ const [openMenuId, setOpenMenuId] = React.useState(null);
+
+ if (comments.length === 0) {
+ return (
+
+
+ 아직 댓글이 없습니다.
+
+
+ 첫 번째 댓글을 작성해보세요!
+
+
+ );
+ }
+
+ return (
+
+ {comments.map((comment) => {
+ const isEditing = editingCommentId === comment.id;
+ const timeAgo = formatDistanceToNow(new Date(comment.createdAt), {
+ addSuffix: true,
+ locale: ko,
+ });
+
+ // VotingComment 타입인지 확인
+ const votedOption =
+ 'votedOption' in comment ? comment.votedOption : undefined;
+
+ // 투표 옵션의 색상 찾기
+ let optionColor = {
+ bg: 'bg-fill-brand-subtle-default',
+ text: 'text-text-brand',
+ icon: 'text-text-brand',
+ };
+ if (votedOption && votingOptions) {
+ const optionIndex = votingOptions.findIndex(
+ (opt) => opt.label === votedOption,
+ );
+ if (optionIndex !== -1) {
+ optionColor =
+ OPTION_BADGE_COLORS[optionIndex % OPTION_BADGE_COLORS.length];
+ }
+ }
+
+ // Author image handling
+ const authorImage =
+ 'avatar' in comment.author
+ ? comment.author.avatar
+ : 'profileImage' in comment.author
+ ? (comment.author as any).profileImage
+ : undefined;
+
+ return (
+
+
+ {/* 작성자 정보 */}
+
+
e.stopPropagation()}>
+ }
+ />
+
+
+
+
+ {comment.author.nickname}
+
+ {votedOption && (
+
+
+
+ {votedOption}
+
+
+ )}
+
+
+ {timeAgo}
+
+
+
+
+ {/* 더보기 메뉴 (본인 댓글만) */}
+ {comment.isAuthor && (
+
+
+ setOpenMenuId(
+ openMenuId === comment.id ? null : comment.id,
+ )
+ }
+ className="rounded-100 text-text-subtle hover:bg-fill-neutral-subtle-hover hover:text-text-strong p-100 transition-colors"
+ >
+
+
+
+ {openMenuId === comment.id && (
+ <>
+ {/* 백드롭 */}
+
setOpenMenuId(null)}
+ />
+
+ {/* 메뉴 */}
+
+ {
+ onEdit?.(comment.id, comment.content);
+ setOpenMenuId(null);
+ }}
+ className="font-designer-13r text-text-default hover:bg-fill-neutral-subtle-hover flex w-full items-center gap-150 px-300 py-200 transition-colors"
+ >
+
+ 수정
+
+ {
+ onDelete?.(comment.id);
+ setOpenMenuId(null);
+ }}
+ className="font-designer-13r flex w-full items-center gap-150 px-300 py-200 text-red-600 transition-colors hover:bg-red-50"
+ >
+
+ 삭제
+
+
+ >
+ )}
+
+ )}
+
+
+ {/* 댓글 내용 또는 수정 폼 */}
+ {isEditing ? (
+
+
+
+ ) : (
+
+ {comment.content}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/discussion/discussion-detail-modal.tsx b/src/components/discussion/discussion-detail-modal.tsx
new file mode 100644
index 00000000..3765f1be
--- /dev/null
+++ b/src/components/discussion/discussion-detail-modal.tsx
@@ -0,0 +1,255 @@
+import { formatDistanceToNow } from 'date-fns';
+import { ko } from 'date-fns/locale';
+import {
+ X,
+ ThumbsUp,
+ ThumbsDown,
+ Eye,
+ Clock,
+ MessageCircle,
+} from 'lucide-react';
+import React, { useState } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { TOPIC_LABELS } from '@/mocks/discussion-mock-data';
+import { Discussion, VoteType } from '@/types/discussion';
+import { CommentFormData } from '@/types/schemas/zod-schema';
+import CommentForm from './comment-form';
+import CommentList from './comment-list';
+
+interface DiscussionDetailModalProps {
+ discussion: Discussion;
+ onClose: () => void;
+ onVote?: (discussionId: number, voteType: VoteType) => void;
+ onAddComment?: (discussionId: number, content: string) => void;
+ onDeleteComment?: (discussionId: number, commentId: number) => void;
+ onEditComment?: (
+ discussionId: number,
+ commentId: number,
+ content: string,
+ ) => void;
+}
+
+export default function DiscussionDetailModal({
+ discussion,
+ onClose,
+ onVote,
+ onAddComment,
+ onDeleteComment,
+ onEditComment,
+}: DiscussionDetailModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const timeAgo = formatDistanceToNow(new Date(discussion.createdAt), {
+ addSuffix: true,
+ locale: ko,
+ });
+
+ const handleVote = (voteType: VoteType) => {
+ if (onVote) {
+ onVote(discussion.id, voteType);
+ }
+ };
+
+ const handleCommentSubmit = async (data: CommentFormData) => {
+ setIsSubmitting(true);
+ try {
+ await onAddComment?.(discussion.id, data.content);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+ {/* 주제 배지 */}
+
+ {TOPIC_LABELS[discussion.topic]}
+
+
+ {/* 제목 */}
+
+ {discussion.title}
+
+
+ {/* 메타 정보 */}
+
+ {/* 작성자 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {discussion.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 시간 */}
+
+
+ {timeAgo}
+
+
+ {/* 조회수 */}
+
+
+ {discussion.viewCount.toLocaleString()}
+
+
+
+ {/* 태그 */}
+ {discussion.tags.length > 0 && (
+
+ {discussion.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ {/* 닫기 버튼 */}
+
+
+
+
+
+ {/* 본문 + 투표 */}
+
+ {/* 본문 */}
+
+
+ {discussion.content}
+
+
+
+ {/* 투표 섹션 */}
+
+
+ 이 토론에 대한 의견은?
+
+
+ {/* 찬성 버튼 */}
+
handleVote('agree')}
+ className={cn(
+ 'rounded-100 flex flex-1 flex-col items-center gap-100 border-2 p-300 transition-all',
+ discussion.vote.myVote === 'agree'
+ ? 'border-green-500 bg-green-50'
+ : 'border-border-subtle bg-background-default hover:border-green-500 hover:bg-green-50',
+ )}
+ >
+
+
+
+ 찬성
+
+
+ {discussion.vote.agreeCount}
+
+
+
+
+ {/* 반대 버튼 */}
+
handleVote('disagree')}
+ className={cn(
+ 'rounded-100 flex flex-1 flex-col items-center gap-100 border-2 p-300 transition-all',
+ discussion.vote.myVote === 'disagree'
+ ? 'border-red-500 bg-red-50'
+ : 'border-border-subtle bg-background-default hover:border-red-500 hover:bg-red-50',
+ )}
+ >
+
+
+
+ 반대
+
+
+ {discussion.vote.disagreeCount}
+
+
+
+
+
+
+ {/* 댓글 섹션 */}
+
+
+
+ 댓글 {discussion.commentCount}
+
+
+ {/* 댓글 목록 */}
+
+
+ onDeleteComment?.(discussion.id, commentId)
+ }
+ onEdit={(commentId, content) =>
+ onEditComment?.(discussion.id, commentId, content)
+ }
+ />
+
+
+ {/* 댓글 작성 폼 */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/discussion/filter-bar.tsx b/src/components/discussion/filter-bar.tsx
new file mode 100644
index 00000000..780d7281
--- /dev/null
+++ b/src/components/discussion/filter-bar.tsx
@@ -0,0 +1,81 @@
+import { ArrowUpDown, Filter } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { TOPIC_LABELS } from '@/mocks/discussion-mock-data';
+import { SortOption, DiscussionTopic } from '@/types/discussion';
+
+interface FilterBarProps {
+ sort: SortOption;
+ topic: DiscussionTopic;
+ onSortChange: (sort: SortOption) => void;
+ onTopicChange: (topic: DiscussionTopic) => void;
+}
+
+const TOPICS: DiscussionTopic[] = [
+ 'all',
+ 'development',
+ 'study',
+ 'free',
+ 'question',
+];
+
+export default function FilterBar({
+ sort,
+ topic,
+ onSortChange,
+ onTopicChange,
+}: FilterBarProps) {
+ return (
+
+ {/* 주제 필터 */}
+
+
+
+ {TOPICS.map((t) => (
+ onTopicChange(t)}
+ className={cn(
+ 'rounded-100 font-designer-13b shrink-0 px-300 py-150 transition-all',
+ topic === t
+ ? 'bg-fill-brand-default-default text-text-inverse shadow-1'
+ : 'border-border-subtle bg-background-default text-text-subtle hover:border-border-brand hover:text-text-brand border',
+ )}
+ >
+ {TOPIC_LABELS[t]}
+
+ ))}
+
+
+
+ {/* 정렬 옵션 */}
+
+
+
+ onSortChange('latest')}
+ className={cn(
+ 'rounded-100 font-designer-13b px-300 py-150 transition-all',
+ sort === 'latest'
+ ? 'bg-fill-neutral-strong-default text-text-inverse'
+ : 'border-border-subtle bg-background-default text-text-subtle hover:border-border-strong hover:text-text-strong border',
+ )}
+ >
+ 최신순
+
+ onSortChange('popular')}
+ className={cn(
+ 'rounded-100 font-designer-13b px-300 py-150 transition-all',
+ sort === 'popular'
+ ? 'bg-fill-neutral-strong-default text-text-inverse'
+ : 'border-border-subtle bg-background-default text-text-subtle hover:border-border-strong hover:text-text-strong border',
+ )}
+ >
+ 인기순
+
+
+
+
+ );
+}
diff --git a/src/components/discussion/search-bar.tsx b/src/components/discussion/search-bar.tsx
new file mode 100644
index 00000000..784853de
--- /dev/null
+++ b/src/components/discussion/search-bar.tsx
@@ -0,0 +1,62 @@
+import { Search, X } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface SearchBarProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ className?: string;
+}
+
+export default function SearchBar({
+ value,
+ onChange,
+ placeholder = '토론 검색...',
+ className,
+}: SearchBarProps) {
+ const [localValue, setLocalValue] = useState(value);
+
+ // 디바운스를 위한 useEffect
+ useEffect(() => {
+ setLocalValue(value);
+ }, [value]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onChange(localValue);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }, [localValue, onChange]);
+
+ const handleClear = () => {
+ setLocalValue('');
+ onChange('');
+ };
+
+ return (
+
+
+ setLocalValue(e.target.value)}
+ placeholder={placeholder}
+ className={cn(
+ 'rounded-100 border-border-subtle bg-background-default w-full border py-200 pr-800 pl-800',
+ 'font-designer-14r text-text-strong placeholder:text-text-subtlest',
+ 'focus:border-border-brand focus:ring-fill-brand-subtle-default transition-colors outline-none focus:ring-2',
+ )}
+ />
+ {localValue && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/home/tab-navigation.tsx b/src/components/home/tab-navigation.tsx
new file mode 100644
index 00000000..13a09619
--- /dev/null
+++ b/src/components/home/tab-navigation.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import {
+ Trophy,
+ BookOpen,
+ MessageSquareText,
+ Calendar,
+ History,
+} from 'lucide-react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface TabNavigationProps {
+ activeTab: string;
+}
+
+const TABS = [
+ {
+ id: 'study',
+ label: '스터디',
+ icon: Calendar,
+ description: '나의 스터디 일정',
+ },
+ {
+ id: 'ranking',
+ label: '명예의 전당',
+ icon: Trophy,
+ description: '랭킹 시스템',
+ },
+ {
+ id: 'archive',
+ label: '제로원 아카이브',
+ icon: BookOpen,
+ description: '학습 자료',
+ },
+ {
+ id: 'history',
+ label: '나의 스터디 기록',
+ icon: History,
+ description: '1:1 스터디 히스토리',
+ },
+ {
+ id: 'community',
+ label: '밸런스게임',
+ icon: MessageSquareText,
+ description: '커뮤니티',
+ },
+];
+
+export default function TabNavigation({ activeTab }: TabNavigationProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const handleTabChange = (tabId: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('tab', tabId);
+ router.push(`/home?${params.toString()}`);
+ };
+
+ return (
+
+
+
제로원 홈
+
+
+
+ {TABS.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id;
+
+ return (
+ handleTabChange(tab.id)}
+ className={cn(
+ 'font-designer-16m flex items-center gap-150 border-b-2 px-300 py-200 whitespace-nowrap transition-all',
+ isActive
+ ? 'text-text-brand border-border-brand bg-fill-brand-subtle-default'
+ : 'text-text-subtle hover:text-text-default hover:bg-fill-neutral-subtle-hover border-transparent',
+ )}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/study-history/study-calendar.tsx b/src/components/study-history/study-calendar.tsx
new file mode 100644
index 00000000..25897ce7
--- /dev/null
+++ b/src/components/study-history/study-calendar.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import { ChevronLeft, ChevronRight, CheckCircle, Clock } from 'lucide-react';
+import { useState } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { StudyHistoryItem } from '@/types/study-history';
+
+export const StudyCalendar = ({
+ items,
+ currentDate,
+ onDateChange,
+}: {
+ items: StudyHistoryItem[];
+ currentDate: Date;
+ onDateChange: (date: Date) => void;
+}) => {
+ const [activeTooltip, setActiveTooltip] = useState<{
+ id: number;
+ text: string;
+ rect: DOMRect;
+ } | null>(null);
+
+ const getDaysInMonth = (date: Date) =>
+ new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
+ const getFirstDayOfMonth = (date: Date) =>
+ new Date(date.getFullYear(), date.getMonth(), 1).getDay();
+ const formatDateKey = (year: number, month: number, day: number) =>
+ `${year}.${String(month + 1).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
+
+ const itemsByDate = items.reduce(
+ (acc, item) => {
+ const match = item.date.match(/(\d{4})\.(\d{2})\.(\d{2})/);
+ if (match) {
+ const key = `${match[1]}.${match[2]}.${match[3]}`;
+ acc[key] = acc[key] ? [...acc[key], item] : [item];
+ }
+
+ return acc;
+ },
+ {} as Record,
+ );
+
+ const year = currentDate.getFullYear();
+ const month = currentDate.getMonth();
+ const daysInMonth = getDaysInMonth(currentDate);
+ const firstDay = getFirstDayOfMonth(currentDate);
+
+ const monthNames = [
+ '1월',
+ '2월',
+ '3월',
+ '4월',
+ '5월',
+ '6월',
+ '7월',
+ '8월',
+ '9월',
+ '10월',
+ '11월',
+ '12월',
+ ];
+ const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
+
+ const renderCalendarDay = (day: number) => {
+ const dateKey = formatDateKey(year, month, day);
+ const dayItems = itemsByDate[dateKey] || [];
+ const hasItems = dayItems.length > 0;
+
+ return (
+
+
{day}
+ {dayItems.map((item, itemIndex) => (
+
item.link && window.open(item.link, '_blank')}
+ onMouseEnter={(e) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ setActiveTooltip({ id: item.id, text: item.subject, rect });
+ }}
+ onMouseLeave={() => {
+ setActiveTooltip(null);
+ }}
+ className={cn(
+ 'rounded-50 relative mb-50 flex cursor-pointer items-center gap-50 truncate border px-100 py-50 text-[11px] font-medium transition-all hover:z-20',
+ item.attendance === 'ATTENDED'
+ ? 'bg-fill-success-subtle-default text-text-success hover:bg-fill-success-default border-transparent'
+ : 'bg-fill-warning-subtle-default text-text-warning hover:bg-fill-warning-default border-transparent',
+ )}
+ >
+ {item.attendance === 'ATTENDED' ? (
+
+ ) : (
+
+ )}
+ {item.subject}
+
+ ))}
+ {dayItems.length > 2 && (
+
+ +{dayItems.length - 2}개 더
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+ onDateChange(new Date(year, month - 1, 1))}
+ className="hover:bg-fill-neutral-subtle-hover rounded-100 p-100 transition-colors"
+ >
+
+
+
+ {year}년 {monthNames[month]}
+
+ onDateChange(new Date(year, month + 1, 1))}
+ className="hover:bg-fill-neutral-subtle-hover rounded-100 p-100 transition-colors"
+ >
+
+
+
+
+
+ {dayNames.map((name, idx) => (
+
+ {name}
+
+ ))}
+
+
+
+ {Array.from({ length: firstDay }, (_, i) => (
+
+ ))}
+ {Array.from({ length: daysInMonth }, (_, i) =>
+ renderCalendarDay(i + 1),
+ )}
+
+
+ {/* Global Tooltip Portal (Fixed Position) */}
+ {activeTooltip && (
+
+ {/* Arrow (Bottom) */}
+
+
+ {activeTooltip.text}
+
+ )}
+
+ );
+};
diff --git a/src/components/study-history/study-history-row.tsx b/src/components/study-history/study-history-row.tsx
new file mode 100644
index 00000000..851ccef4
--- /dev/null
+++ b/src/components/study-history/study-history-row.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import {
+ Calendar,
+ Mic,
+ User,
+ CheckCircle,
+ Clock,
+ ExternalLink,
+ CheckCircle2,
+} from 'lucide-react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ProfileAvatar } from '@/components/ui/profile-avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { StudyHistoryItem } from '@/types/study-history';
+
+export const StudyHistoryRow = ({ item }: { item: StudyHistoryItem }) => {
+ return (
+
+ {/* 날짜 */}
+
+
+ {item.date}
+
+
+ {/* 주제 */}
+
+ {item.subject}
+
+
+ {/* 상대방 */}
+
+
e.stopPropagation()} // 행 클릭 이벤트(링크 이동) 방지
+ >
+
+
+ {item.partner.name}
+
+
+ }
+ />
+
+
+ {/* 역할 */}
+
+ {item.role === 'INTERVIEWER' ? (
+
+
+ 면접자
+
+ ) : (
+
+
+ 답변자
+
+ )}
+
+
+ {/* 역할수행여부 */}
+
+ {item.attendance === 'ATTENDED' ? (
+
+
+ 역할수행
+
+ ) : (
+
+
+ 미진행
+
+ )}
+
+
+ {/* 진행상태 */}
+
+ {item.status === 'COMPLETED' ? (
+
+
+ 완료
+
+ ) : (
+
+
+ 진행중
+
+ )}
+
+
+ {/* 링크 */}
+
+
+ );
+};
diff --git a/src/components/ui/avatar/index.tsx b/src/components/ui/avatar/index.tsx
index 49239515..f1192c46 100644
--- a/src/components/ui/avatar/index.tsx
+++ b/src/components/ui/avatar/index.tsx
@@ -25,7 +25,17 @@ export default function Avatar({
}: UserAvatarProps) {
const [isError, setIsError] = useState(false);
- const showImage = !!image && !isError;
+ // 유효하지 않은 이미지 URL 필터링 (LOCAL, 빈 문자열, 상대 경로만 있는 경우 등)
+ const isValidImage =
+ image &&
+ typeof image === 'string' &&
+ image.trim() !== '' &&
+ image.toUpperCase() !== 'LOCAL' &&
+ (image.startsWith('http://') ||
+ image.startsWith('https://') ||
+ image.startsWith('/'));
+
+ const showImage = isValidImage && !isError;
return (
{
+ const px = { sm: 32, md: 48, lg: 80, xl: 120 }[size]; // 기존 명예의 전당 사이즈와 최대한 비슷하게 매핑 (sm:32px는 기존 row에 맞춤)
+ const effectiveAlt = alt || name || 'profile';
+
+ // src 정리(공백/이상한 상대경로 방지)
+ const normalizedSrc = useMemo(() => {
+ if (!src || typeof src !== 'string') return null;
+ const s = src.trim();
+ if (!s) return null;
+ // 유효하지 않은 값 필터링 (LOCAL, null 등)
+ if (s.toUpperCase() === 'LOCAL' || s === 'null' || s === 'undefined')
+ return null;
+ // LOCAL/로 시작하는 경우 처리 (예: LOCAL/https:/picsum.photos/202)
+ if (s.toUpperCase().startsWith('LOCAL/')) {
+ const afterLocal = s.substring(6); // 'LOCAL/'.length = 6
+ // LOCAL/ 뒤에 실제 URL이 있는 경우
+ if (
+ afterLocal.startsWith('http://') ||
+ afterLocal.startsWith('https://')
+ ) {
+ return afterLocal;
+ }
+
+ // LOCAL/ 뒤에 유효하지 않은 값인 경우
+ return null;
+ }
+ if (
+ s.startsWith('http://') ||
+ s.startsWith('https://') ||
+ s.startsWith('/')
+ )
+ return s;
+
+ return `/${s}`;
+ }, [src]);
+
+ const [broken, setBroken] = useState(false);
+ const finalSrc =
+ !broken && normalizedSrc ? normalizedSrc : '/profile-default.svg';
+
+ return (
+ setBroken(true)}
+ />
+ );
+};
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 00000000..3c231840
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { CheckCircle2 } from 'lucide-react';
+import React, { useEffect } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface ToastProps {
+ message: string;
+ isVisible: boolean;
+ onClose: () => void;
+ duration?: number;
+}
+
+export default function Toast({
+ message,
+ isVisible,
+ onClose,
+ duration = 3000,
+}: ToastProps) {
+ useEffect(() => {
+ if (isVisible) {
+ const timer = setTimeout(() => {
+ onClose();
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isVisible, duration, onClose]);
+
+ if (!isVisible) return null;
+
+ return (
+
+
+ {message}
+
+ );
+}
diff --git a/src/components/voting/daily-stats-chart.tsx b/src/components/voting/daily-stats-chart.tsx
new file mode 100644
index 00000000..1b421946
--- /dev/null
+++ b/src/components/voting/daily-stats-chart.tsx
@@ -0,0 +1,211 @@
+import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { DailyStatistic, VotingOption } from '@/types/voting';
+
+interface DailyStatsChartProps {
+ dailyStats: DailyStatistic[];
+ options: VotingOption[];
+ myVote?: number;
+}
+
+const OPTION_COLORS = [
+ {
+ bg: 'bg-blue-500',
+ light: 'bg-blue-100',
+ text: 'text-blue-600',
+ hex: '#3b82f6',
+ },
+ {
+ bg: 'bg-green-500',
+ light: 'bg-green-100',
+ text: 'text-green-600',
+ hex: '#22c55e',
+ },
+ {
+ bg: 'bg-purple-500',
+ light: 'bg-purple-100',
+ text: 'text-purple-600',
+ hex: '#a855f7',
+ },
+ {
+ bg: 'bg-orange-500',
+ light: 'bg-orange-100',
+ text: 'text-orange-600',
+ hex: '#f97316',
+ },
+ {
+ bg: 'bg-pink-500',
+ light: 'bg-pink-100',
+ text: 'text-pink-600',
+ hex: '#ec4899',
+ },
+];
+
+export default function DailyStatsChart({
+ dailyStats,
+ options,
+ myVote,
+}: DailyStatsChartProps) {
+ if (!dailyStats || dailyStats.length === 0) return null;
+
+ // 내가 선택한 옵션의 추이 계산
+ const myOptionTrend =
+ myVote && dailyStats.length > 1
+ ? (dailyStats[dailyStats.length - 1].percentages[myVote] || 0) -
+ (dailyStats[0].percentages[myVote] || 0)
+ : null;
+
+ // 최대값 찾기 (차트 높이 계산용)
+ const maxPercentage = Math.max(
+ ...dailyStats.flatMap((stat) => Object.values(stat.percentages)),
+ );
+
+ return (
+
+
+ 일별 투표 추이
+
+
+ {/* 내 선택 추세 */}
+ {myVote && myOptionTrend !== null && (
+
0 && 'bg-green-50',
+ myOptionTrend < 0 && 'bg-red-50',
+ myOptionTrend === 0 && 'bg-gray-50',
+ )}
+ >
+
+ {myOptionTrend > 0 && (
+
+ )}
+ {myOptionTrend < 0 && (
+
+ )}
+ {myOptionTrend === 0 && }
+
+
+
+ 내가 선택한 의견이 어제보다{' '}
+ {myOptionTrend > 0 && (
+
+ +{myOptionTrend.toFixed(1)}%p 증가
+
+ )}
+ {myOptionTrend < 0 && (
+
+ {myOptionTrend.toFixed(1)}%p 감소
+
+ )}
+ {myOptionTrend === 0 && (
+ 변화 없음
+ )}
+
+
+ {options.find((opt) => opt.id === myVote)?.label}
+
+
+
+ )}
+
+ {/* 차트 */}
+
+
+ {/* Y축 레이블 */}
+
+ {maxPercentage.toFixed(0)}%
+ {(maxPercentage * 0.75).toFixed(0)}%
+ {(maxPercentage * 0.5).toFixed(0)}%
+ {(maxPercentage * 0.25).toFixed(0)}%
+ 0%
+
+
+ {/* 그리드 라인 */}
+
+ {[0, 25, 50, 75, 100].map((_, index) => (
+
+ ))}
+
+
+ {/* 라인 차트 */}
+
+ {options.map((option, optIndex) => {
+ const color = OPTION_COLORS[optIndex % OPTION_COLORS.length];
+ const points = dailyStats
+ .map((stat, statIndex) => {
+ const x = (statIndex / (dailyStats.length - 1)) * 100;
+ const y =
+ 100 -
+ ((stat.percentages[option.id] || 0) / maxPercentage) * 100;
+
+ return `${x},${y}`;
+ })
+ .join(' ');
+
+ return (
+
+ );
+ })}
+
+
+
+ {/* X축 레이블 */}
+
+ {dailyStats.map((stat, index) => (
+
+ {stat.date}
+
+ ))}
+
+
+
+ {/* 범례 */}
+
+ {options.map((option, index) => {
+ const color = OPTION_COLORS[index % OPTION_COLORS.length];
+ const isMyVote = myVote === option.id;
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/voting/vote-results-chart.tsx b/src/components/voting/vote-results-chart.tsx
new file mode 100644
index 00000000..36da2767
--- /dev/null
+++ b/src/components/voting/vote-results-chart.tsx
@@ -0,0 +1,205 @@
+import { Check, Crown, TrendingUp } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { VotingOption } from '@/types/voting';
+
+interface VoteResultsChartProps {
+ options: VotingOption[];
+ myVote?: number;
+ totalVotes: number;
+ showPercentage?: boolean;
+}
+
+const OPTION_COLORS = [
+ {
+ primary: 'bg-blue-500',
+ gradient: 'from-blue-500 to-blue-400',
+ light: 'bg-blue-50',
+ text: 'text-blue-600',
+ ring: 'ring-blue-500',
+ },
+ {
+ primary: 'bg-green-500',
+ gradient: 'from-green-500 to-green-400',
+ light: 'bg-green-50',
+ text: 'text-green-600',
+ ring: 'ring-green-500',
+ },
+ {
+ primary: 'bg-purple-500',
+ gradient: 'from-purple-500 to-purple-400',
+ light: 'bg-purple-50',
+ text: 'text-purple-600',
+ ring: 'ring-purple-500',
+ },
+ {
+ primary: 'bg-orange-500',
+ gradient: 'from-orange-500 to-orange-400',
+ light: 'bg-orange-50',
+ text: 'text-orange-600',
+ ring: 'ring-orange-500',
+ },
+ {
+ primary: 'bg-pink-500',
+ gradient: 'from-pink-500 to-pink-400',
+ light: 'bg-pink-50',
+ text: 'text-pink-600',
+ ring: 'ring-pink-500',
+ },
+];
+
+export default function VoteResultsChart({
+ options,
+ myVote,
+ totalVotes,
+ showPercentage = true,
+}: VoteResultsChartProps) {
+ // 퍼센트 기준으로 정렬 (높은 순)
+ const sortedOptions = [...options].sort(
+ (a, b) => b.percentage - a.percentage,
+ );
+ const topOption = sortedOptions[0];
+
+ return (
+
+ {sortedOptions.map((option, index) => {
+ const isMyVote = myVote === option.id;
+ const isTopVote = option.id === topOption.id;
+ const colorIndex = options.findIndex((opt) => opt.id === option.id);
+ const colors = OPTION_COLORS[colorIndex % OPTION_COLORS.length];
+ const rank = index + 1;
+
+ return (
+
+ {/* 배경 그라데이션 프로그레스 바 */}
+
+
+ {/* 컨텐츠 */}
+
+ {/* 왼쪽: 순위 + 옵션명 + 내 투표 표시 */}
+
+ {/* 순위 배지 */}
+
+ {isTopVote ? : rank}
+
+
+
+ {/* 옵션 라벨 */}
+
+
+ {option.label}
+
+
+ {/* 내가 투표한 항목 표시 */}
+ {isMyVote && (
+
+
+
+ 내 선택
+
+
+ )}
+
+ {/* 1위 표시 */}
+ {isTopVote && (
+
+
+
+ 1위
+
+
+ )}
+
+
+ {/* 투표 수 */}
+
+ {option.voteCount.toLocaleString()}명이 선택
+
+
+
+
+ {/* 오른쪽: 퍼센트 */}
+ {showPercentage && (
+
+
+ {option.percentage.toFixed(1)}%
+
+
+
+ )}
+
+
+ {/* 호버 효과용 빛나는 테두리 */}
+ {!isMyVote && (
+
+ )}
+
+ );
+ })}
+
+ {/* 총 투표 수 - 더 강조된 디자인 */}
+
+
+
+
+
+
+ 총 투표 참여
+
+
+ {totalVotes.toLocaleString()}명
+
+
+
+
+ );
+}
diff --git a/src/components/voting/vote-timer.tsx b/src/components/voting/vote-timer.tsx
new file mode 100644
index 00000000..1cefa2db
--- /dev/null
+++ b/src/components/voting/vote-timer.tsx
@@ -0,0 +1,99 @@
+import { Clock, Timer } from 'lucide-react';
+import React, { useEffect, useState } from 'react';
+
+interface VoteTimerProps {
+ endsAt?: string;
+ isActive: boolean;
+}
+
+interface TimeLeft {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+}
+
+function calculateTimeLeft(endsAt: string): TimeLeft | null {
+ const difference = new Date(endsAt).getTime() - new Date().getTime();
+
+ if (difference <= 0) {
+ return null;
+ }
+
+ return {
+ days: Math.floor(difference / (1000 * 60 * 60 * 24)),
+ hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
+ minutes: Math.floor((difference / 1000 / 60) % 60),
+ seconds: Math.floor((difference / 1000) % 60),
+ };
+}
+
+export default function VoteTimer({ endsAt, isActive }: VoteTimerProps) {
+ const [timeLeft, setTimeLeft] = useState(
+ endsAt ? calculateTimeLeft(endsAt) : null,
+ );
+
+ useEffect(() => {
+ if (!endsAt || !isActive) return;
+
+ const timer = setInterval(() => {
+ const newTimeLeft = calculateTimeLeft(endsAt);
+ setTimeLeft(newTimeLeft);
+
+ if (!newTimeLeft) {
+ clearInterval(timer);
+ }
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [endsAt, isActive]);
+
+ if (!isActive) {
+ return (
+
+
+ 종료된 투표
+
+ );
+ }
+
+ if (!endsAt) {
+ return (
+
+
+ 진행 중
+
+ );
+ }
+
+ if (!timeLeft) {
+ return (
+
+
+ 종료
+
+ );
+ }
+
+ return (
+
+
+
+ 남은 시간
+
+ {timeLeft.days > 0 && (
+ <>
+ {timeLeft.days}일
+ :
+ >
+ )}
+ {String(timeLeft.hours).padStart(2, '0')}
+ :
+ {String(timeLeft.minutes).padStart(2, '0')}
+ :
+ {String(timeLeft.seconds).padStart(2, '0')}
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-create-modal.tsx b/src/components/voting/voting-create-modal.tsx
new file mode 100644
index 00000000..250e9e50
--- /dev/null
+++ b/src/components/voting/voting-create-modal.tsx
@@ -0,0 +1,392 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { X, Plus, Trash2, Loader2 } from 'lucide-react';
+import React, { useState } from 'react';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { Modal } from '@/components/ui/modal';
+import {
+ VotingCreateFormSchema,
+ VotingCreateFormData,
+} from '@/types/schemas/zod-schema';
+
+interface VotingCreateModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (data: VotingCreateFormData) => Promise;
+}
+
+export default function VotingCreateModal({
+ isOpen,
+ onClose,
+ onSubmit,
+}: VotingCreateModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [tagInput, setTagInput] = useState('');
+
+ const {
+ register,
+ handleSubmit,
+ control,
+ watch,
+ setValue,
+ formState: { errors },
+ reset,
+ } = useForm({
+ resolver: zodResolver(VotingCreateFormSchema),
+ defaultValues: {
+ title: '',
+ description: '',
+ options: [{ label: '' }, { label: '' }],
+ tags: [],
+ endsAt: '',
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: 'options',
+ });
+
+ const watchedTags = watch('tags') || [];
+ const watchedTitle = watch('title') || '';
+ const watchedDescription = watch('description') || '';
+ const watchedEndsAt = watch('endsAt') || '';
+
+ // 날짜만 선택하고 시간은 23:59로 고정하는 핸들러
+ const handleDateChange = (e: React.ChangeEvent) => {
+ const selectedDate = e.target.value;
+ if (selectedDate) {
+ // 선택한 날짜의 23:59로 설정
+ const dateTimeString = `${selectedDate}T23:59`;
+ setValue('endsAt', dateTimeString);
+ } else {
+ setValue('endsAt', '');
+ }
+ };
+
+ // 날짜만 추출 (표시용)
+ const selectedDateOnly = watchedEndsAt ? watchedEndsAt.split('T')[0] : '';
+
+ // 오늘 날짜를 YYYY-MM-DD 형식으로 가져오기
+ const getTodayDateString = () => {
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = String(today.getMonth() + 1).padStart(2, '0');
+ const day = String(today.getDate()).padStart(2, '0');
+
+ return `${year}-${month}-${day}`;
+ };
+
+ // 태그 추가
+ const handleAddTag = () => {
+ const trimmedTag = tagInput.trim();
+ if (
+ trimmedTag &&
+ watchedTags.length < 3 &&
+ !watchedTags.includes(trimmedTag)
+ ) {
+ setValue('tags', [...watchedTags, trimmedTag]);
+ setTagInput('');
+ }
+ };
+
+ // 태그 삭제
+ const handleRemoveTag = (tagToRemove: string) => {
+ setValue(
+ 'tags',
+ watchedTags.filter((tag) => tag !== tagToRemove),
+ );
+ };
+
+ // 폼 제출
+ const handleFormSubmit = async (data: VotingCreateFormData) => {
+ setIsSubmitting(true);
+ try {
+ // endsAt이 빈 문자열이면 undefined로 변환
+ const submitData = {
+ ...data,
+ endsAt:
+ data.endsAt && data.endsAt.trim() !== '' ? data.endsAt : undefined,
+ };
+ await onSubmit(submitData);
+ reset();
+ onClose();
+ } catch (error) {
+ console.error('투표 생성 실패:', error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 모달 닫기
+ const handleClose = () => {
+ if (!isSubmitting) {
+ reset();
+ setTagInput('');
+ onClose();
+ }
+ };
+
+ return (
+ {
+ if (!nextOpen) handleClose();
+ }}
+ >
+
+
+
+
+
+ 새 투표 주제 만들기
+
+
+
+
+
+
+
+
+
+
+ {/* 제목 */}
+
+
+ 제목 *
+
+
+
+ {errors.title && (
+
+ {errors.title.message}
+
+ )}
+
+ {watchedTitle.length}/200
+
+
+
+
+ {/* 설명 */}
+
+
+ 설명 (선택)
+
+
+
+ {errors.description && (
+
+ {errors.description.message}
+
+ )}
+
+ {watchedDescription.length}/500
+
+
+
+
+ {/* 선택지 */}
+
+
+ 선택지 *
+
+ (최소 2개, 최대 5개)
+
+
+
+ {fields.map((field, index) => (
+
+
+ {index + 1}
+
+
+
+ {errors.options?.[index]?.label && (
+
+ {errors.options[index]?.label?.message}
+
+ )}
+
+ {fields.length > 2 && (
+
remove(index)}
+ className="rounded-100 text-text-subtle hover:bg-fill-critical-subtle-default hover:text-text-critical p-150 transition-colors"
+ >
+
+
+ )}
+
+ ))}
+
+ {errors.options && (
+
+ {errors.options.message || errors.options.root?.message}
+
+ )}
+ {fields.length < 5 && (
+
append({ label: '' })}
+ className="rounded-100 border-border-brand font-designer-13b text-text-brand hover:bg-fill-brand-subtle-default mt-200 flex items-center gap-100 border border-dashed px-300 py-200 transition-colors"
+ >
+
+ 선택지 추가
+
+ )}
+
+
+ {/* 태그 */}
+
+
+ 태그 (선택)
+
+ (최대 3개)
+
+
+
+ setTagInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddTag();
+ }
+ }}
+ placeholder="태그 입력 후 Enter"
+ disabled={watchedTags.length >= 3}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14r focus:border-border-brand flex-1 border px-300 py-200 transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-50"
+ />
+ = 3 || !tagInput.trim()}
+ className="rounded-100 bg-fill-brand-default-default font-designer-13b text-text-inverse hover:bg-fill-brand-default-hover px-300 py-200 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ 추가
+
+
+ {watchedTags.length > 0 && (
+
+ {watchedTags.map((tag) => (
+
+
+ #{tag}
+
+ handleRemoveTag(tag)}
+ className="text-text-subtle hover:text-text-strong transition-colors"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 마감 시간 */}
+
+
+ 투표 마감 시간 (선택)
+
+
+ {selectedDateOnly && (
+
+ 선택한 날짜의 23시 59분에 마감됩니다
+
+ )}
+ {!selectedDateOnly && (
+
+ 미입력 시 7일 후 자동 마감됩니다
+
+ )}
+
+
+
+
+
+
+
+ 취소
+
+
+
+ {isSubmitting ? (
+
+
+ 생성 중...
+
+ ) : (
+ '주제 만들기'
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-detail-modal.tsx b/src/components/voting/voting-detail-modal.tsx
new file mode 100644
index 00000000..0c417658
--- /dev/null
+++ b/src/components/voting/voting-detail-modal.tsx
@@ -0,0 +1,228 @@
+import { X, TrendingUp, Info, MessageCircle } from 'lucide-react';
+import React, { useState } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { CommentFormData } from '@/types/schemas/zod-schema';
+import { Voting } from '@/types/voting';
+import VoteResultsChart from './vote-results-chart';
+import VoteTimer from './vote-timer';
+import CommentForm from '../discussion/comment-form';
+import CommentList from '../discussion/comment-list';
+
+interface VotingDetailModalProps {
+ voting: Voting;
+ onClose: () => void;
+ onVote?: (votingId: number, optionId: number) => void;
+ onAddComment?: (votingId: number, content: string) => void;
+ onDeleteComment?: (votingId: number, commentId: number) => void;
+}
+
+export default function VotingDetailModal({
+ voting,
+ onClose,
+ onVote,
+ onAddComment,
+ onDeleteComment,
+}: VotingDetailModalProps) {
+ const [selectedOption, setSelectedOption] = useState(
+ voting.myVote,
+ );
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showDescription, setShowDescription] = useState(false);
+
+ const hasVoted = voting.myVote !== undefined;
+
+ const handleVote = async () => {
+ if (!selectedOption || !voting.isActive) return;
+
+ setIsSubmitting(true);
+ try {
+ await onVote?.(voting.id, selectedOption);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleCommentSubmit = async (data: CommentFormData) => {
+ await onAddComment?.(voting.id, data.content);
+ };
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+ {/* 라운드 & 상태 */}
+
+
+
+
+ {voting.round} 라운드
+
+
+
+
+
+ {/* 제목 */}
+
+ {voting.title}
+
+
+ {/* 작성자 정보 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {voting.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 설명 토글 */}
+ {voting.description && (
+
setShowDescription(!showDescription)}
+ className="rounded-100 border-border-subtle font-designer-12r text-text-subtle hover:border-border-brand hover:text-text-brand flex items-center gap-100 border px-200 py-100 transition-colors"
+ >
+
+ 주제 설명 {showDescription ? '숨기기' : '보기'}
+
+ )}
+
+ {showDescription && voting.description && (
+
+ {voting.description}
+
+ )}
+
+ {/* 태그 */}
+ {voting.tags &&
+ Array.isArray(voting.tags) &&
+ voting.tags.length > 0 && (
+
+ {voting.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ {/* 닫기 버튼 */}
+
+
+
+
+
+ {/* 본문 */}
+
+ {/* 투표 섹션 */}
+ {!hasVoted && voting.isActive ? (
+
+
+ 투표해주세요
+
+
+ {voting.options.map((option) => (
+ setSelectedOption(option.id)}
+ disabled={isSubmitting}
+ className={cn(
+ 'rounded-200 font-designer-15b border-2 p-400 text-left transition-all',
+ selectedOption === option.id
+ ? 'border-fill-brand-default-default bg-fill-brand-subtle-default text-text-brand'
+ : 'border-border-subtle bg-background-default text-text-default hover:border-border-brand',
+ isSubmitting && 'cursor-not-allowed opacity-50',
+ )}
+ >
+ {option.label}
+
+ ))}
+
+
+
+ {isSubmitting ? '투표 중...' : '투표하기'}
+
+
+ ) : (
+
+
+ 투표 결과
+
+
+
+ )}
+
+ {/* 댓글 섹션 */}
+
+
+
+ 댓글 {voting.commentCount}
+
+
+ {/* 투표 안 했으면 댓글 작성 불가 안내 */}
+ {!hasVoted && voting.isActive ? (
+
+
+ 투표 후 댓글을 작성할 수 있습니다
+
+
+ ) : (
+ <>
+ {/* 댓글 목록 */}
+
+
+ onDeleteComment?.(voting.id, commentId)
+ }
+ />
+
+
+ {/* 댓글 작성 폼 */}
+ {voting.isActive && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-detail-view.tsx b/src/components/voting/voting-detail-view.tsx
new file mode 100644
index 00000000..6b19ba55
--- /dev/null
+++ b/src/components/voting/voting-detail-view.tsx
@@ -0,0 +1,632 @@
+'use client';
+
+import {
+ Loader2,
+ ArrowLeft,
+ TrendingUp,
+ MessageCircle,
+ MoreVertical,
+ Edit,
+ Trash2,
+} from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import CommentForm from '@/components/discussion/comment-form';
+import CommentList from '@/components/discussion/comment-list';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import Button from '@/components/ui/button';
+import { Modal } from '@/components/ui/modal';
+import DailyStatsChart from '@/components/voting/daily-stats-chart';
+import VoteResultsChart from '@/components/voting/vote-results-chart';
+import VoteTimer from '@/components/voting/vote-timer';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import {
+ useVoteBalanceGameMutation,
+ useCancelVoteBalanceGameMutation,
+ useCreateBalanceGameCommentMutation,
+ useDeleteBalanceGameCommentMutation,
+ useDeleteBalanceGameMutation,
+ useUpdateBalanceGameCommentMutation,
+ useUpdateBalanceGameMutation,
+} from '@/features/study/one-to-one/balance-game/model/use-balance-game-mutation';
+import {
+ useBalanceGameDetailQuery,
+ useBalanceGameCommentsQuery,
+} from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import { useUserStore } from '@/stores/useUserStore';
+import { BalanceGameComment } from '@/types/balance-game';
+import {
+ CommentFormData,
+ VotingCreateFormData,
+} from '@/types/schemas/zod-schema';
+import { VotingOption } from '@/types/voting';
+import VotingEditModal from './voting-edit-modal';
+
+interface VotingDetailViewProps {
+ votingId: number;
+ onBack: () => void;
+}
+
+export default function VotingDetailView({
+ votingId,
+ onBack,
+}: VotingDetailViewProps) {
+ const [selectedOption, setSelectedOption] = useState();
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [editingCommentContent, setEditingCommentContent] =
+ useState('');
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ // User Info
+ const memberId = useUserStore((state) => state.memberId);
+
+ // Queries
+ const {
+ data: voting,
+ isLoading,
+ error,
+ } = useBalanceGameDetailQuery(votingId);
+
+ // 투표 여부 확인 (투표를 한 경우에만 댓글 목록 가져오기)
+ const hasVoted = voting?.myVote !== undefined && voting?.myVote !== null;
+
+ const {
+ data: commentsData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useBalanceGameCommentsQuery(votingId, { enabled: !!hasVoted });
+
+ // Mutations
+ const voteMutation = useVoteBalanceGameMutation(votingId);
+ const cancelVoteMutation = useCancelVoteBalanceGameMutation(votingId);
+ const createCommentMutation = useCreateBalanceGameCommentMutation(votingId);
+ const updateCommentMutation = useUpdateBalanceGameCommentMutation(votingId);
+ const deleteCommentMutation = useDeleteBalanceGameCommentMutation(votingId);
+ const updateGameMutation = useUpdateBalanceGameMutation(votingId);
+ const deleteGameMutation = useDeleteBalanceGameMutation(votingId);
+
+ // Set selected option when voting loads
+ useEffect(() => {
+ if (voting?.myVote) {
+ setSelectedOption(voting.myVote);
+ }
+ }, [voting?.myVote]);
+
+ const comments = React.useMemo(() => {
+ const allComments =
+ commentsData?.pages.flatMap((page) => page.content) || [];
+ // 중복 제거 (key prop warning 방지)
+ const seen = new Set();
+
+ return allComments.filter((comment) => {
+ if (seen.has(comment.id)) return false;
+ seen.add(comment.id);
+
+ return true;
+ });
+ }, [commentsData]);
+
+ // isActive는 백엔드가 내려줄 수도 있고(권장), 없으면 endsAt 기준으로 프론트에서 계산
+ // VoteTimer도 endsAt으로 "종료"를 판단하므로, 두 로직이 어긋나지 않게 맞춘다.
+ const isActiveByEndsAt = React.useMemo(() => {
+ if (!voting?.endsAt) return true;
+
+ return new Date(voting.endsAt).getTime() > Date.now();
+ }, [voting?.endsAt]);
+
+ const isActive = voting?.isActive ?? isActiveByEndsAt;
+
+ const handleVote = async () => {
+ if (!selectedOption || !isActive) return;
+ try {
+ await voteMutation.mutateAsync(selectedOption);
+ } catch (error) {
+ console.error('Vote failed:', error);
+ }
+ };
+
+ const handleRevote = async () => {
+ try {
+ if (voting?.myVote) {
+ await cancelVoteMutation.mutateAsync();
+ setSelectedOption(undefined);
+ }
+ } catch (error) {
+ console.error('Cancel vote failed:', error);
+ }
+ };
+
+ const handleAddComment = async (data: CommentFormData) => {
+ if (!voting) return;
+ try {
+ await createCommentMutation.mutateAsync(data.content);
+ } catch (error) {
+ console.error('Add comment failed:', error);
+ }
+ };
+
+ const handleDeleteComment = async (commentId: number) => {
+ try {
+ await deleteCommentMutation.mutateAsync(commentId);
+ } catch (error) {
+ console.error('Delete comment failed:', error);
+ }
+ };
+
+ const handleStartEditComment = (commentId: number, content: string) => {
+ setEditingCommentId(commentId);
+ setEditingCommentContent(content);
+ };
+
+ const handleCancelEditComment = () => {
+ setEditingCommentId(null);
+ setEditingCommentContent('');
+ };
+
+ const handleUpdateComment = async (data: CommentFormData) => {
+ if (!editingCommentId) return;
+ try {
+ await updateCommentMutation.mutateAsync({
+ commentId: editingCommentId,
+ content: data.content,
+ });
+ handleCancelEditComment();
+ } catch (error) {
+ console.error('Update comment failed:', error);
+ }
+ };
+
+ const handleUpdateGame = async (data: Partial) => {
+ try {
+ await updateGameMutation.mutateAsync({
+ title: data.title,
+ description: data.description,
+ tags: data.tags || [], // tags가 undefined일 경우 빈 배열로 전달
+ });
+ setIsEditModalOpen(false);
+ } catch (error) {
+ console.error('Update game failed:', error);
+ }
+ };
+
+ const handleDeleteGame = async () => {
+ try {
+ await deleteGameMutation.mutateAsync();
+ setIsDeleteModalOpen(false);
+ onBack(); // 삭제 후 목록으로 돌아가기
+ } catch (error) {
+ console.error('Delete game failed:', error);
+ alert('투표 삭제에 실패했습니다. 다시 시도해주세요.');
+ }
+ };
+
+ // Check if current user is author
+ const isAuthor = voting?.author.id === memberId;
+
+ // 로딩 상태
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (error || !voting) {
+ return (
+
+
+
+ 데이터를 불러오는데 실패했습니다.
+
+
+ 목록으로 돌아가기
+
+
+
+ );
+ }
+
+ // Adapt BalanceGame options to VotingOption for compatibility
+ const votingOptions: VotingOption[] = voting.options.map((opt) => ({
+ ...opt,
+ }));
+
+ // 디버깅용 로그 추가
+ console.log('Voting Data:', {
+ myVote: voting.myVote,
+ hasVoted,
+ endsAt: voting.endsAt,
+ rawIsActive: voting.isActive,
+ isActiveByEndsAt,
+ isActive,
+ });
+
+ const showVoteOptions = !hasVoted && isActive;
+
+ return (
+
+ {/* 뒤로가기 버튼 */}
+
+
+ 돌아가기
+
+
+ {/* 헤더 */}
+
+ {/* 작성자 & 상태 */}
+
+ {/* 작성자 정보 */}
+
e.stopPropagation()}>
+
+
+
+
+
+ {voting.author.nickname}
+
+
+ }
+ />
+
+
+ {/* 우측 컨트롤: 타이머 + 작성자 메뉴 (겹침 방지) */}
+
+
+
+ {isAuthor && (
+
+
setIsMenuOpen(!isMenuOpen)}
+ className="rounded-100 text-text-subtle hover:bg-fill-neutral-subtle-default p-100 transition-colors"
+ >
+
+
+
+ {isMenuOpen && (
+ <>
+
setIsMenuOpen(false)}
+ />
+
+ {
+ setIsMenuOpen(false);
+ setIsEditModalOpen(true);
+ }}
+ className="font-designer-13r text-text-default hover:bg-fill-neutral-subtle-default flex w-full items-center gap-200 px-300 py-200"
+ >
+
+ 수정
+
+ {
+ setIsMenuOpen(false);
+ setIsDeleteModalOpen(true);
+ }}
+ className="font-designer-13r text-text-critical hover:bg-fill-critical-subtle-default flex w-full items-center gap-200 px-300 py-200"
+ >
+
+ 삭제
+
+
+ >
+ )}
+
+ )}
+
+
+
+ {/* 제목 */}
+
{voting.title}
+
+ {/* 태그 - 제목 바로 아래에 표시 */}
+ {voting.tags &&
+ Array.isArray(voting.tags) &&
+ voting.tags.length > 0 && (
+
+ {voting.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* 설명 */}
+ {voting.description && (
+
+ {voting.description}
+
+ )}
+
+
+ {/* 투표 섹션 */}
+
+ {showVoteOptions ? (
+ <>
+ {/* 헤더 */}
+
+
+ 투표해주세요
+
+
+ {/* 현재 투표 참여 인원 */}
+
+
+
+
+
+
+ 현재 참여
+
+
+ {voting.totalVotes.toLocaleString()}명
+
+
+
+
+
+ {/* 선택지 */}
+
+ {voting.options.map((option, index) => {
+ const isSelected = selectedOption === option.id;
+ const colors = [
+ {
+ border: 'border-blue-500',
+ bg: 'bg-blue-50',
+ text: 'text-blue-600',
+ ring: 'ring-blue-500',
+ primary: 'bg-blue-500',
+ },
+ {
+ border: 'border-green-500',
+ bg: 'bg-green-50',
+ text: 'text-green-600',
+ ring: 'ring-green-500',
+ primary: 'bg-green-500',
+ },
+ {
+ border: 'border-purple-500',
+ bg: 'bg-purple-50',
+ text: 'text-purple-600',
+ ring: 'ring-purple-500',
+ primary: 'bg-purple-500',
+ },
+ {
+ border: 'border-orange-500',
+ bg: 'bg-orange-50',
+ text: 'text-orange-600',
+ ring: 'ring-orange-500',
+ primary: 'bg-orange-500',
+ },
+ {
+ border: 'border-pink-500',
+ bg: 'bg-pink-50',
+ text: 'text-pink-600',
+ ring: 'ring-pink-500',
+ primary: 'bg-pink-500',
+ },
+ ];
+ const color = colors[index % colors.length];
+
+ return (
+
setSelectedOption(option.id)}
+ disabled={voteMutation.isPending}
+ className={cn(
+ 'group rounded-200 relative border-2 p-300 text-left transition-all duration-200',
+ isSelected
+ ? cn('shadow-lg', color.border, color.bg)
+ : 'border-border-subtle bg-background-default hover:border-border-brand hover:shadow-1',
+ voteMutation.isPending && 'cursor-not-allowed opacity-50',
+ )}
+ >
+
+ {/* 번호 배지 */}
+
+ {index + 1}
+
+
+
+ {option.label}
+
+
+
+ );
+ })}
+
+
+ {/* 투표하기 버튼 */}
+
+ {voteMutation.isPending ? (
+
+
+ 투표 중...
+
+ ) : (
+ '투표하기'
+ )}
+
+ >
+ ) : (
+ <>
+
+
투표 결과
+ {isActive && hasVoted && (
+
+ {cancelVoteMutation.isPending
+ ? '취소 중...'
+ : '투표 취소하고 다시하기'}
+
+ )}
+
+
+ >
+ )}
+
+
+ {/* 일별 통계 (투표 후에만 표시) */}
+ {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && (
+
+
+
+ )}
+
+ {/* 댓글 섹션 */}
+
+
+
+ 댓글 {voting.commentCount || 0}
+
+
+ {/* 댓글 목록 (항상 표시) */}
+
+
+
+ {hasNextPage && (
+ fetchNextPage()}
+ disabled={isFetchingNextPage}
+ className="rounded-100 border-border-subtle font-designer-13r text-text-subtle hover:bg-background-alternative mt-300 w-full border py-200"
+ >
+ {isFetchingNextPage ? '불러오는 중...' : '더 보기'}
+
+ )}
+
+
+ {/* 댓글 작성 폼 */}
+ {isActive && (
+ <>
+ {!hasVoted ? (
+
+
+ 투표 후 댓글을 작성할 수 있습니다
+
+
+ ) : editingCommentId === null ? (
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ {/* 수정 모달 */}
+
setIsEditModalOpen(false)}
+ onSubmit={handleUpdateGame}
+ initialData={voting}
+ />
+
+ {/* 삭제 확인 모달 */}
+
+
+
+
+
+ 투표 주제를 삭제하시겠습니까?
+
+
+
+ 작성하신 투표 주제가 영구적으로 삭제됩니다.
+
+
+
+ setIsDeleteModalOpen(false)}
+ >
+ 취소
+
+
+ {deleteGameMutation.isPending ? '삭제 중...' : '삭제하기'}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/voting/voting-edit-modal.tsx b/src/components/voting/voting-edit-modal.tsx
new file mode 100644
index 00000000..f667df5d
--- /dev/null
+++ b/src/components/voting/voting-edit-modal.tsx
@@ -0,0 +1,276 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { X, Loader2 } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { BalanceGame } from '@/types/balance-game';
+import {
+ VotingEditFormSchema,
+ VotingEditFormData,
+ VotingCreateFormData,
+} from '@/types/schemas/zod-schema';
+
+interface VotingEditModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (data: Partial) => Promise;
+ initialData: BalanceGame;
+}
+
+export default function VotingEditModal({
+ isOpen,
+ onClose,
+ onSubmit,
+ initialData,
+}: VotingEditModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [tagInput, setTagInput] = useState('');
+
+ // 모달이 열릴 때 배경 스크롤 방지 (스크롤바 2개 문제 해결)
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ document.body.style.overflow = prevOverflow;
+ };
+ }, [isOpen]);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(VotingEditFormSchema), // 옵션과 마감일은 수정 불가
+ defaultValues: {
+ title: initialData.title,
+ description: initialData.description || '',
+ tags: initialData.tags || [],
+ },
+ });
+
+ // Initialize form when opening
+ useEffect(() => {
+ if (isOpen) {
+ setValue('title', initialData.title);
+ setValue('description', initialData.description || '');
+ setValue('tags', initialData.tags || []);
+ }
+ }, [isOpen, initialData, setValue]);
+
+ const watchedTags = watch('tags') || [];
+ const watchedTitle = watch('title') || '';
+ const watchedDescription = watch('description') || '';
+
+ // 태그 추가
+ const handleAddTag = () => {
+ const trimmedTag = tagInput.trim();
+ if (
+ trimmedTag &&
+ watchedTags.length < 3 &&
+ !watchedTags.includes(trimmedTag)
+ ) {
+ setValue('tags', [...watchedTags, trimmedTag]);
+ setTagInput('');
+ }
+ };
+
+ // 태그 삭제
+ const handleRemoveTag = (tagToRemove: string) => {
+ setValue(
+ 'tags',
+ watchedTags.filter((tag) => tag !== tagToRemove),
+ );
+ };
+
+ // 폼 제출
+ const handleFormSubmit = async (data: VotingEditFormData) => {
+ setIsSubmitting(true);
+ try {
+ // Only pass editable fields
+ await onSubmit({
+ title: data.title,
+ description: data.description,
+ tags: data.tags || [], // tags가 undefined일 경우 빈 배열로 전달
+ });
+ onClose();
+ } catch (error) {
+ console.error('투표 수정 실패:', error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* 헤더 */}
+
+
투표 주제 수정하기
+
+
+
+
+
+ {/* 폼 */}
+
+ {/* 제목 */}
+
+
+ 제목 *
+
+
+
+ {errors.title && (
+
+ {errors.title.message}
+
+ )}
+
+ {watchedTitle.length}/200
+
+
+
+
+ {/* 설명 */}
+
+
+ 설명 (선택)
+
+
+
+ {errors.description && (
+
+ {errors.description.message}
+
+ )}
+
+ {watchedDescription.length}/500
+
+
+
+
+ {/* 태그 */}
+
+
+ 태그 (선택)
+
+ (최대 3개)
+
+
+
+ setTagInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddTag();
+ }
+ }}
+ placeholder="태그 입력 후 Enter"
+ disabled={watchedTags.length >= 3}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14r focus:border-border-brand flex-1 border px-300 py-200 transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-50"
+ />
+ = 3 || !tagInput.trim()}
+ className="rounded-100 bg-fill-brand-default-default font-designer-13b text-text-inverse hover:bg-fill-brand-default-hover px-300 py-200 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ 추가
+
+
+ {watchedTags.length > 0 && (
+
+ {watchedTags.map((tag) => (
+
+
+ #{tag}
+
+ handleRemoveTag(tag)}
+ className="text-text-subtle hover:text-text-strong transition-colors"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 알림 메시지 */}
+
+
+ 선택지와 마감일은 수정할 수 없습니다.
+
+
+
+ {/* 액션 버튼 */}
+
+
+ 취소
+
+
+ {isSubmitting ? (
+
+
+ 수정 중...
+
+ ) : (
+ '수정 완료'
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/features/auth/api/test-login.ts b/src/features/auth/api/test-login.ts
new file mode 100644
index 00000000..b16ec59d
--- /dev/null
+++ b/src/features/auth/api/test-login.ts
@@ -0,0 +1,18 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export interface TestLoginResponse {
+ accessToken: string;
+ memberId: string;
+ profileImageUrl?: string;
+}
+
+export const testLogin = async (
+ memberId: number,
+): Promise => {
+ const { data } = await axiosInstance.post<{ content: TestLoginResponse }>(
+ '/growth/test/login',
+ { memberId },
+ );
+
+ return data.content;
+};
diff --git a/src/features/auth/ui/login-modal.tsx b/src/features/auth/ui/login-modal.tsx
index 0c4f9722..15143752 100644
--- a/src/features/auth/ui/login-modal.tsx
+++ b/src/features/auth/ui/login-modal.tsx
@@ -3,8 +3,11 @@
import { sendGTMEvent } from '@next/third-parties/google';
import { XIcon } from 'lucide-react';
import Image from 'next/image';
+import { useRouter } from 'next/navigation';
import { ReactNode, useEffect, useState } from 'react';
+import { setCookie } from '@/api/client/cookie';
import { Modal } from '@/components/ui/modal';
+import { testLogin } from '@/features/auth/api/test-login';
import { getAttributionParams } from '@/utils/attribution-tracker';
export default function LoginModal({
@@ -14,6 +17,42 @@ export default function LoginModal({
}) {
const [state, setState] = useState(null);
const [isOpen, setIsOpen] = useState(false);
+ const router = useRouter();
+
+ // Test Login State
+ const [testMemberId, setTestMemberId] = useState('1');
+ const [isTestLoading, setIsTestLoading] = useState(false);
+ const isDev =
+ process.env.NODE_ENV === 'development' ||
+ process.env.NEXT_PUBLIC_ENABLE_TEST_LOGIN === 'true';
+
+ const handleTestLogin = async () => {
+ if (!testMemberId) return;
+
+ setIsTestLoading(true);
+ try {
+ const { accessToken, memberId, profileImageUrl } = await testLogin(
+ Number(testMemberId),
+ );
+
+ setCookie('accessToken', accessToken);
+ setCookie('memberId', memberId);
+ if (profileImageUrl) {
+ setCookie('socialImageURL', profileImageUrl);
+ }
+
+ setIsOpen(false);
+ router.push('/home');
+ router.refresh();
+
+ // alert(`테스트 로그인 성공! (ID: ${memberId})`);
+ } catch (error) {
+ console.error('Test login failed:', error);
+ alert('테스트 로그인 실패 (백엔드 실행 여부를 확인하세요)');
+ } finally {
+ setIsTestLoading(false);
+ }
+ };
useEffect(() => {
const origin = window.location.origin;
@@ -124,6 +163,37 @@ export default function LoginModal({
+
+ {/* Test Login Section (Dev Only) */}
+ {isDev && (
+
+
+
+ 🧪 개발자 테스트 로그인
+
+
+
+ setTestMemberId(e.target.value)}
+ placeholder="Member ID"
+ className="border-border-default focus:ring-fill-brand-default-default w-full max-w-[120px] rounded-md border px-3 py-1 text-sm focus:ring-2 focus:outline-none"
+ min="1"
+ />
+
+ {isTestLoading ? '...' : '로그인'}
+
+
+
+ * 로컬/개발 환경에서만 보입니다 (Member ID 입력)
+
+
+ )}
diff --git a/src/features/auth/ui/sign-up-modal.tsx b/src/features/auth/ui/sign-up-modal.tsx
index 9a9f70e7..7081e1ce 100644
--- a/src/features/auth/ui/sign-up-modal.tsx
+++ b/src/features/auth/ui/sign-up-modal.tsx
@@ -115,7 +115,6 @@ export default function SignupModal({
const memberId = content?.generatedMemberId;
const accessToken = content?.accessToken;
const refreshToken = content?.refreshToken;
-
if (memberId && accessToken && refreshToken) {
setCookie('memberId', memberId);
setCookie('accessToken', accessToken);
diff --git a/src/features/study/one-to-one/archive/api/get-archive.server.ts b/src/features/study/one-to-one/archive/api/get-archive.server.ts
new file mode 100644
index 00000000..b7fdf112
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/get-archive.server.ts
@@ -0,0 +1,13 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import { ArchiveResponse, GetArchiveParams } from '@/types/archive';
+
+export const getArchiveServer = async (params: GetArchiveParams) => {
+ const { data } = await axiosServerInstance.get<{ content: ArchiveResponse }>(
+ '/archive',
+ {
+ params,
+ },
+ );
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/api/get-archive.ts b/src/features/study/one-to-one/archive/api/get-archive.ts
new file mode 100644
index 00000000..0c33f199
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/get-archive.ts
@@ -0,0 +1,13 @@
+import { axiosInstance } from '@/api/client/axios';
+import { ArchiveResponse, GetArchiveParams } from '@/types/archive';
+
+export const getArchive = async (params: GetArchiveParams) => {
+ const { data } = await axiosInstance.get<{ content: ArchiveResponse }>(
+ '/archive',
+ {
+ params,
+ },
+ );
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/api/record-view.ts b/src/features/study/one-to-one/archive/api/record-view.ts
new file mode 100644
index 00000000..d947cdd2
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/record-view.ts
@@ -0,0 +1,5 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export const recordArchiveView = async (id: number) => {
+ await axiosInstance.post(`/archive/${id}/view`);
+};
diff --git a/src/features/study/one-to-one/archive/api/toggle-bookmark.ts b/src/features/study/one-to-one/archive/api/toggle-bookmark.ts
new file mode 100644
index 00000000..160dfbf9
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/toggle-bookmark.ts
@@ -0,0 +1,9 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export const toggleArchiveBookmark = async (id: number) => {
+ const { data } = await axiosInstance.post<{
+ content: { isBookmarked: boolean };
+ }>(`/archive/${id}/bookmark`);
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/api/toggle-like.ts b/src/features/study/one-to-one/archive/api/toggle-like.ts
new file mode 100644
index 00000000..ac806d83
--- /dev/null
+++ b/src/features/study/one-to-one/archive/api/toggle-like.ts
@@ -0,0 +1,9 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export const toggleArchiveLike = async (id: number) => {
+ const { data } = await axiosInstance.post<{ content: { isLiked: boolean } }>(
+ `/archive/${id}/like`,
+ );
+
+ return data.content;
+};
diff --git a/src/features/study/one-to-one/archive/const/archive.ts b/src/features/study/one-to-one/archive/const/archive.ts
new file mode 100644
index 00000000..92bae807
--- /dev/null
+++ b/src/features/study/one-to-one/archive/const/archive.ts
@@ -0,0 +1,15 @@
+export const ARCHIVE_SORT_OPTIONS = [
+ { value: 'LATEST', label: '최신순' },
+ { value: 'VIEWS', label: '조회순' },
+ { value: 'LIKES', label: '좋아요순' },
+] as const;
+
+export const ARCHIVE_VIEW_MODES = {
+ GRID: 'GRID',
+ LIST: 'LIST',
+} as const;
+
+export const ARCHIVE_PAGE_SIZE = {
+ GRID: 10,
+ LIST: 15,
+} as const;
diff --git a/src/features/study/one-to-one/archive/model/use-archive-actions.ts b/src/features/study/one-to-one/archive/model/use-archive-actions.ts
new file mode 100644
index 00000000..badc8382
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-archive-actions.ts
@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+import { useToggleArchiveBookmarkMutation } from '@/features/study/one-to-one/archive/model/use-bookmark-mutation';
+import { useToggleArchiveLikeMutation } from '@/features/study/one-to-one/archive/model/use-like-mutation';
+import { useRecordArchiveViewMutation } from '@/features/study/one-to-one/archive/model/use-view-mutation';
+interface ArchiveViewTarget {
+ id: number;
+ link: string;
+}
+
+export const useArchiveActions = () => {
+ const { mutate: toggleBookmark } = useToggleArchiveBookmarkMutation();
+ const { mutate: toggleLike } = useToggleArchiveLikeMutation();
+ const { mutate: recordView } = useRecordArchiveViewMutation();
+
+ const handleToggleBookmark = useCallback(
+ (id: number) => {
+ toggleBookmark(id);
+ },
+ [toggleBookmark],
+ );
+
+ const handleToggleLike = useCallback(
+ (id: number) => {
+ toggleLike(id);
+ },
+ [toggleLike],
+ );
+
+ const openAndRecordView = useCallback(
+ (target: ArchiveViewTarget) => {
+ window.open(target.link, '_blank');
+ recordView(target.id);
+ },
+ [recordView],
+ );
+
+ return {
+ toggleBookmark: handleToggleBookmark,
+ toggleLike: handleToggleLike,
+ openAndRecordView,
+ };
+};
diff --git a/src/features/study/one-to-one/archive/model/use-archive-query.ts b/src/features/study/one-to-one/archive/model/use-archive-query.ts
new file mode 100644
index 00000000..0b1d2cf9
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-archive-query.ts
@@ -0,0 +1,21 @@
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { getArchive } from '@/features/study/one-to-one/archive/api/get-archive';
+import { GetArchiveParams } from '@/types/archive';
+
+export const ARCHIVE_QUERY_KEY = {
+ all: ['archive'] as const,
+ list: (params: GetArchiveParams) =>
+ [...ARCHIVE_QUERY_KEY.all, params] as const,
+};
+
+export const useArchiveQuery = (
+ params: GetArchiveParams,
+ options?: { initialData?: Awaited> },
+) => {
+ return useQuery({
+ queryKey: ARCHIVE_QUERY_KEY.list(params),
+ queryFn: () => getArchive(params),
+ placeholderData: keepPreviousData,
+ initialData: options?.initialData,
+ });
+};
diff --git a/src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts b/src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts
new file mode 100644
index 00000000..32e85880
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts
@@ -0,0 +1,56 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toggleArchiveBookmark } from '@/features/study/one-to-one/archive/api/toggle-bookmark';
+import { ArchiveResponse } from '@/types/archive';
+import { ARCHIVE_QUERY_KEY } from './use-archive-query';
+
+export const useToggleArchiveBookmarkMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: toggleArchiveBookmark,
+ onMutate: async (id) => {
+ // 진행 중인 쿼리 취소
+ await queryClient.cancelQueries({ queryKey: ARCHIVE_QUERY_KEY.all });
+
+ // 이전 데이터 스냅샷
+ const previousData = queryClient.getQueriesData({
+ queryKey: ARCHIVE_QUERY_KEY.all,
+ });
+
+ // 낙관적 업데이트
+ queryClient.setQueriesData(
+ { queryKey: ARCHIVE_QUERY_KEY.all },
+ (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ content: oldData.content.map((item) =>
+ item.id === id
+ ? { ...item, isBookmarked: !item.isBookmarked }
+ : item,
+ ),
+ };
+ },
+ );
+
+ return { previousData };
+ },
+ onError: (err, id, context) => {
+ // 에러 시 롤백
+ if (context?.previousData) {
+ context.previousData.forEach(([queryKey, data]) => {
+ queryClient.setQueryData(queryKey, data);
+ });
+ }
+ },
+ onSettled: () => {
+ // 완료 후 리프레시
+ queryClient
+ .invalidateQueries({ queryKey: ARCHIVE_QUERY_KEY.all })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/archive/model/use-like-mutation.ts b/src/features/study/one-to-one/archive/model/use-like-mutation.ts
new file mode 100644
index 00000000..1735f0b5
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-like-mutation.ts
@@ -0,0 +1,60 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toggleArchiveLike } from '@/features/study/one-to-one/archive/api/toggle-like';
+import { ArchiveResponse } from '@/types/archive';
+import { ARCHIVE_QUERY_KEY } from './use-archive-query';
+
+export const useToggleArchiveLikeMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: toggleArchiveLike,
+ onMutate: async (id) => {
+ // 진행 중인 쿼리 취소
+ await queryClient.cancelQueries({ queryKey: ARCHIVE_QUERY_KEY.all });
+
+ // 이전 데이터 스냅샷
+ const previousData = queryClient.getQueriesData({
+ queryKey: ARCHIVE_QUERY_KEY.all,
+ });
+
+ // 낙관적 업데이트
+ queryClient.setQueriesData(
+ { queryKey: ARCHIVE_QUERY_KEY.all },
+ (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ content: oldData.content.map((item) =>
+ item.id === id
+ ? {
+ ...item,
+ isLiked: !item.isLiked,
+ likes: item.isLiked ? item.likes - 1 : item.likes + 1,
+ }
+ : item,
+ ),
+ };
+ },
+ );
+
+ return { previousData };
+ },
+ onError: (err, id, context) => {
+ // 에러 시 롤백
+ if (context?.previousData) {
+ context.previousData.forEach(([queryKey, data]) => {
+ queryClient.setQueryData(queryKey, data);
+ });
+ }
+ },
+ onSettled: () => {
+ // 완료 후 리프레시
+ queryClient
+ .invalidateQueries({ queryKey: ARCHIVE_QUERY_KEY.all })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/archive/model/use-view-mutation.ts b/src/features/study/one-to-one/archive/model/use-view-mutation.ts
new file mode 100644
index 00000000..e27d5178
--- /dev/null
+++ b/src/features/study/one-to-one/archive/model/use-view-mutation.ts
@@ -0,0 +1,81 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { recordArchiveView } from '@/features/study/one-to-one/archive/api/record-view';
+import { ArchiveResponse } from '@/types/archive';
+import { ARCHIVE_QUERY_KEY } from './use-archive-query';
+
+const VIEWED_ARCHIVES_KEY = 'viewed_archives';
+
+// localStorage에서 조회한 아카이브 목록 가져오기
+const getViewedArchives = (): Record => {
+ try {
+ const stored = localStorage.getItem(VIEWED_ARCHIVES_KEY);
+
+ return stored ? JSON.parse(stored) : {};
+ } catch {
+ return {};
+ }
+};
+
+// localStorage에 조회 기록 저장
+const setViewedArchive = (id: number) => {
+ try {
+ const viewed = getViewedArchives();
+ viewed[id] = Date.now();
+ localStorage.setItem(VIEWED_ARCHIVES_KEY, JSON.stringify(viewed));
+ } catch (error) {
+ console.error('Failed to save view record:', error);
+ }
+};
+
+// 이미 조회했는지 확인
+const hasViewed = (id: number): boolean => {
+ const viewed = getViewedArchives();
+
+ return !!viewed[id];
+};
+
+export const useRecordArchiveViewMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: number) => {
+ // 이미 조회한 아카이브인지 확인
+ if (hasViewed(id)) {
+ console.log(`Archive ${id} already viewed, skipping API call`);
+
+ return; // API 호출하지 않음
+ }
+
+ // API 호출
+ await recordArchiveView(id);
+
+ // localStorage에 기록
+ setViewedArchive(id);
+ },
+ onMutate: async (id) => {
+ // 이미 조회한 경우 낙관적 업데이트도 스킵
+ if (hasViewed(id)) {
+ return;
+ }
+
+ // 낙관적 업데이트: 조회수 즉시 +1
+ queryClient.setQueriesData(
+ { queryKey: ARCHIVE_QUERY_KEY.all },
+ (oldData) => {
+ if (!oldData) return oldData;
+
+ return {
+ ...oldData,
+ content: oldData.content.map((item) =>
+ item.id === id ? { ...item, views: item.views + 1 } : item,
+ ),
+ };
+ },
+ );
+ },
+ // 에러 발생해도 무시 (Fire-and-forget)
+ onError: () => {
+ // 조용히 실패 처리
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/archive/ui/archive-filters.tsx b/src/features/study/one-to-one/archive/ui/archive-filters.tsx
new file mode 100644
index 00000000..bd82ddb4
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-filters.tsx
@@ -0,0 +1,122 @@
+import { ArrowUpDown, Bookmark, LayoutGrid, List, Search } from 'lucide-react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import {
+ ARCHIVE_SORT_OPTIONS,
+ ARCHIVE_VIEW_MODES,
+} from '@/features/study/one-to-one/archive/const/archive';
+
+interface ArchiveFiltersProps {
+ librarySort: 'LATEST' | 'VIEWS' | 'LIKES';
+ onSortChange: (sort: 'LATEST' | 'VIEWS' | 'LIKES') => void;
+ viewMode: 'GRID' | 'LIST';
+ onViewModeChange: (mode: 'GRID' | 'LIST') => void;
+ searchTerm: string;
+ onSearchChange: (value: string) => void;
+ showBookmarkedOnly: boolean;
+ onToggleBookmarkedOnly: () => void;
+}
+
+export default function ArchiveFilters({
+ librarySort,
+ onSortChange,
+ viewMode,
+ onViewModeChange,
+ searchTerm,
+ onSearchChange,
+ showBookmarkedOnly,
+ onToggleBookmarkedOnly,
+}: ArchiveFiltersProps) {
+ const sortLabel =
+ ARCHIVE_SORT_OPTIONS.find((option) => option.value === librarySort)
+ ?.label ?? '최신순';
+
+ return (
+
+
+
+
+
+ 북마크
+
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14m focus:border-border-default focus:ring-fill-neutral-default-default h-600 w-full border pr-500 pl-200 transition-all outline-none focus:ring-2"
+ />
+
+
+
+
+
+
+
+
+
+ {sortLabel}
+
+
+
+
+ {ARCHIVE_SORT_OPTIONS.map((option) => (
+ onSortChange(option.value)}
+ className="hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors"
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+ onViewModeChange(ARCHIVE_VIEW_MODES.GRID)}
+ className={cn(
+ 'rounded-75 p-100 transition-colors',
+ viewMode === ARCHIVE_VIEW_MODES.GRID
+ ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
+ : 'text-text-subtlest hover:text-text-subtle',
+ )}
+ title="2열 보기"
+ >
+
+
+ onViewModeChange(ARCHIVE_VIEW_MODES.LIST)}
+ className={cn(
+ 'rounded-75 p-100 transition-colors',
+ viewMode === ARCHIVE_VIEW_MODES.LIST
+ ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
+ : 'text-text-subtlest hover:text-text-subtle',
+ )}
+ title="1열 보기 (촘촘하게)"
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-grid.tsx b/src/features/study/one-to-one/archive/ui/archive-grid.tsx
new file mode 100644
index 00000000..fea248bd
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-grid.tsx
@@ -0,0 +1,141 @@
+import { Bookmark, Eye, Heart, Search } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ArchiveItem } from '@/types/archive';
+
+interface ArchiveGridProps {
+ items: ArchiveItem[];
+ isAdmin: boolean;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+}
+
+const LibraryCard = ({
+ item,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+ isAdmin,
+}: {
+ item: ArchiveItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+ isAdmin?: boolean;
+}) => {
+ const isHidden = (item as any).isHidden;
+
+ return (
+ onView(item)}
+ className={cn(
+ 'rounded-200 border-border-subtle bg-background-default shadow-1 hover:shadow-2 flex h-full cursor-pointer flex-col gap-250 border p-400 transition-all hover:-translate-y-50',
+ isHidden && 'opacity-50',
+ )}
+ >
+
+
+ {isHidden && (
+
+ 숨김됨
+
+ )}
+
+
+ {isAdmin && onHide && (
+ onHide(e, item.id)}
+ className="rounded-100 font-designer-12m bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover flex items-center gap-50 px-150 py-50 transition-colors"
+ >
+ {isHidden ? '보이기' : '숨기기'}
+
+ )}
+ onBookmark(e, item.id)}
+ className="font-designer-12r hover:bg-fill-neutral-subtle-hover flex items-center gap-50 rounded-full p-1 transition-transform hover:scale-110"
+ >
+
+
+
+
+
+
+
+ {item.title}
+
+
+
+
+
+ by{' '}
+ {item.author}
+
+
+
+
+ {item.views.toLocaleString()}
+
+
onLike(e, item.id)}
+ className="font-designer-12r flex items-center gap-50 rounded-full p-1 transition-transform hover:scale-110 hover:bg-red-50"
+ >
+
+
+ {item.likes.toLocaleString()}
+
+
+
+
+
+ );
+};
+
+export default function ArchiveGrid({
+ items,
+ isAdmin,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+}: ArchiveGridProps) {
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-header.tsx b/src/features/study/one-to-one/archive/ui/archive-header.tsx
new file mode 100644
index 00000000..aa5a0dfe
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-header.tsx
@@ -0,0 +1,33 @@
+import { LibraryBig } from 'lucide-react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface ArchiveHeaderProps {
+ isAdmin: boolean;
+ onToggleAdmin: () => void;
+}
+
+export default function ArchiveHeader({
+ isAdmin,
+ onToggleAdmin,
+}: ArchiveHeaderProps) {
+ return (
+
+
+ 제로원 아카이브
+
+
+
+
+ {isAdmin ? 'Admin Mode ON' : 'Admin'}
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-list.tsx b/src/features/study/one-to-one/archive/ui/archive-list.tsx
new file mode 100644
index 00000000..8d452997
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-list.tsx
@@ -0,0 +1,146 @@
+import { Bookmark, Eye, Heart, Search } from 'lucide-react';
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ArchiveItem } from '@/types/archive';
+
+interface ArchiveListProps {
+ items: ArchiveItem[];
+ isAdmin: boolean;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+}
+
+const LibraryRow = ({
+ item,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+ isAdmin,
+}: {
+ item: ArchiveItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (item: ArchiveItem) => void;
+ onBookmark: (e: React.MouseEvent, id: number) => void;
+ onHide?: (e: React.MouseEvent, id: number) => void;
+ isAdmin?: boolean;
+}) => {
+ const isHidden = (item as any).isHidden;
+
+ return (
+ onView(item)}
+ className={cn(
+ 'group border-border-subtlest hover:bg-fill-neutral-subtle-hover flex cursor-pointer items-center gap-300 border-b px-300 py-200 transition-colors last:border-0',
+ isHidden && 'opacity-50',
+ )}
+ >
+
+
+
+ {item.title}
+
+ {isHidden && (
+
+ 숨김됨
+
+ )}
+
+
+ {item.author}
+
+ {item.date}
+
+
+
+
+ {isAdmin && onHide && (
+
{
+ e.stopPropagation();
+ onHide(e, item.id);
+ }}
+ className="rounded-100 font-designer-11m bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover flex items-center gap-25 px-100 py-50 transition-colors"
+ >
+ {isHidden ? '보이기' : '숨기기'}
+
+ )}
+
onBookmark(e, item.id)}
+ className="font-designer-12r hover:bg-fill-neutral-subtle-hover flex items-center gap-50 rounded-full p-1 transition-transform hover:scale-110"
+ >
+
+
+
+
+
+ {item.views.toLocaleString()}
+
+
+
onLike(e, item.id)}
+ className="font-designer-13m flex min-w-[50px] items-center justify-end gap-50 rounded-full p-1 transition-transform hover:scale-110 hover:bg-red-50"
+ >
+
+
+ {item.likes.toLocaleString()}
+
+
+
+
+ );
+};
+
+export default function ArchiveList({
+ items,
+ isAdmin,
+ onLike,
+ onView,
+ onBookmark,
+ onHide,
+}: ArchiveListProps) {
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-tab-client.tsx b/src/features/study/one-to-one/archive/ui/archive-tab-client.tsx
new file mode 100644
index 00000000..275d5841
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-tab-client.tsx
@@ -0,0 +1,175 @@
+'use client';
+
+import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
+import React, { useState } from 'react';
+import {
+ ARCHIVE_PAGE_SIZE,
+ ARCHIVE_VIEW_MODES,
+} from '@/features/study/one-to-one/archive/const/archive';
+import { useArchiveActions } from '@/features/study/one-to-one/archive/model/use-archive-actions';
+import { useArchiveQuery } from '@/features/study/one-to-one/archive/model/use-archive-query';
+import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';
+import { useDebounce } from '@/hooks/use-debounce'; // Assuming this hook exists, or I will create it/use raw
+import {
+ ArchiveItem,
+ ArchiveResponse,
+ GetArchiveParams,
+} from '@/types/archive';
+import ArchiveFilters from './archive-filters';
+import ArchiveGrid from './archive-grid';
+import ArchiveHeader from './archive-header';
+import ArchiveList from './archive-list';
+
+// ----------------------------------------------------------------------
+// Main Component
+// ----------------------------------------------------------------------
+
+interface ArchiveTabClientProps {
+ initialData?: ArchiveResponse;
+ initialParams: GetArchiveParams;
+}
+
+export default function ArchiveTabClient({
+ initialData,
+ initialParams,
+}: ArchiveTabClientProps) {
+ const [librarySort, setLibrarySort] = useState<'LATEST' | 'VIEWS' | 'LIKES'>(
+ 'LATEST',
+ );
+ const [viewMode, setViewMode] = useState<'GRID' | 'LIST'>(
+ ARCHIVE_VIEW_MODES.GRID,
+ );
+ const [currentPage, setCurrentPage] = useState(1);
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearchTerm = useDebounce(searchTerm, 500);
+
+ // New States
+ const [showBookmarkedOnly, setShowBookmarkedOnly] = useState(false);
+ const [isAdmin, setIsAdmin] = useState(false); // Mock Admin Mode
+
+ const ITEMS_PER_PAGE =
+ viewMode === ARCHIVE_VIEW_MODES.LIST
+ ? ARCHIVE_PAGE_SIZE.LIST
+ : ARCHIVE_PAGE_SIZE.GRID;
+
+ // React Query Hook
+ const archiveParams = {
+ page: currentPage - 1,
+ size: ITEMS_PER_PAGE,
+ sort: librarySort,
+ search: debouncedSearchTerm || undefined,
+ bookmarkedOnly: showBookmarkedOnly || undefined,
+ };
+
+ const shouldUseInitialData =
+ archiveParams.page === initialParams.page &&
+ archiveParams.size === initialParams.size &&
+ archiveParams.sort === initialParams.sort &&
+ archiveParams.search === initialParams.search &&
+ archiveParams.bookmarkedOnly === initialParams.bookmarkedOnly;
+
+ const { data: archiveData, isLoading } = useArchiveQuery(archiveParams, {
+ initialData: shouldUseInitialData ? initialData : undefined,
+ });
+
+ const { toggleBookmark, toggleLike, openAndRecordView } = useArchiveActions();
+
+ const libraryItems = archiveData?.content || [];
+ const totalPages = archiveData?.totalPages || 1;
+
+ // Handler for Likes
+ const handleLike = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ toggleLike(id);
+ };
+
+ const handleView = (item: ArchiveItem) => {
+ openAndRecordView(item);
+ };
+
+ const handleLibraryBookmark = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ toggleBookmark(id);
+ };
+
+ const handleHide = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation();
+ // TODO: Implement Hide Mutation (Admin Only)
+ console.log('Hide', id);
+ };
+
+ return (
+
+
setIsAdmin(!isAdmin)}
+ />
+
+
+ setShowBookmarkedOnly(!showBookmarkedOnly)
+ }
+ />
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {/* Library Content */}
+ {viewMode === ARCHIVE_VIEW_MODES.GRID ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
setCurrentPage(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+
+ setCurrentPage(Math.min(totalPages, currentPage + 1))
+ }
+ disabled={currentPage === totalPages}
+ >
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/study/one-to-one/archive/ui/archive-tab.tsx b/src/features/study/one-to-one/archive/ui/archive-tab.tsx
new file mode 100644
index 00000000..ef75951d
--- /dev/null
+++ b/src/features/study/one-to-one/archive/ui/archive-tab.tsx
@@ -0,0 +1,18 @@
+import { getArchiveServer } from '@/features/study/one-to-one/archive/api/get-archive.server';
+import { ARCHIVE_PAGE_SIZE } from '@/features/study/one-to-one/archive/const/archive';
+import { GetArchiveParams } from '@/types/archive';
+import ArchiveTabClient from './archive-tab-client';
+
+export default async function ArchiveTab() {
+ const initialParams: GetArchiveParams = {
+ page: 0,
+ size: ARCHIVE_PAGE_SIZE.GRID,
+ sort: 'LATEST',
+ };
+
+ const initialData = await getArchiveServer(initialParams);
+
+ return (
+
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts b/src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts
new file mode 100644
index 00000000..99aa8793
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts
@@ -0,0 +1,65 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import type {
+ ApiResponse,
+ BalanceGame,
+ BalanceGameCommentListResponse,
+ BalanceGameListResponse,
+} from '@/types/balance-game';
+
+export const getBalanceGameListServer = async (params: {
+ page?: number;
+ size?: number;
+ sort?: 'latest' | 'popular';
+ status?: 'active' | 'closed';
+}): Promise => {
+ const { page = 1, size = 10, sort = 'latest', status } = params;
+
+ const response = await axiosServerInstance.get<
+ ApiResponse
+ >('/balance-games', {
+ params: {
+ page,
+ limit: size,
+ sort,
+ status,
+ },
+ });
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGameListResponse;
+};
+
+export const getBalanceGameDetailServer = async (
+ gameId: number,
+): Promise => {
+ const response = await axiosServerInstance.get>(
+ `/balance-games/${gameId}`,
+ );
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGame;
+};
+
+export const getBalanceGameCommentsServer = async (
+ gameId: number,
+ params: { page?: number; size?: number },
+): Promise => {
+ const { page = 0, size = 10 } = params;
+ const response = await axiosServerInstance.get<
+ ApiResponse
+ >(`/balance-games/${gameId}/comments`, {
+ params: { page, size },
+ });
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGameCommentListResponse;
+};
diff --git a/src/features/study/one-to-one/balance-game/api/balance-game-api.ts b/src/features/study/one-to-one/balance-game/api/balance-game-api.ts
new file mode 100644
index 00000000..0fa9a16a
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/api/balance-game-api.ts
@@ -0,0 +1,155 @@
+import { axiosInstance } from '@/api/client/axios';
+import {
+ ApiResponse,
+ BalanceGame,
+ BalanceGameCommentListResponse,
+ BalanceGameListResponse,
+ CreateBalanceGameRequest,
+ CreateCommentRequest,
+ UpdateBalanceGameRequest,
+ UpdateCommentRequest,
+ VoteRequest,
+} from '@/types/balance-game';
+
+// 1. 밸런스 게임 목록 조회
+export const getBalanceGameList = async (params: {
+ page?: number;
+ size?: number;
+ sort?: 'latest' | 'popular';
+ status?: 'active' | 'closed';
+}): Promise => {
+ // 백엔드는 page를 1부터 시작하고 limit을 사용함
+ const { page = 1, size = 10, sort = 'latest', status } = params;
+
+ const response = await axiosInstance.get<
+ ApiResponse
+ >('/balance-games', {
+ params: {
+ page,
+ limit: size, // 백엔드는 limit 파라미터를 사용
+ sort,
+ status,
+ },
+ });
+
+ // content 필드 사용 (실제 백엔드 응답 구조 반영)
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ // Fallback: 혹시 content 없이 바로 데이터가 오는 경우
+ return response.data as unknown as BalanceGameListResponse;
+};
+
+// 2. 밸런스 게임 상세 조회
+export const getBalanceGameDetail = async (
+ gameId: number,
+): Promise => {
+ const response = await axiosInstance.get>(
+ `/balance-games/${gameId}`,
+ );
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGame;
+};
+
+// 3. 밸런스 게임 생성
+export const createBalanceGame = async (
+ body: CreateBalanceGameRequest,
+): Promise => {
+ const response = await axiosInstance.post>(
+ '/balance-games',
+ body,
+ );
+
+ return response.data.content;
+};
+
+// 4. 투표하기
+export const voteBalanceGame = async (
+ gameId: number,
+ body: VoteRequest,
+): Promise => {
+ await axiosInstance.post>(
+ `/balance-games/${gameId}/votes`,
+ body,
+ );
+};
+
+// 5. 투표 취소
+export const cancelVoteBalanceGame = async (gameId: number): Promise => {
+ await axiosInstance.delete>(
+ `/balance-games/${gameId}/votes`,
+ );
+};
+
+// 6. 댓글 목록 조회
+export const getBalanceGameComments = async (
+ gameId: number,
+ params: { page?: number; size?: number },
+): Promise => {
+ const { page = 0, size = 10 } = params;
+
+ const response = await axiosInstance.get<
+ ApiResponse
+ >(`/balance-games/${gameId}/comments`, {
+ params: { page, size },
+ });
+
+ if (response.data && 'content' in response.data) {
+ return response.data.content;
+ }
+
+ return response.data as unknown as BalanceGameCommentListResponse;
+};
+
+// 7. 댓글 작성
+export const createBalanceGameComment = async (
+ gameId: number,
+ body: CreateCommentRequest,
+): Promise => {
+ const { data } = await axiosInstance.post>(
+ `/balance-games/${gameId}/comments`,
+ body,
+ );
+
+ return data.content;
+};
+
+// 8. 댓글 수정
+export const updateBalanceGameComment = async (
+ gameId: number,
+ commentId: number,
+ body: UpdateCommentRequest,
+): Promise => {
+ await axiosInstance.put>(
+ `/balance-games/${gameId}/comments/${commentId}`,
+ body,
+ );
+};
+
+// 9. 댓글 삭제
+export const deleteBalanceGameComment = async (
+ gameId: number,
+ commentId: number,
+): Promise => {
+ await axiosInstance.delete>(
+ `/balance-games/${gameId}/comments/${commentId}`,
+ );
+};
+
+// 10. 밸런스 게임 수정
+export const updateBalanceGame = async (
+ gameId: number,
+ body: UpdateBalanceGameRequest,
+): Promise => {
+ await axiosInstance.put>(`/balance-games/${gameId}`, body);
+};
+
+// 11. 밸런스 게임 삭제
+export const deleteBalanceGame = async (gameId: number): Promise => {
+ await axiosInstance.delete>(`/balance-games/${gameId}`);
+};
diff --git a/src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts b/src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts
new file mode 100644
index 00000000..ac9a0ac6
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts
@@ -0,0 +1,184 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UpdateBalanceGameRequest } from '@/types/balance-game';
+import { BALANCE_GAME_KEYS } from './use-balance-game-query';
+import {
+ cancelVoteBalanceGame,
+ createBalanceGame,
+ createBalanceGameComment,
+ deleteBalanceGame,
+ deleteBalanceGameComment,
+ updateBalanceGame,
+ updateBalanceGameComment,
+ voteBalanceGame,
+} from '../api/balance-game-api';
+
+export const useCreateBalanceGameMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: createBalanceGame,
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useVoteBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (optionId: number) => voteBalanceGame(gameId, { optionId }),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useCancelVoteBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => cancelVoteBalanceGame(gameId),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useCreateBalanceGameCommentMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (content: string) =>
+ createBalanceGameComment(gameId, { content }),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useUpdateBalanceGameCommentMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ commentId,
+ content,
+ }: {
+ commentId: number;
+ content: string;
+ }) => updateBalanceGameComment(gameId, commentId, { content }),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useDeleteBalanceGameCommentMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (commentId: number) =>
+ deleteBalanceGameComment(gameId, commentId),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useUpdateBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (body: UpdateBalanceGameRequest) =>
+ updateBalanceGame(gameId, body),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ },
+ });
+};
+
+export const useDeleteBalanceGameMutation = (gameId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => deleteBalanceGame(gameId),
+ onSuccess: () => {
+ queryClient
+ .invalidateQueries({ queryKey: BALANCE_GAME_KEYS.lists() })
+ .catch(() => {
+ // 쿼리 무효화 실패 시 무시
+ });
+ queryClient.removeQueries({ queryKey: BALANCE_GAME_KEYS.detail(gameId) });
+ },
+ });
+};
diff --git a/src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts b/src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts
new file mode 100644
index 00000000..bf1eb080
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts
@@ -0,0 +1,102 @@
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
+import {
+ getBalanceGameComments,
+ getBalanceGameDetail,
+ getBalanceGameList,
+} from '../api/balance-game-api';
+
+export const BALANCE_GAME_KEYS = {
+ all: ['balanceGames'] as const,
+ lists: () => [...BALANCE_GAME_KEYS.all, 'list'] as const,
+ list: (filters: Record) =>
+ [...BALANCE_GAME_KEYS.lists(), filters] as const,
+ details: () => [...BALANCE_GAME_KEYS.all, 'detail'] as const,
+ detail: (id: number) => [...BALANCE_GAME_KEYS.details(), id] as const,
+ comments: (id: number) =>
+ [...BALANCE_GAME_KEYS.detail(id), 'comments'] as const,
+};
+
+export const useBalanceGameListQuery = (
+ sort: 'latest' | 'popular' = 'latest',
+ status?: 'active' | 'closed',
+ options?: {
+ initialPage?: Awaited>;
+ },
+) => {
+ return useInfiniteQuery({
+ queryKey: BALANCE_GAME_KEYS.list({ sort, status }),
+ queryFn: ({ pageParam = 1 }) =>
+ getBalanceGameList({ page: pageParam, size: 10, sort, status }),
+ getNextPageParam: (lastPage) => {
+ // lastPage가 유효하고 pageable 정보가 있는지 확인
+ if (
+ !lastPage ||
+ !lastPage.pageable ||
+ typeof lastPage.totalPages !== 'number'
+ ) {
+ return undefined;
+ }
+
+ // Spring Data Page의 pageable.pageNumber는 0-based
+ // 백엔드 API는 page 파라미터를 1-based로 받지만, 내부적으로 0-based로 변환
+ // 예: 요청 page=1 → 내부 page=0 → 응답 pageable.pageNumber=0
+ // 요청 page=2 → 내부 page=1 → 응답 pageable.pageNumber=1
+ const currentPageNumber = lastPage.pageable.pageNumber; // 0-based
+ const totalPages = lastPage.totalPages;
+
+ // 다음 페이지가 존재하는지 확인 (0-based 기준)
+ // currentPageNumber는 0-based이므로, totalPages와 비교할 때는 +1 해서 비교
+ if (currentPageNumber + 1 < totalPages) {
+ // 다음 페이지를 1-based로 요청해야 하므로, 0-based + 1 + 1 = +2
+ return currentPageNumber + 2;
+ }
+
+ return undefined;
+ },
+ initialPageParam: 1, // 백엔드는 1부터 시작
+ initialData: options?.initialPage
+ ? { pages: [options.initialPage], pageParams: [1] }
+ : undefined,
+ });
+};
+
+export const useBalanceGameDetailQuery = (gameId: number) => {
+ return useQuery({
+ queryKey: BALANCE_GAME_KEYS.detail(gameId),
+ queryFn: () => getBalanceGameDetail(gameId),
+ enabled: !!gameId,
+ });
+};
+
+export const useBalanceGameCommentsQuery = (
+ gameId: number,
+ options?: { enabled?: boolean },
+) => {
+ return useInfiniteQuery({
+ queryKey: BALANCE_GAME_KEYS.comments(gameId),
+ queryFn: ({ pageParam = 0 }) =>
+ getBalanceGameComments(gameId, { page: pageParam, size: 10 }),
+ getNextPageParam: (lastPage) => {
+ // lastPage가 유효하고 pageable 정보가 있는지 확인
+ if (
+ !lastPage ||
+ !lastPage.pageable ||
+ typeof lastPage.totalPages !== 'number'
+ ) {
+ return undefined;
+ }
+
+ const currentPage = lastPage.pageable.pageNumber;
+ const totalPages = lastPage.totalPages;
+
+ // 다음 페이지가 존재하는지 확인 (현재 페이지 + 1 < 전체 페이지 수)
+ if (currentPage + 1 < totalPages) {
+ return currentPage + 1;
+ }
+
+ return undefined;
+ },
+ initialPageParam: 0,
+ enabled: !!gameId && options?.enabled !== false,
+ });
+};
diff --git a/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx b/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
new file mode 100644
index 00000000..26b3ce1d
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
@@ -0,0 +1,248 @@
+'use client';
+
+import { Loader2, Vote, SearchX, Plus, ArrowUpDown } from 'lucide-react';
+import React, { useState, useEffect, useRef } from 'react';
+import VotingCard from '@/components/card/voting-card';
+import Toast from '@/components/ui/toast';
+import VotingCreateModal from '@/components/voting/voting-create-modal';
+import { useCreateBalanceGameMutation } from '@/features/study/one-to-one/balance-game/model/use-balance-game-mutation';
+import { useBalanceGameListQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import { CreateBalanceGameRequest } from '@/types/balance-game';
+import { VotingCreateFormData } from '@/types/schemas/zod-schema';
+import FilterPillButton from './filter-pill-button';
+
+export default function BalanceGamePage() {
+ // 상태 관리
+ const [statusFilter, setStatusFilter] = useState<'active' | 'closed' | 'all'>(
+ 'active',
+ );
+ const [sortMode, setSortMode] = useState<'latest' | 'popular'>('latest');
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [showToast, setShowToast] = useState(false);
+
+ // React Query Hooks
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetching,
+ isFetchingNextPage,
+ status,
+ isPending,
+ error,
+ } = useBalanceGameListQuery(
+ sortMode,
+ statusFilter === 'all' ? undefined : statusFilter,
+ );
+
+ const createMutation = useCreateBalanceGameMutation();
+
+ // 무한 스크롤용 ref
+ const observerTarget = useRef(null);
+
+ // 투표 생성 핸들러
+ const handleCreateVoting = async (data: VotingCreateFormData) => {
+ try {
+ const requestBody: CreateBalanceGameRequest = {
+ title: data.title,
+ description: data.description || '',
+ options: data.options.map((opt) => opt.label),
+ endsAt:
+ data.endsAt && data.endsAt.trim() !== '' ? data.endsAt : undefined,
+ tags: data.tags || [],
+ };
+
+ await createMutation.mutateAsync(requestBody);
+ setIsCreateModalOpen(false);
+ setShowToast(true);
+ } catch (error) {
+ console.error('투표 생성 실패:', error);
+ throw error;
+ }
+ };
+
+ // 무한 스크롤 Intersection Observer
+ useEffect(() => {
+ const currentTarget = observerTarget.current;
+ if (!currentTarget) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (
+ entries[0].isIntersecting &&
+ hasNextPage &&
+ !isFetchingNextPage &&
+ !isFetching
+ ) {
+ fetchNextPage().catch(() => {
+ // 무한 스크롤 실패 시 무시
+ });
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ observer.observe(currentTarget);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]);
+
+ // 로딩 상태 (첫 로드만)
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (status === 'error') {
+ return (
+
+
+
+
+ 데이터를 불러오는데 실패했습니다.
+
+
window.location.reload()}
+ className="rounded-100 bg-fill-brand-default-default font-designer-14b text-text-inverse hover:bg-fill-brand-default-hover px-400 py-200 transition-colors"
+ >
+ 다시 시도
+
+
+
+ );
+ }
+
+ const votings = data?.pages.flatMap((page) => page.content) || [];
+
+ return (
+ <>
+
+
+ {/* 사이드바 + 메인 컨텐츠 */}
+
+ {/* Left Sidebar */}
+
+
+
+
+
+ 밸런스 게임
+
+
+
+ 선택하고 의견을 나눠보세요
+
+
+
+
+ {/* Main Content */}
+
+ {/* 헤더 */}
+
+
+ 선택하고, 의견을 나눠보세요
+
+
+ 다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다.
+
+
+
+ {/* 필터 + 주제 생성 버튼 */}
+
+ {/* 필터(상태) + 정렬 */}
+
+
setStatusFilter('active')}
+ >
+ 진행 중
+
+
setStatusFilter('closed')}
+ >
+ 종료됨
+
+
setStatusFilter('all')}
+ >
+ 전체
+
+
+ {/* divider */}
+
+
+
setSortMode('latest')}
+ >
+ 최신순
+
+
setSortMode('popular')}
+ >
+ 인기순
+
+
+
+ {/* 주제 생성 버튼 */}
+
setIsCreateModalOpen(true)}
+ className="rounded-100 bg-fill-brand-default-default font-designer-14b text-text-inverse hover:bg-fill-brand-default-hover flex items-center gap-100 px-400 py-250 transition-colors"
+ >
+ 새 주제 만들기
+
+
+
+ {/* 카드 리스트 */}
+
+ {votings.map((voting) => (
+
+ ))}
+
+
+ {/* 무한 스크롤 로딩 */}
+
+ {isFetchingNextPage && (
+
+ )}
+ {!hasNextPage && votings.length > 0 && (
+
+ 더 이상 불러올 투표가 없습니다.
+
+ )}
+
+
+
+
+
+
+ {/* Toast */}
+ setShowToast(false)}
+ />
+
+ {/* 모달 */}
+ setIsCreateModalOpen(false)}
+ onSubmit={handleCreateVoting}
+ />
+ >
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx b/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx
new file mode 100644
index 00000000..0d4b723a
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import {
+ Loader2,
+ Vote,
+ SearchX,
+ Plus,
+ MessageSquareText,
+ ArrowUpDown,
+} from 'lucide-react';
+import React, { useState, useEffect, useRef } from 'react';
+import VotingCard from '@/components/card/voting-card';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import Toast from '@/components/ui/toast';
+import VotingCreateModal from '@/components/voting/voting-create-modal';
+import VotingDetailView from '@/components/voting/voting-detail-view';
+import { useCreateBalanceGameMutation } from '@/features/study/one-to-one/balance-game/model/use-balance-game-mutation';
+import { useBalanceGameListQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-query';
+import type {
+ BalanceGameListResponse,
+ CreateBalanceGameRequest,
+} from '@/types/balance-game';
+import { VotingCreateFormData } from '@/types/schemas/zod-schema';
+import FilterPillButton from './filter-pill-button';
+
+interface CommunityTabClientProps {
+ initialList?: BalanceGameListResponse;
+}
+
+export default function CommunityTabClient({
+ initialList,
+}: CommunityTabClientProps) {
+ // 상태 관리
+ const [statusFilter, setStatusFilter] = useState<'active' | 'closed' | 'all'>(
+ 'active',
+ );
+ const [sortMode, setSortMode] = useState<'latest' | 'popular'>('latest');
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [selectedVotingId, setSelectedVotingId] = useState(null);
+ const [showToast, setShowToast] = useState(false);
+
+ // React Query Hooks
+ const shouldUseInitialList =
+ sortMode === 'latest' && statusFilter === 'active';
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetching,
+ isFetchingNextPage,
+ status,
+ isPending,
+ error,
+ } = useBalanceGameListQuery(
+ sortMode,
+ statusFilter === 'all' ? undefined : statusFilter,
+ {
+ initialPage: shouldUseInitialList ? initialList : undefined,
+ },
+ );
+
+ const createMutation = useCreateBalanceGameMutation();
+
+ // 무한 스크롤용 ref
+ const observerTarget = useRef(null);
+
+ // 투표 생성 핸들러
+ const handleCreateVoting = async (data: VotingCreateFormData) => {
+ try {
+ const requestBody: CreateBalanceGameRequest = {
+ title: data.title,
+ description: data.description || '',
+ options: data.options.map((opt) => opt.label),
+ endsAt:
+ data.endsAt && data.endsAt.trim() !== '' ? data.endsAt : undefined,
+ tags: data.tags || [],
+ };
+
+ await createMutation.mutateAsync(requestBody);
+ setIsCreateModalOpen(false);
+ setShowToast(true);
+ } catch (error) {
+ console.error('투표 생성 실패:', error);
+ throw error;
+ }
+ };
+
+ // 무한 스크롤 Intersection Observer
+ useEffect(() => {
+ const currentTarget = observerTarget.current;
+ if (!currentTarget) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (
+ entries[0].isIntersecting &&
+ hasNextPage &&
+ !isFetchingNextPage &&
+ !isFetching
+ ) {
+ fetchNextPage().catch(() => {
+ // 무한 스크롤 실패 시 무시
+ });
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ observer.observe(currentTarget);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]);
+
+ // 상세 화면으로 전환
+ const handleVotingClick = (votingId: number) => {
+ setSelectedVotingId(votingId);
+ };
+
+ // 목록으로 돌아가기
+ const handleBackToList = () => {
+ setSelectedVotingId(null);
+ };
+
+ // 상세 화면이 열려있으면 상세 화면 표시
+ if (selectedVotingId) {
+ return (
+
+
+
+ );
+ }
+
+ // 로딩 상태 (첫 로드만)
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (status === 'error') {
+ return (
+
+
+
+
+ 데이터를 불러오는데 실패했습니다.
+
+
window.location.reload()}
+ className="rounded-100 bg-fill-brand-default-default font-designer-14b text-text-inverse hover:bg-fill-brand-default-hover px-400 py-200 transition-colors"
+ >
+ 다시 시도
+
+
+
+ );
+ }
+
+ const votings = data?.pages.flatMap((page) => page.content) || [];
+
+ return (
+ <>
+
+ {/* Header */}
+
+
+ 밸런스게임
+
+
+
+
+ {/* 헤더 설명 */}
+
+
+ 다양한 주제에 투표하고 댓글로 자유롭게 토론할 수 있습니다.
+
+
+
+ {/* 필터 + 주제 생성 버튼 */}
+
+ {/* 필터(상태) + 정렬 */}
+
+
setStatusFilter('active')}
+ >
+ 진행 중
+
+
setStatusFilter('closed')}
+ >
+ 종료됨
+
+
setStatusFilter('all')}
+ >
+ 전체
+
+
+ {/* divider */}
+
+
+ {/* Sort Dropdown */}
+
+
+
+ {sortMode === 'latest' ? '최신순' : '인기순'}
+
+
+ {/* Dropdown */}
+
+
+ setSortMode('latest')}
+ className={cn(
+ 'hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors',
+ sortMode === 'latest' && 'bg-fill-neutral-subtle-default',
+ )}
+ >
+ 최신순
+
+ setSortMode('popular')}
+ className={cn(
+ 'hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors',
+ sortMode === 'popular' &&
+ 'bg-fill-neutral-subtle-default',
+ )}
+ >
+ 인기순
+
+
+
+
+
+
+ {/* 주제 생성 버튼 */}
+
setIsCreateModalOpen(true)}
+ className="rounded-100 bg-fill-brand-default-default font-designer-13b text-text-inverse shadow-1 hover:bg-fill-brand-default-hover hover:shadow-2 flex items-center gap-100 px-400 py-200 transition-all hover:scale-105"
+ >
+
+ 주제 생성
+
+
+
+ {/* 투표 목록 */}
+ {votings.length === 0 ? (
+
+
+
+
+ 투표가 없습니다
+
+
+ 곧 새로운 투표가 등록될 예정입니다
+
+
+
+ ) : (
+ <>
+
+ {votings.map((voting) => (
+ handleVotingClick(voting.id)}
+ />
+ ))}
+
+
+ {/* 무한 스크롤 트리거 */}
+
+ {isFetchingNextPage && (
+
+
+
+ 불러오는 중...
+
+
+ )}
+ {!hasNextPage && votings.length > 0 && (
+
+ 모든 투표를 불러왔습니다
+
+ )}
+
+ >
+ )}
+
+
+ {/* 주제 생성 모달 */}
+ setIsCreateModalOpen(false)}
+ onSubmit={handleCreateVoting}
+ />
+
+ {/* 토스트 */}
+ setShowToast(false)}
+ />
+ >
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/community-tab.tsx b/src/features/study/one-to-one/balance-game/ui/community-tab.tsx
new file mode 100644
index 00000000..6988f14b
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/community-tab.tsx
@@ -0,0 +1,13 @@
+import { getBalanceGameListServer } from '@/features/study/one-to-one/balance-game/api/balance-game-api.server';
+import CommunityTabClient from './community-tab-client';
+
+export default async function CommunityTab() {
+ const initialList = await getBalanceGameListServer({
+ page: 1,
+ size: 10,
+ sort: 'latest',
+ status: 'active',
+ });
+
+ return ;
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx b/src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx
new file mode 100644
index 00000000..49662159
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface FilterPillButtonProps {
+ isActive: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}
+
+export default function FilterPillButton({
+ isActive,
+ onClick,
+ children,
+}: FilterPillButtonProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx b/src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx
new file mode 100644
index 00000000..81469e17
--- /dev/null
+++ b/src/features/study/one-to-one/balance-game/ui/voting-detail-page-client.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import React from 'react';
+import VotingDetailView from '@/components/voting/voting-detail-view';
+
+interface VotingDetailPageClientProps {
+ votingId: number;
+}
+
+export default function VotingDetailPageClient({
+ votingId,
+}: VotingDetailPageClientProps) {
+ const router = useRouter();
+
+ return router.back()} />;
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts
new file mode 100644
index 00000000..bc0abed3
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server.ts
@@ -0,0 +1,13 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import type { HallOfFameResponse, HallOfFameData } from '@/types/hall-of-fame';
+
+export const getHallOfFameServer = async (): Promise => {
+ const response =
+ await axiosServerInstance.get('/hall-of-fame');
+
+ if (response.data && response.data.content) {
+ return response.data.content;
+ }
+
+ throw new Error('Invalid API response structure');
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts
new file mode 100644
index 00000000..6e3c2f9f
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.ts
@@ -0,0 +1,22 @@
+import { axiosInstance } from '@/api/client/axios';
+import type { HallOfFameResponse, HallOfFameData } from '@/types/hall-of-fame';
+
+/**
+ * 명예의 전당 정보 조회
+ * GET /api/v1/hall-of-fame
+ */
+export const getHallOfFame = async (): Promise => {
+ try {
+ const response =
+ await axiosInstance.get('/hall-of-fame');
+
+ if (response.data && response.data.content) {
+ return response.data.content;
+ }
+
+ throw new Error('Invalid API response structure');
+ } catch (error) {
+ console.error('Failed to fetch hall of fame data:', error);
+ throw error;
+ }
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts b/src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts
new file mode 100644
index 00000000..25bb410c
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query.ts
@@ -0,0 +1,22 @@
+import { useQuery } from '@tanstack/react-query';
+import type { HallOfFameData } from '@/types/hall-of-fame';
+import { getHallOfFame } from '../api/hall-of-fame-api';
+
+export const HALL_OF_FAME_KEYS = {
+ all: ['hallOfFame'] as const,
+ detail: () => [...HALL_OF_FAME_KEYS.all, 'detail'] as const,
+} as const;
+
+/**
+ * 명예의 전당 정보 조회 훅
+ */
+export const useHallOfFameQuery = (options?: {
+ initialData?: HallOfFameData;
+}) => {
+ return useQuery({
+ queryKey: HALL_OF_FAME_KEYS.detail(),
+ queryFn: getHallOfFame,
+ staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
+ initialData: options?.initialData,
+ });
+};
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx
new file mode 100644
index 00000000..63354486
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx
@@ -0,0 +1,393 @@
+'use client';
+
+import {
+ Trophy,
+ Flame,
+ Crown,
+ Users,
+ FileText,
+ Thermometer,
+} from 'lucide-react';
+import Image from 'next/image';
+import React, { useState, useMemo } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ProfileAvatar } from '@/components/ui/profile-avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { useHallOfFameQuery } from '@/features/study/one-to-one/hall-of-fame/model/use-hall-of-fame-query';
+import type { HallOfFameData, Ranker, MVPTeam } from '@/types/hall-of-fame';
+import RankingTabButton from './ranking-tab-button';
+
+// ----------------------------------------------------------------------
+// Types & Constants
+// ----------------------------------------------------------------------
+
+type RankingType = 'ATTENDANCE' | 'STUDY_LOG' | 'SINCERITY';
+
+interface RankerWithLabel extends Ranker {
+ scoreLabel: string;
+}
+
+const TAB_CONFIG: Record<
+ RankingType,
+ { label: string; icon: React.ReactNode; unit: string; colorClass: string }
+> = {
+ ATTENDANCE: {
+ label: '불꽃 출석왕',
+ icon: ,
+ unit: '회',
+ colorClass: 'text-text-brand',
+ },
+ STUDY_LOG: {
+ label: '열정 기록왕',
+ icon: ,
+ unit: '건',
+ colorClass: 'text-text-information',
+ },
+ SINCERITY: {
+ label: '성실 온도왕',
+ icon: ,
+ unit: '℃',
+ colorClass: 'text-text-warning',
+ },
+};
+
+/**
+ * 랭커 데이터에 scoreLabel 추가
+ */
+const addScoreLabel = (ranker: Ranker, type: RankingType): RankerWithLabel => {
+ let scoreLabel = '';
+
+ if (type === 'ATTENDANCE') {
+ scoreLabel = `${ranker.score}회`;
+ } else if (type === 'STUDY_LOG') {
+ scoreLabel = `${ranker.score}건`;
+ } else {
+ // SINCERITY
+ scoreLabel = `${ranker.score}℃`;
+ }
+
+ return {
+ ...ranker,
+ scoreLabel,
+ };
+};
+
+// ----------------------------------------------------------------------
+// Components
+// ----------------------------------------------------------------------
+
+const RankBadge = ({ rank }: { rank: number }) => {
+ const iconPath =
+ rank === 1
+ ? '/icons/gold-rank.svg'
+ : rank === 2
+ ? '/icons/silver-rank.svg'
+ : '/icons/bronze-rank.svg';
+
+ if (rank > 3)
+ return (
+
+ {rank}
+
+ );
+
+ return (
+
+
+
+ );
+};
+
+const MVPTeamCard = ({
+ team,
+ className,
+}: {
+ team: MVPTeam;
+ className?: string;
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ {team.weekDate} MVP 팀
+
+
+ 최고의 스터디 메이트
+
+
+
+
+ {team.members.map((member, index) => (
+
+
+
+
+
+
+ {member.nickname}
+
+
+
+ }
+ />
+
+ {index === 0 && (
+
+ &
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ 이번 주 공유한 자료
+
+
+
+
+
+
+ );
+};
+
+const RankerListItem = ({ ranker }: { ranker: RankerWithLabel }) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {ranker.nickname}
+
+ {ranker.rank === 1 && (
+
+ )}
+
+
+ {ranker.jobs && ranker.jobs.length > 0
+ ? ranker.jobs
+ .map((job) => job.description || job.job || '')
+ .filter(Boolean)
+ .join(', ')
+ : ranker.major}
+
+
+
+
+
+ {ranker.scoreLabel}
+
+
+
+ }
+ />
+ );
+};
+
+// ----------------------------------------------------------------------
+// Main Component
+// ----------------------------------------------------------------------
+
+interface HallOfFameTabClientProps {
+ initialData?: HallOfFameData;
+}
+
+export default function HallOfFameTabClient({
+ initialData,
+}: HallOfFameTabClientProps) {
+ const [rankingType, setRankingType] = useState('ATTENDANCE');
+ const { data, isLoading, error } = useHallOfFameQuery({ initialData });
+
+ // 랭킹 데이터 변환 및 scoreLabel 추가
+ const allRankers = useMemo(() => {
+ if (!data) {
+ return {
+ ATTENDANCE: [] as RankerWithLabel[],
+ STUDY_LOG: [] as RankerWithLabel[],
+ SINCERITY: [] as RankerWithLabel[],
+ };
+ }
+
+ return {
+ ATTENDANCE: data.rankings.attendanceRankings.map((r) =>
+ addScoreLabel(r, 'ATTENDANCE'),
+ ),
+ STUDY_LOG: data.rankings.studyLogRankings.map((r) =>
+ addScoreLabel(r, 'STUDY_LOG'),
+ ),
+ SINCERITY: data.rankings.sincerityRankings.map((r) =>
+ addScoreLabel(r, 'SINCERITY'),
+ ),
+ };
+ }, [data]);
+
+ const currentRankers = allRankers[rankingType];
+ const baseDate = data?.rankings.baseDate
+ ? new Date(data.rankings.baseDate).toLocaleDateString('ko-KR')
+ : new Date().toLocaleDateString('ko-KR');
+
+ if (isLoading) {
+ return (
+
+
+
+
+ 명예의 전당을 불러오고 있습니다...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ 명예의 전당 정보를 불러오는 중 오류가 발생했습니다.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ 명예의 전당
+
+
+
+
제로원을 빛낸 열정적인 멤버들과 최고의 유저들을 소개합니다.
+
+ 꾸준한 1:1 스터디를 통해 제로원 명예의 전당에 이름을 올려보세요!
+
+
+
+
+
+ {/* Section 1: Top 5 Rankers */}
+
+
+
+
+
+ {TAB_CONFIG[rankingType].icon}
+
+ {TAB_CONFIG[rankingType].label} TOP 5
+
+
+ {baseDate} 기준
+
+
+
+
+ {(Object.keys(TAB_CONFIG) as RankingType[]).map((type) => (
+ setRankingType(type)}
+ >
+
+ {TAB_CONFIG[type].icon}
+
+ {TAB_CONFIG[type].label}
+
+ ))}
+
+
+
+
+ {currentRankers.length > 0 ? (
+ currentRankers.map((ranker) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ {/* Section 2: MVP Team */}
+
+
+
+ 저번 주 스터디 MVP 팀
+
+
+ {data?.mvpTeam ? (
+
+ ) : (
+
+
+ 이번 주 MVP 팀이 없습니다.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx
new file mode 100644
index 00000000..792724ae
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab.tsx
@@ -0,0 +1,8 @@
+import { getHallOfFameServer } from '@/features/study/one-to-one/hall-of-fame/api/hall-of-fame-api.server';
+import HallOfFameTabClient from './hall-of-fame-tab-client';
+
+export default async function HallOfFameTab() {
+ const initialData = await getHallOfFameServer();
+
+ return ;
+}
diff --git a/src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx b/src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx
new file mode 100644
index 00000000..f382d7cd
--- /dev/null
+++ b/src/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface RankingTabButtonProps {
+ isActive: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}
+
+export default function RankingTabButton({
+ isActive,
+ onClick,
+ children,
+}: RankingTabButtonProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/features/study/one-to-one/history/api/get-my-study-history.server.ts b/src/features/study/one-to-one/history/api/get-my-study-history.server.ts
new file mode 100644
index 00000000..c145a734
--- /dev/null
+++ b/src/features/study/one-to-one/history/api/get-my-study-history.server.ts
@@ -0,0 +1,16 @@
+import { axiosServerInstance } from '@/api/client/axios.server';
+import type {
+ PageableResponse,
+ StudyHistoryContent,
+} from '@/types/study-history';
+import type { GetMyStudyHistoryParams } from './get-my-study-history';
+
+export const getMyStudyHistoryServer = async (
+ params: GetMyStudyHistoryParams,
+) => {
+ const { data } = await axiosServerInstance.get<
+ PageableResponse
+ >('study/daily/history', { params });
+
+ return data;
+};
diff --git a/src/features/study/one-to-one/history/api/get-my-study-history.ts b/src/features/study/one-to-one/history/api/get-my-study-history.ts
new file mode 100644
index 00000000..97ae5539
--- /dev/null
+++ b/src/features/study/one-to-one/history/api/get-my-study-history.ts
@@ -0,0 +1,18 @@
+import { axiosInstance } from '@/api/client/axios';
+import { PageableResponse, StudyHistoryContent } from '@/types/study-history';
+
+export interface GetMyStudyHistoryParams {
+ page?: number;
+ size?: number;
+ startDate?: string;
+ endDate?: string;
+ sort?: string;
+}
+
+export const getMyStudyHistory = async (params: GetMyStudyHistoryParams) => {
+ const { data } = await axiosInstance.get<
+ PageableResponse
+ >('study/daily/history', { params });
+
+ return data;
+};
diff --git a/src/features/study/one-to-one/history/model/use-my-study-history-query.ts b/src/features/study/one-to-one/history/model/use-my-study-history-query.ts
new file mode 100644
index 00000000..1190080c
--- /dev/null
+++ b/src/features/study/one-to-one/history/model/use-my-study-history-query.ts
@@ -0,0 +1,23 @@
+import { useQuery } from '@tanstack/react-query';
+import {
+ getMyStudyHistory,
+ GetMyStudyHistoryParams,
+} from '@/features/study/one-to-one/history/api/get-my-study-history';
+
+export const STUDY_HISTORY_QUERY_KEY = {
+ all: ['myStudyHistory'] as const,
+ list: (params: GetMyStudyHistoryParams) =>
+ [...STUDY_HISTORY_QUERY_KEY.all, params] as const,
+};
+
+export const useMyStudyHistoryQuery = (
+ params: GetMyStudyHistoryParams,
+ options?: { initialData?: Awaited> },
+) => {
+ return useQuery({
+ queryKey: STUDY_HISTORY_QUERY_KEY.list(params),
+ queryFn: () => getMyStudyHistory(params),
+ select: (data) => data.content, // API 응답에서 content 부분만 추출해서 사용하기 편하게 함
+ initialData: options?.initialData,
+ });
+};
diff --git a/src/features/study/one-to-one/history/ui/study-history-tab-client.tsx b/src/features/study/one-to-one/history/ui/study-history-tab-client.tsx
new file mode 100644
index 00000000..77fbf653
--- /dev/null
+++ b/src/features/study/one-to-one/history/ui/study-history-tab-client.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import { History, List, Calendar as CalendarIcon, Loader2 } from 'lucide-react';
+import { useState } from 'react';
+import { StudyCalendar } from '@/components/study-history/study-calendar';
+import { StudyHistoryRow } from '@/components/study-history/study-history-row';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { GetMyStudyHistoryParams } from '@/features/study/one-to-one/history/api/get-my-study-history';
+import { useMyStudyHistoryQuery } from '@/features/study/one-to-one/history/model/use-my-study-history-query';
+import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';
+import {
+ PageableResponse,
+ StudyHistoryItem,
+ StudyHistoryContent,
+} from '@/types/study-history';
+
+// 데이터 매핑 함수 (API Response -> UI Model)
+const mapHistoryItem = (data: StudyHistoryContent): StudyHistoryItem => {
+ const dateObj = new Date(data.scheduledAt);
+ const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`;
+ const dayName = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()];
+
+ return {
+ id: data.studyId,
+ date: `${dateStr} (${dayName})`,
+ subject: data.title,
+ role: data.participation.role,
+ attendance:
+ data.participation.attendance === 'PRESENT' ? 'ATTENDED' : 'NOT_STARTED',
+ link: data.studyLink,
+ status: data.status === 'COMPLETE' ? 'COMPLETED' : 'IN_PROGRESS',
+ partner: {
+ id: data.partner.memberId,
+ name: data.partner.nickname,
+ profileImage: data.partner.profileImageUrl,
+ },
+ };
+};
+
+interface StudyHistoryTabClientProps {
+ initialData?: PageableResponse;
+ initialParams: GetMyStudyHistoryParams;
+}
+
+export default function StudyHistoryTabClient({
+ initialData,
+ initialParams,
+}: StudyHistoryTabClientProps) {
+ const [viewMode, setViewMode] = useState<'LIST' | 'CALENDAR'>('LIST');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [currentCalendarDate, setCurrentCalendarDate] = useState(new Date());
+ const ITEMS_PER_PAGE = 15;
+
+ // API 파라미터 동적 생성
+ const getQueryParams = () => {
+ if (viewMode === 'CALENDAR') {
+ const year = currentCalendarDate.getFullYear();
+ const month = currentCalendarDate.getMonth() + 1;
+ const lastDay = new Date(year, month, 0).getDate(); // 해당 월의 마지막 날짜
+
+ return {
+ page: 0,
+ size: 100, // 한 달치 데이터를 충분히 가져오기 위해 크게 설정
+ startDate: `${year}-${String(month).padStart(2, '0')}-01`,
+ endDate: `${year}-${String(month).padStart(2, '0')}-${lastDay}`,
+ sort: 'createdAt,desc', // 요청대로 createdAt 사용 (단, 캘린더 뷰라면 scheduledAt이 더 적절할 수 있음)
+ };
+ }
+
+ return {
+ page: currentPage - 1,
+ size: ITEMS_PER_PAGE,
+ sort: 'createdAt,desc',
+ };
+ };
+
+ const queryParams = getQueryParams();
+ const shouldUseInitialData =
+ queryParams.page === initialParams.page &&
+ queryParams.size === initialParams.size &&
+ queryParams.startDate === initialParams.startDate &&
+ queryParams.endDate === initialParams.endDate &&
+ queryParams.sort === initialParams.sort;
+
+ const { data: historyData, isLoading } = useMyStudyHistoryQuery(queryParams, {
+ initialData: shouldUseInitialData ? initialData : undefined,
+ });
+
+ // 데이터 변환
+ const historyItems = historyData?.content?.map(mapHistoryItem) || [];
+ const totalPages = historyData?.totalPages || 1;
+ const totalElements = historyData?.totalElements || 0;
+
+ if (isLoading) {
+ return (
+
+
+
+ 1:1 스터디 기록을 불러오는 중입니다...
+
+
+ );
+ }
+
+ return (
+
+
+
+ 나의 1:1 스터디 기록
+
+
+
+
+ setViewMode('LIST')}
+ className={cn(
+ 'rounded-75 flex items-center gap-50 p-100 transition-colors',
+ viewMode === 'LIST'
+ ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
+ : 'text-text-subtlest hover:text-text-subtle',
+ )}
+ >
+
+ 리스트
+
+ setViewMode('CALENDAR')}
+ className={cn(
+ 'rounded-75 flex items-center gap-50 p-100 transition-colors',
+ viewMode === 'CALENDAR'
+ ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
+ : 'text-text-subtlest hover:text-text-subtle',
+ )}
+ >
+
+ 달력
+
+
+
+
+
+
+ 총 {totalElements}
+ 개의 1:1 스터디 기록이 있습니다.
+
+
+ {viewMode === 'LIST' ? (
+
+
+
날짜
+
오늘의 주제
+
상대방
+
내 역할
+
역할수행여부
+
진행상태
+
링크
+
+
+
+ {historyItems.length > 0 ? (
+ historyItems.map((item) => (
+
+ ))
+ ) : (
+
+
+
+ 아직 1:1 스터디 기록이 없습니다.
+
+
+ )}
+
+
+ ) : (
+
+ )}
+
+ {viewMode === 'LIST' && totalPages > 1 && (
+
+
setCurrentPage(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ >
+ ←
+
+
+ {currentPage} / {totalPages}
+
+
+ setCurrentPage(Math.min(totalPages, currentPage + 1))
+ }
+ disabled={currentPage === totalPages}
+ >
+ →
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/history/ui/study-history-tab.tsx b/src/features/study/one-to-one/history/ui/study-history-tab.tsx
new file mode 100644
index 00000000..9f0fd64d
--- /dev/null
+++ b/src/features/study/one-to-one/history/ui/study-history-tab.tsx
@@ -0,0 +1,20 @@
+import { GetMyStudyHistoryParams } from '@/features/study/one-to-one/history/api/get-my-study-history';
+import { getMyStudyHistoryServer } from '@/features/study/one-to-one/history/api/get-my-study-history.server';
+import StudyHistoryTabClient from './study-history-tab-client';
+
+export default async function StudyHistoryTab() {
+ const initialParams: GetMyStudyHistoryParams = {
+ page: 0,
+ size: 15,
+ sort: 'createdAt,desc',
+ };
+
+ const initialData = await getMyStudyHistoryServer(initialParams);
+
+ return (
+
+ );
+}
diff --git a/src/features/study/schedule/api/get-study-schedule.tsx b/src/features/study/one-to-one/schedule/api/get-study-schedule.tsx
similarity index 95%
rename from src/features/study/schedule/api/get-study-schedule.tsx
rename to src/features/study/one-to-one/schedule/api/get-study-schedule.tsx
index a5357909..096ac484 100644
--- a/src/features/study/schedule/api/get-study-schedule.tsx
+++ b/src/features/study/one-to-one/schedule/api/get-study-schedule.tsx
@@ -7,7 +7,7 @@ import {
MonthlyCalendarResponse,
StudyStatus,
WeeklyParticipationResponse,
-} from '@/features/study/schedule/api/schedule-types';
+} from '@/features/study/one-to-one/schedule/api/schedule-types';
// 스터디 전체 조회
export const getDailyStudies = async (
diff --git a/src/features/study/schedule/api/schedule-types.ts b/src/features/study/one-to-one/schedule/api/schedule-types.ts
similarity index 100%
rename from src/features/study/schedule/api/schedule-types.ts
rename to src/features/study/one-to-one/schedule/api/schedule-types.ts
diff --git a/src/features/study/schedule/model/use-schedule-query.ts b/src/features/study/one-to-one/schedule/model/use-schedule-query.ts
similarity index 86%
rename from src/features/study/schedule/model/use-schedule-query.ts
rename to src/features/study/one-to-one/schedule/model/use-schedule-query.ts
index 68c9d28a..1302de29 100644
--- a/src/features/study/schedule/model/use-schedule-query.ts
+++ b/src/features/study/one-to-one/schedule/model/use-schedule-query.ts
@@ -4,16 +4,16 @@ import {
getMonthlyStudyCalendar,
getStudyStatus,
getWeeklyParticipation,
-} from '@/features/study/schedule/api/get-study-schedule';
+} from '@/features/study/one-to-one/schedule/api/get-study-schedule';
import {
GetDailyStudiesParams,
GetMonthlyCalendarParams,
MonthlyCalendarResponse,
StudyStatus,
-} from '@/features/study/schedule/api/schedule-types';
+} from '@/features/study/one-to-one/schedule/api/schedule-types';
// 스터디 주간 참여 유무 확인 query
-export const useWeeklyParticipation = (params: string) => {
+export const useWeeklyParticipationQuery = (params: string) => {
return useQuery({
queryKey: ['weeklyParticipation', params],
queryFn: () => getWeeklyParticipation(params),
diff --git a/src/features/study/schedule/ui/data-selector.tsx b/src/features/study/one-to-one/schedule/ui/data-selector.tsx
similarity index 100%
rename from src/features/study/schedule/ui/data-selector.tsx
rename to src/features/study/one-to-one/schedule/ui/data-selector.tsx
diff --git a/src/features/study/one-to-one/schedule/ui/home-study-tab.tsx b/src/features/study/one-to-one/schedule/ui/home-study-tab.tsx
new file mode 100644
index 00000000..26475882
--- /dev/null
+++ b/src/features/study/one-to-one/schedule/ui/home-study-tab.tsx
@@ -0,0 +1,12 @@
+import StudyCard from '@/features/study/one-to-one/schedule/ui/study-card';
+import Banner from '@/widgets/home/banner';
+
+export default function StudyTab() {
+ return (
+
+ {/* 기존 컴포넌트들을 그대로 사용 - 100% 안전 */}
+
+
+
+ );
+}
diff --git a/src/features/study/schedule/ui/study-card.tsx b/src/features/study/one-to-one/schedule/ui/study-card.tsx
similarity index 87%
rename from src/features/study/schedule/ui/study-card.tsx
rename to src/features/study/one-to-one/schedule/ui/study-card.tsx
index e38ea184..823e13ce 100644
--- a/src/features/study/schedule/ui/study-card.tsx
+++ b/src/features/study/one-to-one/schedule/ui/study-card.tsx
@@ -2,19 +2,19 @@
import { getMonth, getDay, startOfWeek, getDate } from 'date-fns';
import { useMemo, useState } from 'react';
-import ReservationList from '@/features/study/participation/ui/reservation-list';
import {
useStudyStatusQuery,
- useWeeklyParticipation,
-} from '@/features/study/schedule/model/use-schedule-query';
-import DateSelector from '@/features/study/schedule/ui/data-selector';
-import TodayStudyCard from '@/features/study/schedule/ui/today-study-card';
+ useWeeklyParticipationQuery,
+} from '@/features/study/one-to-one/schedule/model/use-schedule-query';
+import DateSelector from '@/features/study/one-to-one/schedule/ui/data-selector';
+import TodayStudyCard from '@/features/study/one-to-one/schedule/ui/today-study-card';
+import ReservationList from '@/features/study/participation/ui/reservation-list';
import {
formatKoreaYMD,
getKoreaDate,
getKoreaDisplayMonday,
} from '@/utils/time';
-import StudyListSection from '../../../../widgets/home/study-list-table';
+import StudyListSection from '@/widgets/home/study-list-table';
// 스터디 주차 구하는 함수
function getWeekly(date: Date): { month: number; week: number } {
@@ -67,7 +67,7 @@ export default function StudyCard() {
const { data: status } = useStudyStatusQuery();
- const { data: participationData } = useWeeklyParticipation(studyDate);
+ const { data: participationData } = useWeeklyParticipationQuery(studyDate);
const isParticipate = participationData?.isParticipate ?? false;
const displayMonday = useMemo(
diff --git a/src/features/study/schedule/ui/today-study-card.tsx b/src/features/study/one-to-one/schedule/ui/today-study-card.tsx
similarity index 95%
rename from src/features/study/schedule/ui/today-study-card.tsx
rename to src/features/study/one-to-one/schedule/ui/today-study-card.tsx
index e33db149..fd5bf978 100644
--- a/src/features/study/schedule/ui/today-study-card.tsx
+++ b/src/features/study/one-to-one/schedule/ui/today-study-card.tsx
@@ -6,12 +6,12 @@ import UserAvatar from '@/components/ui/avatar';
import Badge from '@/components/ui/badge';
import UserPhoneNumberCopyModal from '@/entities/user/ui/user-phone-number-copy-modal';
import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { DailyStudyDetail } from '@/features/study/interview/api/interview-types';
+import { useDailyStudyDetailQuery } from '@/features/study/interview/model/use-interview-query';
+import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map';
+import StudyDoneModal from '@/features/study/interview/ui/study-done-modal';
+import StudyReadyModal from '@/features/study/interview/ui/study-ready-modal';
import { useAuth } from '@/hooks/common/use-auth';
-import { DailyStudyDetail } from '../../interview/api/interview-types';
-import { useDailyStudyDetailQuery } from '../../interview/model/use-interview-query';
-import { getStatusBadge } from '../../interview/ui/status-badge-map';
-import StudyDoneModal from '../../interview/ui/study-done-modal';
-import StudyReadyModal from '../../interview/ui/study-ready-modal';
export default function TodayStudyCard({ studyDate }: { studyDate: string }) {
const { data: authData } = useAuth();
diff --git a/src/features/study/one-to-one/ui/one-on-one-page.tsx b/src/features/study/one-to-one/ui/one-on-one-page.tsx
new file mode 100644
index 00000000..83f6cf78
--- /dev/null
+++ b/src/features/study/one-to-one/ui/one-on-one-page.tsx
@@ -0,0 +1,988 @@
+'use client';
+
+import {
+ Trophy,
+ BookOpen,
+ Flame,
+ FileText,
+ Thermometer,
+ Search,
+ Eye,
+ Heart,
+ Loader2,
+ ChevronUp,
+ ChevronDown,
+ Minus,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ LibraryBig,
+ LayoutGrid,
+ List,
+ ArrowUpDown,
+ MessageSquareText,
+} from 'lucide-react';
+import Image from 'next/image';
+import Link from 'next/link';
+import React, { useState, useEffect } from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { ProfileAvatar } from '@/components/ui/profile-avatar';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+
+// ----------------------------------------------------------------------
+// Types & Mock Data (Hall of Fame)
+// ----------------------------------------------------------------------
+
+type RankingType = 'ATTENDANCE' | 'STUDY_LOG' | 'SINCERITY';
+
+interface Ranker {
+ rank: number;
+ userId: number;
+ nickname: string;
+ profileImage: string | null;
+ score: number;
+ scoreLabel: string;
+ change?: 'up' | 'down' | 'same';
+ lastActive: string;
+ studyTime: string;
+ major?: string;
+ streak?: number;
+ changeValue?: number;
+}
+
+const JOBS = [
+ 'IT 노베이스 - 비지니스/창업',
+ 'IT 노베이스 - 업무 자동화',
+ 'IT 노베이스 - 내 서비스 개발',
+ 'IT 실무자 - PM/PO/기획',
+ 'IT 실무자 - 프론트엔드',
+ 'IT 실무자 - 백엔드',
+ 'IT 실무자 - AI/머신러닝',
+ 'IT 실무자 - iOS',
+ 'IT 실무자 - 안드로이드',
+ 'IT 실무자 - DevOps',
+ 'IT 실무자 - 데이터 분석',
+ 'IT 실무자 - QA',
+ 'IT 실무자 - 게임 개발',
+ 'IT 실무자 - 디자인',
+ 'IT 실무자 - 마케팅',
+ 'IT 실무자 - 기타',
+];
+
+const generateMockRankers = (type: RankingType): Ranker[] => {
+ return Array.from({ length: 20 }, (_, i) => {
+ const rank = i + 1;
+ let score = 0;
+ let scoreLabel = '';
+
+ if (type === 'ATTENDANCE') {
+ score = 150 - i * 3;
+ scoreLabel = `${score}회`;
+ } else if (type === 'STUDY_LOG') {
+ score = 80 - i * 2;
+ scoreLabel = `${score}건`;
+ } else {
+ score = parseFloat((99.9 - i * 1.5).toFixed(1));
+ scoreLabel = `${score}℃`;
+ }
+
+ const lastActive = i < 3 ? '방금 전' : `${i * 10 + 5}분 전`;
+ const studyTime = `${Math.floor(Math.random() * 40 + 10)}시간`;
+ const streak = Math.floor(Math.random() * 50) + 1;
+ const major = JOBS[Math.floor(Math.random() * JOBS.length)];
+
+ return {
+ rank,
+ userId: 100 + i,
+ nickname: `User_${100 + i}`,
+ profileImage: null as string | null,
+ score,
+ scoreLabel,
+ change:
+ Math.random() > 0.7 ? 'up' : Math.random() > 0.8 ? 'down' : 'same',
+ lastActive,
+ studyTime,
+ major,
+ streak,
+ changeValue: Math.floor(Math.random() * 5),
+ };
+ });
+};
+
+const TAB_CONFIG: Record<
+ RankingType,
+ { label: string; desc: string; icon: React.ReactNode; colorClass: string }
+> = {
+ ATTENDANCE: {
+ label: '불꽃 출석왕',
+ desc: '비가 오나 눈이 오나 자리를 지킨, 제로원의 개근상',
+ icon: ,
+ colorClass: 'text-text-brand',
+ },
+ STUDY_LOG: {
+ label: '열정 기록왕',
+ desc: '제로원의 도서관을 채운, 자료 공유의 신',
+ icon: ,
+ colorClass: 'text-text-information',
+ },
+ SINCERITY: {
+ label: '성실 온도왕',
+ desc: '가장 뜨거운 심장을 가진, 제로원의 신뢰 아이콘',
+ icon: ,
+ colorClass: 'text-text-warning',
+ },
+};
+
+// ----------------------------------------------------------------------
+// Types & Mock Data (Library)
+// ----------------------------------------------------------------------
+
+interface LibraryItem {
+ id: number;
+ title: string;
+ author: string;
+ date: string;
+ views: number;
+ likes: number;
+ link: string;
+ isLiked: boolean;
+}
+
+const MOCK_LIBRARY_DATA: LibraryItem[] = [
+ {
+ id: 1,
+ title: '2025년 상반기 백엔드 개발자 면접 질문 모음 (네카라쿠배)',
+ author: '제로원 운영진',
+ date: '2025.01.10',
+ views: 1250,
+ likes: 342,
+ link: 'https://velog.io',
+ isLiked: true,
+ },
+ {
+ id: 2,
+ title: '프론트엔드 성능 최적화: React 19 도입 가이드',
+ author: 'TechLead_Kim',
+ date: '2025.01.12',
+ views: 890,
+ likes: 120,
+ link: 'https://medium.com',
+ isLiked: false,
+ },
+ {
+ id: 3,
+ title: '비전공자가 6개월 만에 개발자로 취업한 현실적인 공부법',
+ author: 'NewDeveloper',
+ date: '2025.01.05',
+ views: 2100,
+ likes: 560,
+ link: 'https://brunch.co.kr',
+ isLiked: true,
+ },
+ {
+ id: 4,
+ title: 'CS 기초: 운영체제와 네트워크 핵심 요약 (PDF 다운로드)',
+ author: 'CS_Master',
+ date: '2024.12.28',
+ views: 1500,
+ likes: 410,
+ link: 'https://tistory.com',
+ isLiked: false,
+ },
+ {
+ id: 5,
+ title: '주니어 개발자를 위한 이력서 첨삭 가이드 101',
+ author: 'HR_Manager',
+ date: '2025.01.15',
+ views: 750,
+ likes: 230,
+ link: 'https://linkedin.com',
+ isLiked: false,
+ },
+ ...Array.from({ length: 10 }, (_, i) => ({
+ id: 10 + i,
+ title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
+ author: 'Admin',
+ date: `2024.12.${20 - i}`,
+ views: 100 + i * 10,
+ likes: 10 + i,
+ link: 'https://google.com',
+ isLiked: Math.random() > 0.5,
+ })),
+];
+
+// ----------------------------------------------------------------------
+// Components
+// ----------------------------------------------------------------------
+
+const RankBadge = ({ rank }: { rank: number }) => {
+ const iconPath =
+ rank === 1
+ ? '/icons/gold-rank.svg'
+ : rank === 2
+ ? '/icons/silver-rank.svg'
+ : '/icons/bronze-rank.svg';
+
+ return (
+
+
+
+ );
+};
+
+const RankChangeIndicator = ({
+ change,
+ value,
+}: {
+ change?: 'up' | 'down' | 'same';
+ value?: number;
+}) => {
+ if (change === 'same' || !value) {
+ return ;
+ }
+ if (change === 'up') {
+ return (
+
+
+ {value}
+
+ );
+ }
+
+ return (
+
+
+ {value}
+
+ );
+};
+
+const TopRankerCard = ({ ranker }: { ranker: Ranker }) => {
+ const isFirst = ranker.rank === 1;
+
+ return (
+
+ {isFirst && (
+
+
+
+ )}
+
+
+
+
+ {!isFirst && (
+
+
+
+ )}
+
+ {ranker.nickname}
+
+
+
+
+ {parseInt(ranker.scoreLabel.replace(/[^0-9.]/g, ''))}
+
+
+ {ranker.scoreLabel.replace(/[0-9.]/g, '')}
+
+
+
+
+
+
+ 최근 활동
+
+ {ranker.lastActive}
+
+
+
+
+ 누적 학습
+
+ {ranker.studyTime}
+
+
+
+
+ }
+ />
+ );
+};
+
+const LibraryCard = ({
+ item,
+ onLike,
+ onView,
+}: {
+ item: LibraryItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (link: string) => void;
+}) => {
+ return (
+ onView(item.link)}
+ className="group rounded-200 border-border-subtle bg-background-default hover:shadow-2 hover:border-border-default flex cursor-pointer flex-col gap-200 border p-400 transition-all hover:-translate-y-50"
+ >
+
+
+ {item.date}
+
+
+
+
+
+ {item.title}
+
+
+
+
+ by{' '}
+ {item.author}
+
+
+
+
+ {item.views.toLocaleString()}
+
+
onLike(e, item.id)}
+ className="font-designer-12r flex items-center gap-50 rounded-full p-1 transition-transform hover:scale-110 hover:bg-red-50"
+ >
+
+
+ {item.likes.toLocaleString()}
+
+
+
+
+
+ );
+};
+
+const LibraryRow = ({
+ item,
+ onLike,
+ onView,
+}: {
+ item: LibraryItem;
+ onLike: (e: React.MouseEvent, id: number) => void;
+ onView: (link: string) => void;
+}) => {
+ return (
+ onView(item.link)}
+ className="group border-border-subtlest hover:bg-fill-neutral-subtle-hover flex cursor-pointer items-center gap-300 border-b px-300 py-200 transition-colors last:border-0"
+ >
+ {/* Title Area */}
+
+
+ {item.title}
+
+
+ {item.author}
+
+ {item.date}
+
+
+
+ {/* Stats Area - Hidden on very small screens if needed, but flex-wrap handles it */}
+
+
+
+ {item.views.toLocaleString()}
+
+
+
onLike(e, item.id)}
+ className="font-designer-13m flex min-w-[50px] items-center justify-end gap-50 rounded-full p-1 transition-transform hover:scale-110 hover:bg-red-50"
+ >
+
+
+ {item.likes.toLocaleString()}
+
+
+
+
+ );
+};
+
+// ----------------------------------------------------------------------
+// Main Page
+// ----------------------------------------------------------------------
+
+export default function OneOnOnePage() {
+ const [activeTab, setActiveTab] = useState<'RANKING' | 'LIBRARY'>('RANKING');
+ const [rankingType, setRankingType] = useState('ATTENDANCE');
+ const [rankers, setRankers] = useState([]);
+ const [libraryItems, setLibraryItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Library Specific States
+ const [librarySort, setLibrarySort] = useState<'LATEST' | 'VIEWS' | 'LIKES'>(
+ 'LATEST',
+ );
+ const [viewMode, setViewMode] = useState<'GRID' | 'LIST'>('GRID');
+
+ // Pagination & Filter States
+ const [currentPage, setCurrentPage] = useState(1);
+ const ITEMS_PER_PAGE = viewMode === 'LIST' ? 15 : 10;
+ const [searchTerm, setSearchTerm] = useState('');
+ const [jobFilter, setJobFilter] = useState('');
+
+ // Data Loading Simulation
+ useEffect(() => {
+ setIsLoading(true);
+ setCurrentPage(1); // 탭 변경 시 페이지 리셋
+
+ const timer = setTimeout(() => {
+ if (activeTab === 'RANKING') {
+ const data = Array.from({ length: 100 }, (_, i) => {
+ return generateMockRankers(rankingType).map((r) => ({
+ ...r,
+ rank: r.rank + i * 20,
+ }));
+ })
+ .flat()
+ .map((item, index) => ({ ...item, rank: index + 1 }));
+ setRankers(data);
+ } else if (activeTab === 'LIBRARY') {
+ setLibraryItems(MOCK_LIBRARY_DATA);
+ }
+ setIsLoading(false);
+ }, 400);
+
+ return () => clearTimeout(timer);
+ }, [activeTab, rankingType]);
+
+ // Handler for Likes
+ const handleLike = (e: React.MouseEvent, id: number) => {
+ e.stopPropagation(); // Prevent card click
+ setLibraryItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ return {
+ ...item,
+ isLiked: !item.isLiked,
+ likes: item.isLiked ? item.likes - 1 : item.likes + 1,
+ };
+ }
+
+ return item;
+ }),
+ );
+ };
+
+ // Handler for Viewing (Clicking Card)
+ const handleView = (link: string) => {
+ window.open(link, '_blank');
+ };
+
+ // Filtering Logic
+ const filteredRankers = rankers.filter((r) => {
+ const matchesSearch = r.nickname
+ .toLowerCase()
+ .includes(searchTerm.toLowerCase());
+ const matchesJob = jobFilter ? r.major === jobFilter : true;
+
+ return matchesSearch && matchesJob;
+ });
+
+ const filteredLibrary = libraryItems.filter((item) =>
+ item.title.toLowerCase().includes(searchTerm.toLowerCase()),
+ );
+
+ const sortedLibrary = [...filteredLibrary].sort((a, b) => {
+ if (librarySort === 'VIEWS') return b.views - a.views;
+ if (librarySort === 'LIKES') return b.likes - a.likes;
+
+ return new Date(b.date).getTime() - new Date(a.date).getTime();
+ });
+
+ // Pagination Logic
+ const totalItems =
+ activeTab === 'RANKING' ? filteredRankers.length : sortedLibrary.length;
+
+ const totalPages = Math.ceil(
+ (activeTab === 'RANKING' ? Math.max(0, totalItems - 3) : totalItems) /
+ ITEMS_PER_PAGE,
+ );
+
+ const currentRankers = filteredRankers
+ .slice(3)
+ .slice(
+ (currentPage - 1) * ITEMS_PER_PAGE,
+ currentPage * ITEMS_PER_PAGE + (currentPage === 1 ? 0 : 0),
+ );
+
+ const currentLibrary = sortedLibrary.slice(
+ (currentPage - 1) * ITEMS_PER_PAGE,
+ currentPage * ITEMS_PER_PAGE,
+ );
+
+ return (
+
+
+ {/* Left Sidebar (Tabs) */}
+
+
+ {/* Main Content Area */}
+
+
+ {/* Header Area */}
+
+
+ {activeTab === 'RANKING' && '명예의 전당'}
+ {activeTab === 'LIBRARY' && '제로원 도서관'}
+
+ {activeTab === 'RANKING' && (
+
+ )}
+ {activeTab === 'LIBRARY' && (
+
+ )}
+
+
+
+ {/* Filters & Search */}
+
+ {activeTab === 'RANKING' && (
+
+ {(Object.keys(TAB_CONFIG) as RankingType[]).map((type) => (
+ setRankingType(type)}
+ className={cn(
+ 'font-designer-15m rounded-100 flex items-center gap-100 px-300 py-150 whitespace-nowrap transition-all',
+ rankingType === type
+ ? 'bg-fill-neutral-strong-default text-text-inverse shadow-1'
+ : 'text-text-subtle hover:bg-fill-neutral-subtle-hover',
+ )}
+ >
+ {TAB_CONFIG[type].icon}
+ {TAB_CONFIG[type].label}
+
+ ))}
+
+ )}
+
+
+ {/* 1. Total Count (Left) */}
+ {activeTab === 'LIBRARY' && (
+
+ 총{' '}
+
+ {totalItems}
+
+ 개의 자료가 있습니다.
+
+ )}
+
+ {/* Right Side Controls */}
+
+ {activeTab === 'RANKING' && (
+
setJobFilter(e.target.value)}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14m focus:border-border-default focus:ring-fill-neutral-default-default h-600 w-full cursor-pointer border px-200 transition-all outline-none focus:ring-2 md:w-auto"
+ >
+ 모든 직무
+ {JOBS.map((job) => (
+
+ {job}
+
+ ))}
+
+ )}
+
+ {/* 2. Search Bar (Center/Right) */}
+
+ setSearchTerm(e.target.value)}
+ className="rounded-100 border-border-subtle bg-background-default font-designer-14m focus:border-border-default focus:ring-fill-neutral-default-default h-600 w-full border pr-500 pl-200 transition-all outline-none focus:ring-2"
+ />
+
+
+
+
+
+ {/* 3. Sort & View Toggle (Right End - Library Only) */}
+ {activeTab === 'LIBRARY' && (
+
+ {/* Sort Dropdown */}
+
+
+
+ {librarySort === 'LATEST'
+ ? '최신순'
+ : librarySort === 'VIEWS'
+ ? '조회순'
+ : '좋아요순'}
+
+
+ {/* Dropdown Wrapper - Invisible Bridge */}
+
+ {/* Visual Dropdown */}
+
+ setLibrarySort('LATEST')}
+ className="hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors"
+ >
+ 최신순
+
+ setLibrarySort('VIEWS')}
+ className="hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors"
+ >
+ 조회순
+
+ setLibrarySort('LIKES')}
+ className="hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors"
+ >
+ 좋아요순
+
+
+
+
+
+
+
+ {/* View Mode Toggle */}
+
+ setViewMode('GRID')}
+ className={cn(
+ 'rounded-75 p-100 transition-colors',
+ viewMode === 'GRID'
+ ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
+ : 'text-text-subtlest hover:text-text-subtle',
+ )}
+ >
+
+
+ setViewMode('LIST')}
+ className={cn(
+ 'rounded-75 p-100 transition-colors',
+ viewMode === 'LIST'
+ ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
+ : 'text-text-subtlest hover:text-text-subtle',
+ )}
+ >
+
+
+
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+
+
+ 데이터를 불러오는 중입니다...
+
+
+ ) : activeTab === 'RANKING' ? (
+ <>
+ {/* Top 3 Section */}
+ {filteredRankers.length > 0 && (
+
+ {filteredRankers[1] && (
+
+
+
+ )}
+ {filteredRankers[0] && (
+
+
+
+ )}
+ {filteredRankers[2] && (
+
+
+
+ )}
+
+ )}
+
+ {/* Ranking Table */}
+
+
+
+ Rank
+
+
Member
+
+ Score
+
+
+ Activity
+
+
+
+
+ {currentRankers.map((ranker) => (
+
+
+
+ {ranker.rank}
+
+
+
+
+
+
+
+ {ranker.nickname}
+
+
+ {ranker.major}
+
+
+
+
+
+
+ {ranker.scoreLabel}
+
+
+ 상위 1%
+
+
+
+
+
+ {ranker.studyTime} 누적
+
+
+ 최근: {ranker.lastActive}
+
+
+
+ }
+ />
+ ))}
+
+
+ >
+ ) : (
+ /* Library Content */
+ <>
+ {viewMode === 'GRID' ? (
+ /* Grid View */
+
+ {currentLibrary.length > 0 ? (
+ currentLibrary.map((item) => (
+
+ ))
+ ) : (
+
+ )}
+
+ ) : (
+ /* List View (Compact Rows) */
+
+ {currentLibrary.length > 0 ? (
+
+ {currentLibrary.map((item) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ )}
+ >
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+ setCurrentPage(Math.max(1, currentPage - 1))}
+ disabled={currentPage === 1}
+ className="border-border-subtle hover:bg-fill-neutral-subtle-hover text-text-subtle flex h-[40px] w-[40px] items-center justify-center rounded-[9999px] border transition-colors disabled:opacity-50 disabled:hover:bg-transparent"
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+
+ setCurrentPage(Math.min(totalPages, currentPage + 1))
+ }
+ disabled={currentPage === totalPages}
+ className="border-border-subtle hover:bg-fill-neutral-subtle-hover text-text-subtle flex h-[40px] w-[40px] items-center justify-center rounded-[9999px] border transition-colors disabled:opacity-50 disabled:hover:bg-transparent"
+ >
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/features/study/one-to-one/ui/pagination-circle-button.tsx b/src/features/study/one-to-one/ui/pagination-circle-button.tsx
new file mode 100644
index 00000000..ca06d547
--- /dev/null
+++ b/src/features/study/one-to-one/ui/pagination-circle-button.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+interface PaginationCircleButtonProps {
+ onClick: () => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export default function PaginationCircleButton({
+ onClick,
+ disabled,
+ children,
+ className,
+}: PaginationCircleButtonProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/hooks/common/use-auth.ts b/src/hooks/common/use-auth.ts
index 1f56f3d1..9af3fd96 100644
--- a/src/hooks/common/use-auth.ts
+++ b/src/hooks/common/use-auth.ts
@@ -1,15 +1,34 @@
-import { useMemo } from 'react';
+// 데이터 조회(Query) 를 담당하는 커스텀 훅
+
+// cf. useQuery 는 클라이언트 컴포넌트에서 사용하는 React Hook 으로 실시간 데이터 구독, 캐싱, 리렌더링 처리를 담당함
+// 한편, prefetchQuery 는 서버컴포넌트에서 사용하는 함수로 데이터를 미리 가져와서 캐시에 저장함
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
import { getCookie } from '@/api/client/cookie';
+import { getMemberId } from '@/features/auth/api/auth';
import { decodeJwt } from '@/utils/jwt';
+// 회원 Id 조회
+export const useMemberId = () => {
+ return useQuery<{ memberId: string }>({
+ queryKey: ['member'],
+ queryFn: getMemberId,
+ enabled: !!getCookie('accessToken'), // 토큰이 있을 때만 실행
+ });
+};
+
type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR' | 'ROLE_GUEST';
-type AuthVendor = 'GOOGLE' | 'KAKAO';
+type AuthVendor = 'GOOGLE' | 'KAKAO' | 'TEST'; // NATIVE -> TEST 변경
-interface DecodedToken {
+export interface DecodedToken {
roleIds: RoleId[];
authVendor: AuthVendor;
- memberId: number | null;
+
+ memberId: number;
+ sub: string;
+ iat: number;
+ exp: number;
}
interface UseAuthReturn {
@@ -25,16 +44,12 @@ function isDecodedToken(value: unknown): value is DecodedToken {
// roleIds가 배열이고, 모든 요소가 유효한 RoleId인지 확인
if (!Array.isArray(obj.roleIds)) return false;
- const validRoles: RoleId[] = [
- 'ROLE_MEMBER',
- 'ROLE_ADMIN',
- 'ROLE_MENTOR',
- 'ROLE_GUEST',
- ];
- if (!obj.roleIds.every((role) => validRoles.includes(role))) return false;
+ // TODO: 서버 역할과 프론트 역할 싱크 필요. 일단 validation 완화
+ // const validRoles: RoleId[] = ['ROLE_MEMBER', 'ROLE_ADMIN', 'ROLE_MENTOR', 'ROLE_GUEST'];
+ // if (!obj.roleIds.every((role) => validRoles.includes(role))) return false;
// authVendor가 유효한 값인지 확인
- const validVendors: AuthVendor[] = ['GOOGLE', 'KAKAO'];
+ const validVendors: AuthVendor[] = ['GOOGLE', 'KAKAO', 'TEST']; // NATIVE -> TEST 변경
if (!validVendors.includes(obj.authVendor as AuthVendor)) return false;
// memberId가 숫자이거나 null인지 확인
@@ -49,7 +64,9 @@ function isDecodedToken(value: unknown): value is DecodedToken {
}
export function useAuth(): UseAuthReturn {
- const accessToken = getCookie('accessToken');
+ // getCookie는 클라이언트 사이드에서만 실행되어야 함
+ const accessToken =
+ typeof window !== 'undefined' ? getCookie('accessToken') : undefined;
const decodedToken: DecodedToken | undefined = useMemo(() => {
if (!accessToken) return undefined;
@@ -57,8 +74,9 @@ export function useAuth(): UseAuthReturn {
try {
const decoded = decodeJwt(accessToken);
- if (!isDecodedToken(decoded)) {
- console.error('Invalid token structure');
+ // decoded가 null이거나 형식이 안 맞으면 에러 출력
+ if (!decoded || !isDecodedToken(decoded)) {
+ console.error('Invalid token structure', decoded);
return undefined;
}
diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts
new file mode 100644
index 00000000..4574fc6a
--- /dev/null
+++ b/src/hooks/use-debounce.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+export function useDebounce(value: T, delay?: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/hooks/use-discussion-params.ts b/src/hooks/use-discussion-params.ts
new file mode 100644
index 00000000..d54943d2
--- /dev/null
+++ b/src/hooks/use-discussion-params.ts
@@ -0,0 +1,82 @@
+import { useSearchParams, useRouter } from 'next/navigation';
+import { useCallback, useMemo } from 'react';
+import { DiscussionTopic, SortOption } from '@/types/discussion';
+
+export interface DiscussionParams {
+ q: string;
+ sort: SortOption;
+ topic: DiscussionTopic;
+}
+
+/**
+ * Discussion 페이지의 URL 쿼리스트링을 관리하는 훅
+ * ?q=검색어&sort=latest&topic=all 형태로 동기화
+ */
+export function useDiscussionParams() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ // 현재 파라미터 값 파싱
+ const params = useMemo(() => {
+ return {
+ q: searchParams.get('q') || '',
+ sort: (searchParams.get('sort') as SortOption) || 'latest',
+ topic: (searchParams.get('topic') as DiscussionTopic) || 'all',
+ };
+ }, [searchParams]);
+
+ // URL 업데이트 함수
+ const updateParams = useCallback(
+ (updates: Partial) => {
+ const current = new URLSearchParams(searchParams.toString());
+
+ Object.entries(updates).forEach(([key, value]) => {
+ if (value === '' || value === 'all' || value === 'latest') {
+ current.delete(key);
+ } else {
+ current.set(key, value);
+ }
+ });
+
+ const queryString = current.toString();
+ const newUrl = queryString ? `?${queryString}` : '';
+ router.push(newUrl, { scroll: false });
+ },
+ [searchParams, router],
+ );
+
+ // 개별 업데이트 헬퍼 함수들
+ const setSearch = useCallback(
+ (q: string) => {
+ updateParams({ q });
+ },
+ [updateParams],
+ );
+
+ const setSort = useCallback(
+ (sort: SortOption) => {
+ updateParams({ sort });
+ },
+ [updateParams],
+ );
+
+ const setTopic = useCallback(
+ (topic: DiscussionTopic) => {
+ updateParams({ topic });
+ },
+ [updateParams],
+ );
+
+ const resetParams = useCallback(() => {
+ router.push('', { scroll: false });
+ }, [router]);
+
+ return {
+ params,
+ updateParams,
+ setSearch,
+ setSort,
+ setTopic,
+ resetParams,
+ };
+}
diff --git a/src/mocks/discussion-mock-data.ts b/src/mocks/discussion-mock-data.ts
new file mode 100644
index 00000000..be6faf11
--- /dev/null
+++ b/src/mocks/discussion-mock-data.ts
@@ -0,0 +1,325 @@
+import { Discussion, DiscussionTopic } from '@/types/discussion';
+
+// Mock Discussion 데이터
+export const MOCK_DISCUSSIONS: Discussion[] = [
+ {
+ id: 1,
+ title: 'Next.js 15 App Router에서 서버 컴포넌트 활용법',
+ content: `Next.js 15의 App Router를 사용하면서 서버 컴포넌트와 클라이언트 컴포넌트를 어떻게 나눠야 할지 고민이 많습니다.
+
+특히 다음과 같은 경우에 어떤 선택을 하시나요?
+
+1. 데이터 페칭이 필요한 컴포넌트
+2. 사용자 인터랙션이 필요한 컴포넌트
+3. 상태 관리가 필요한 컴포넌트
+
+여러분의 경험과 노하우를 공유해주세요!`,
+ summary:
+ 'Next.js 15의 App Router를 사용하면서 서버 컴포넌트와 클라이언트 컴포넌트를 어떻게 나눠야 할지 고민이 많습니다.',
+ author: {
+ id: 1,
+ nickname: '프론트엔드개발자',
+ avatar: undefined,
+ },
+ topic: 'development',
+ tags: ['Next.js', 'React', 'Server Components'],
+ vote: {
+ agreeCount: 42,
+ disagreeCount: 3,
+ myVote: null,
+ },
+ commentCount: 15,
+ comments: [
+ {
+ id: 101,
+ author: { id: 2, nickname: '시니어개발자' },
+ content:
+ '저는 데이터 페칭은 무조건 서버 컴포넌트에서 하고, useState나 useEffect가 필요한 부분만 클라이언트 컴포넌트로 분리합니다.',
+ createdAt: '2026-01-19T10:30:00Z',
+ isAuthor: false,
+ },
+ {
+ id: 102,
+ author: { id: 3, nickname: '주니어개발자' },
+ content:
+ '저도 같은 고민을 했었는데, 결국 성능 측정해보고 결정하는게 제일 좋더라구요!',
+ createdAt: '2026-01-19T11:15:00Z',
+ isAuthor: false,
+ },
+ ],
+ viewCount: 1234,
+ createdAt: '2026-01-18T14:30:00Z',
+ updatedAt: '2026-01-18T14:30:00Z',
+ lastActivityAt: '2026-01-19T11:15:00Z',
+ },
+ {
+ id: 2,
+ title: '효과적인 스터디 방법 공유합니다',
+ content: `저는 3개월간 매일 아침 6시에 일어나서 1시간씩 알고리즘 문제를 풀었습니다.
+
+처음에는 정말 힘들었지만, 2주가 지나니 습관이 되더라구요.
+
+제가 사용한 방법:
+- 전날 밤 11시 전에 잠들기
+- 알람을 침대에서 먼 곳에 설정
+- 스터디 그룹에서 서로 인증하기
+- 작은 목표부터 시작하기
+
+여러분은 어떤 방법을 사용하시나요?`,
+ summary:
+ '저는 3개월간 매일 아침 6시에 일어나서 1시간씩 알고리즘 문제를 풀었습니다. 처음에는 정말 힘들었지만, 2주가 지나니 습관이 되더라구요.',
+ author: {
+ id: 4,
+ nickname: '아침형인간',
+ },
+ topic: 'study',
+ tags: ['습관', '알고리즘', '아침 루틴'],
+ vote: {
+ agreeCount: 89,
+ disagreeCount: 2,
+ myVote: 'agree',
+ },
+ commentCount: 23,
+ comments: [],
+ viewCount: 2567,
+ createdAt: '2026-01-17T09:00:00Z',
+ updatedAt: '2026-01-17T09:00:00Z',
+ lastActivityAt: '2026-01-19T08:45:00Z',
+ },
+ {
+ id: 3,
+ title: 'TypeScript의 제네릭은 언제 사용해야 할까요?',
+ content: `TypeScript를 배우고 있는데 제네릭 개념이 너무 어렵습니다.
+
+어떤 상황에서 제네릭을 사용하는 것이 좋을까요?
+그리고 제네릭을 잘 사용하는 팁이 있다면 공유해주세요!`,
+ summary:
+ 'TypeScript를 배우고 있는데 제네릭 개념이 너무 어렵습니다. 어떤 상황에서 제네릭을 사용하는 것이 좋을까요?',
+ author: {
+ id: 5,
+ nickname: 'TS초보',
+ },
+ topic: 'question',
+ tags: ['TypeScript', '제네릭', '질문'],
+ vote: {
+ agreeCount: 15,
+ disagreeCount: 1,
+ myVote: null,
+ },
+ commentCount: 8,
+ comments: [],
+ viewCount: 456,
+ createdAt: '2026-01-19T08:00:00Z',
+ updatedAt: '2026-01-19T08:00:00Z',
+ lastActivityAt: '2026-01-19T10:20:00Z',
+ },
+ {
+ id: 4,
+ title: '개발자 취업 준비, 이렇게 했습니다',
+ content: `6개월간 개발자 취업 준비를 하고 드디어 합격했습니다!
+
+제가 한 것들:
+1. 백준 골드 5 달성
+2. 개인 프로젝트 3개 (Next.js, Spring Boot)
+3. 기술 블로그 운영 (주 1회 포스팅)
+4. 오픈소스 기여 경험
+5. 네트워킹 (개발자 밋업 참석)
+
+가장 도움이 된 것은 역시 실전 프로젝트 경험이었습니다.`,
+ summary:
+ '6개월간 개발자 취업 준비를 하고 드디어 합격했습니다! 제가 한 것들과 가장 도움이 된 것을 공유합니다.',
+ author: {
+ id: 6,
+ nickname: '신입개발자',
+ },
+ topic: 'free',
+ tags: ['취업', '경험담', '신입'],
+ vote: {
+ agreeCount: 156,
+ disagreeCount: 5,
+ myVote: null,
+ },
+ commentCount: 45,
+ comments: [],
+ viewCount: 3421,
+ createdAt: '2026-01-16T15:30:00Z',
+ updatedAt: '2026-01-16T15:30:00Z',
+ lastActivityAt: '2026-01-19T09:30:00Z',
+ },
+ {
+ id: 5,
+ title: 'React Query vs SWR, 어떤 것을 선택해야 할까요?',
+ content: `새 프로젝트를 시작하는데 데이터 페칭 라이브러리를 고민 중입니다.
+
+React Query (TanStack Query)와 SWR 중 어떤 것이 더 좋을까요?
+
+각각의 장단점과 실제 사용 경험을 공유해주시면 감사하겠습니다.`,
+ summary:
+ '새 프로젝트를 시작하는데 데이터 페칭 라이브러리를 고민 중입니다. React Query와 SWR 중 어떤 것이 더 좋을까요?',
+ author: {
+ id: 7,
+ nickname: '기술선택고민중',
+ },
+ topic: 'development',
+ tags: ['React Query', 'SWR', '비교'],
+ vote: {
+ agreeCount: 28,
+ disagreeCount: 7,
+ myVote: 'disagree',
+ },
+ commentCount: 19,
+ comments: [],
+ viewCount: 892,
+ createdAt: '2026-01-19T07:00:00Z',
+ updatedAt: '2026-01-19T07:00:00Z',
+ lastActivityAt: '2026-01-19T12:00:00Z',
+ },
+ {
+ id: 6,
+ title: '코드 리뷰 문화, 어떻게 만들어가나요?',
+ content: `팀에 코드 리뷰 문화가 없어서 도입하려고 합니다.
+
+어떻게 시작하면 좋을까요?
+그리고 효과적인 코드 리뷰 방법이 있다면 공유해주세요!`,
+ summary:
+ '팀에 코드 리뷰 문화가 없어서 도입하려고 합니다. 어떻게 시작하면 좋을까요?',
+ author: {
+ id: 8,
+ nickname: '팀리더',
+ },
+ topic: 'development',
+ tags: ['코드리뷰', '문화', '협업'],
+ vote: {
+ agreeCount: 67,
+ disagreeCount: 3,
+ myVote: null,
+ },
+ commentCount: 31,
+ comments: [],
+ viewCount: 1567,
+ createdAt: '2026-01-18T10:00:00Z',
+ updatedAt: '2026-01-18T10:00:00Z',
+ lastActivityAt: '2026-01-19T11:30:00Z',
+ },
+ {
+ id: 7,
+ title: '스터디 모임, 온라인 vs 오프라인?',
+ content: `코로나 이후로 계속 온라인 스터디만 했는데, 요즘 오프라인도 고려 중입니다.
+
+여러분은 어떤 방식을 선호하시나요?
+각각의 장단점을 경험해보신 분들의 의견이 궁금합니다!`,
+ summary:
+ '코로나 이후로 계속 온라인 스터디만 했는데, 요즘 오프라인도 고려 중입니다. 여러분은 어떤 방식을 선호하시나요?',
+ author: {
+ id: 9,
+ nickname: '스터디장',
+ },
+ topic: 'study',
+ tags: ['스터디', '온라인', '오프라인'],
+ vote: {
+ agreeCount: 34,
+ disagreeCount: 28,
+ myVote: null,
+ },
+ commentCount: 42,
+ comments: [],
+ viewCount: 1123,
+ createdAt: '2026-01-17T16:00:00Z',
+ updatedAt: '2026-01-17T16:00:00Z',
+ lastActivityAt: '2026-01-19T13:15:00Z',
+ },
+ {
+ id: 8,
+ title: 'Git 커밋 메시지 컨벤션 추천해주세요',
+ content: `팀 프로젝트를 시작하는데 Git 커밋 메시지 규칙을 정하려고 합니다.
+
+Conventional Commits를 사용하시나요?
+아니면 다른 컨벤션을 사용하시나요?
+
+실제로 사용하시는 커밋 메시지 예시와 함께 공유해주시면 감사하겠습니다!`,
+ summary:
+ '팀 프로젝트를 시작하는데 Git 커밋 메시지 규칙을 정하려고 합니다. 어떤 컨벤션을 추천하시나요?',
+ author: {
+ id: 10,
+ nickname: 'Git초보',
+ },
+ topic: 'question',
+ tags: ['Git', '커밋', '컨벤션'],
+ vote: {
+ agreeCount: 23,
+ disagreeCount: 2,
+ myVote: null,
+ },
+ commentCount: 14,
+ comments: [],
+ viewCount: 678,
+ createdAt: '2026-01-19T09:30:00Z',
+ updatedAt: '2026-01-19T09:30:00Z',
+ lastActivityAt: '2026-01-19T12:45:00Z',
+ },
+];
+
+// 토픽 한글 라벨
+export const TOPIC_LABELS: Record = {
+ all: '전체',
+ development: '개발',
+ study: '스터디',
+ free: '자유',
+ question: '질문',
+};
+
+// Mock API 함수들
+export const mockFetchDiscussions = async (params: {
+ q?: string;
+ sort?: 'latest' | 'popular';
+ topic?: DiscussionTopic;
+ page?: number;
+ limit?: number;
+}): Promise<{ items: Discussion[]; hasMore: boolean; total: number }> => {
+ // 시뮬레이션 딜레이
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ let filtered = [...MOCK_DISCUSSIONS];
+
+ // 검색 필터
+ if (params.q) {
+ const query = params.q.toLowerCase();
+ filtered = filtered.filter(
+ (d) =>
+ d.title.toLowerCase().includes(query) ||
+ d.content.toLowerCase().includes(query) ||
+ d.tags.some((tag) => tag.toLowerCase().includes(query)),
+ );
+ }
+
+ // 토픽 필터
+ if (params.topic && params.topic !== 'all') {
+ filtered = filtered.filter((d) => d.topic === params.topic);
+ }
+
+ // 정렬
+ if (params.sort === 'popular') {
+ filtered.sort((a, b) => b.vote.agreeCount - a.vote.agreeCount);
+ } else {
+ // latest (기본값)
+ filtered.sort(
+ (a, b) =>
+ new Date(b.lastActivityAt).getTime() -
+ new Date(a.lastActivityAt).getTime(),
+ );
+ }
+
+ const page = params.page || 1;
+ const limit = params.limit || 10;
+ const start = (page - 1) * limit;
+ const end = start + limit;
+
+ const items = filtered.slice(start, end);
+ const hasMore = end < filtered.length;
+
+ return {
+ items,
+ hasMore,
+ total: filtered.length,
+ };
+};
diff --git a/src/types/archive.ts b/src/types/archive.ts
new file mode 100644
index 00000000..9e2605a9
--- /dev/null
+++ b/src/types/archive.ts
@@ -0,0 +1,29 @@
+export interface ArchiveItem {
+ id: number;
+ title: string;
+ description: string;
+ author: string;
+ date: string;
+ views: number;
+ likes: number;
+ link: string;
+ isLiked: boolean;
+ isBookmarked: boolean;
+ tags: string[];
+}
+
+export interface GetArchiveParams {
+ page: number;
+ size: number;
+ sort?: 'LATEST' | 'VIEWS' | 'LIKES';
+ search?: string;
+ bookmarkedOnly?: boolean;
+}
+
+export interface ArchiveResponse {
+ content: ArchiveItem[];
+ totalPages: number;
+ totalElements: number;
+ first: boolean;
+ last: boolean;
+}
diff --git a/src/types/balance-game.ts b/src/types/balance-game.ts
new file mode 100644
index 00000000..b4a9c006
--- /dev/null
+++ b/src/types/balance-game.ts
@@ -0,0 +1,95 @@
+export interface BalanceGameOption {
+ id: number;
+ label: string;
+ voteCount: number;
+ percentage: number;
+}
+
+export interface BalanceGameAuthor {
+ id: number;
+ nickname: string;
+ profileImage: string | null;
+}
+
+export interface DailyStatistic {
+ date: string;
+ percentages: { [key: string]: number };
+}
+
+export interface BalanceGame {
+ id: number;
+ title: string;
+ description: string;
+ options: BalanceGameOption[];
+ totalVotes: number;
+ commentCount?: number;
+ myVote?: number | null;
+ createdAt: string;
+ endsAt: string;
+ isActive?: boolean;
+ tags?: string[];
+ author: BalanceGameAuthor;
+ dailyStats?: DailyStatistic[];
+}
+
+export interface Pageable {
+ pageNumber: number;
+ pageSize: number;
+}
+
+export interface BalanceGameListResponse {
+ content: BalanceGame[];
+ pageable: Pageable;
+ totalElements: number;
+ totalPages: number;
+}
+
+export interface BalanceGameComment {
+ id: number;
+ content: string;
+ createdAt: string;
+ votedOption: string;
+ author: BalanceGameAuthor;
+ isAuthor: boolean;
+}
+
+export interface BalanceGameCommentListResponse {
+ content: BalanceGameComment[];
+ pageable: Pageable;
+ totalElements: number;
+ totalPages: number;
+}
+
+export interface CreateBalanceGameRequest {
+ title: string;
+ description: string;
+ options: string[];
+ endsAt?: string;
+ tags: string[];
+}
+
+export interface UpdateBalanceGameRequest {
+ title?: string;
+ description?: string;
+ tags?: string[];
+}
+
+export interface CreateCommentRequest {
+ content: string;
+}
+
+export interface UpdateCommentRequest {
+ content: string;
+}
+
+export interface VoteRequest {
+ optionId: number;
+}
+
+// Common Response Wrapper (Actual Backend Structure)
+export interface ApiResponse {
+ content: T; // Changed from data to content
+ statusCode: number; // Changed from status to statusCode
+ message: string;
+ timestamp?: string; // Added timestamp
+}
diff --git a/src/types/discussion.ts b/src/types/discussion.ts
new file mode 100644
index 00000000..f9f53ea2
--- /dev/null
+++ b/src/types/discussion.ts
@@ -0,0 +1,62 @@
+// Discussion 관련 타입 정의
+
+export type DiscussionTopic =
+ | 'all'
+ | 'development'
+ | 'study'
+ | 'free'
+ | 'question';
+export type SortOption = 'latest' | 'popular';
+export type VoteType = 'agree' | 'disagree';
+
+export interface DiscussionAuthor {
+ id: number;
+ nickname: string;
+ avatar?: string;
+}
+
+export interface DiscussionVote {
+ agreeCount: number;
+ disagreeCount: number;
+ myVote?: VoteType | null;
+}
+
+export interface DiscussionComment {
+ id: number;
+ author: DiscussionAuthor;
+ content: string;
+ createdAt: string;
+ isAuthor: boolean; // 현재 로그인 유저가 작성자인지
+}
+
+export interface Discussion {
+ id: number;
+ title: string;
+ content: string;
+ summary: string; // 목록용 요약 (2줄)
+ author: DiscussionAuthor;
+ topic: DiscussionTopic;
+ tags: string[];
+ vote: DiscussionVote;
+ commentCount: number;
+ comments: DiscussionComment[];
+ viewCount: number;
+ createdAt: string;
+ updatedAt: string;
+ lastActivityAt: string; // 최신 활동 시간 (댓글 등)
+}
+
+export interface DiscussionListParams {
+ q?: string; // 검색 키워드
+ sort?: SortOption;
+ topic?: DiscussionTopic;
+ page?: number;
+ limit?: number;
+}
+
+export interface DiscussionListResponse {
+ items: Discussion[];
+ total: number;
+ page: number;
+ hasMore: boolean;
+}
diff --git a/src/types/hall-of-fame.ts b/src/types/hall-of-fame.ts
new file mode 100644
index 00000000..207ee707
--- /dev/null
+++ b/src/types/hall-of-fame.ts
@@ -0,0 +1,64 @@
+// 명예의 전당 API 타입 정의
+
+export interface ApiResponse {
+ statusCode: number;
+ timestamp: string;
+ content: T;
+ message: string;
+}
+
+export interface Job {
+ job?: string; // Enum 값 (예: "IT_PRACTITIONER_GAME_DEV")
+ description?: string; // 설명 (예: "게임 개발자")
+}
+
+export interface Ranker {
+ rank: number;
+ userId: number;
+ nickname: string;
+ profileImage: string | null;
+ score: number;
+ sincerity: number | null;
+ major: string;
+ jobs?: Job[] | null; // 직무 정보
+ lastActive: string;
+}
+
+export interface Rankings {
+ attendanceRankings: Ranker[];
+ studyLogRankings: Ranker[];
+ sincerityRankings: Ranker[];
+ baseDate: string;
+}
+
+export interface MVPTeamMember {
+ userId: number;
+ nickname: string;
+ profileImage: string | null;
+}
+
+export interface SharedLink {
+ id: number;
+ title: string;
+ url: string;
+ sharedAt: string;
+}
+
+export interface MVPTeam {
+ id: number;
+ studyId: number;
+ studyTitle: string;
+ members: [MVPTeamMember, MVPTeamMember];
+ sharedLinks: SharedLink[];
+ weekDate: string;
+ weekStartDate: string;
+ weekEndDate: string;
+ totalSharedLinks: number;
+}
+
+export interface HallOfFameData {
+ rankings: Rankings;
+ mvpTeam: MVPTeam | null;
+}
+
+export type HallOfFameResponse = ApiResponse;
diff --git a/src/types/schemas/zod-schema.ts b/src/types/schemas/zod-schema.ts
index 72126071..541cf2bc 100644
--- a/src/types/schemas/zod-schema.ts
+++ b/src/types/schemas/zod-schema.ts
@@ -16,3 +16,79 @@ export const UrlSchema = z
.refine((v) => !v || isValidUrl(v), {
message: '올바른 URL 형식이 아닙니다.',
});
+
+// Discussion 댓글 폼 스키마
+export const CommentFormSchema = z.object({
+ content: z
+ .string()
+ .trim()
+ .min(1, '댓글 내용을 입력해주세요.')
+ .max(1000, '댓글은 1000자 이하로 입력해주세요.'),
+});
+
+export type CommentFormData = z.infer;
+
+// Discussion 작성 폼 스키마
+export const DiscussionFormSchema = z.object({
+ title: z
+ .string()
+ .trim()
+ .min(5, '제목은 5자 이상 입력해주세요.')
+ .max(100, '제목은 100자 이하로 입력해주세요.'),
+ content: z
+ .string()
+ .trim()
+ .min(10, '내용은 10자 이상 입력해주세요.')
+ .max(5000, '내용은 5000자 이하로 입력해주세요.'),
+ topic: z.enum(['development', 'study', 'free', 'question'], {
+ message: '주제를 선택해주세요.',
+ }),
+ tags: z
+ .array(z.string())
+ .min(1, '태그를 1개 이상 입력해주세요.')
+ .max(5, '태그는 5개까지만 입력 가능합니다.')
+ .optional(),
+});
+
+export type DiscussionFormData = z.infer;
+
+// Voting 생성 폼 스키마
+export const VotingCreateFormSchema = z.object({
+ title: z
+ .string()
+ .trim()
+ .min(5, '제목은 5자 이상 입력해주세요.')
+ .max(200, '제목은 200자 이하로 입력해주세요.'),
+ description: z
+ .string()
+ .trim()
+ .max(500, '설명은 500자 이하로 입력해주세요.')
+ .optional(),
+ options: z
+ .array(
+ z.object({
+ label: z
+ .string()
+ .trim()
+ .min(1, '선택지를 입력해주세요.')
+ .max(100, '선택지는 100자 이하로 입력해주세요.'),
+ }),
+ )
+ .min(2, '선택지는 최소 2개 이상 입력해주세요.')
+ .max(5, '선택지는 최대 5개까지 입력 가능합니다.'),
+ tags: z
+ .array(z.string().trim().min(1))
+ .max(3, '태그는 최대 3개까지 입력 가능합니다.')
+ .optional(),
+ endsAt: z.string().optional(), // ISO date string
+});
+
+export type VotingCreateFormData = z.infer;
+
+// Voting 수정 폼 스키마 (옵션/마감일 수정 불가)
+export const VotingEditFormSchema = VotingCreateFormSchema.omit({
+ options: true,
+ endsAt: true,
+});
+
+export type VotingEditFormData = z.infer;
diff --git a/src/types/study-history.ts b/src/types/study-history.ts
new file mode 100644
index 00000000..fcae31f5
--- /dev/null
+++ b/src/types/study-history.ts
@@ -0,0 +1,74 @@
+'use client';
+
+// 백엔드 API 응답 구조에 맞춘 타입 정의
+
+export type StudyRole = 'INTERVIEWER' | 'INTERVIEWEE';
+export type AttendanceStatus = 'PRESENT' | 'PENDING' | 'ABSENT'; // 백엔드 값에 맞춤 (PRESENT, PENDING 등)
+export type StudyStatus = 'COMPLETE' | 'IN_PROGRESS' | 'PENDING';
+
+export interface StudyHistoryContent {
+ studyId: number;
+ title: string;
+ scheduledAt: string; // ISO Date String
+ status: StudyStatus;
+ studyLink: string | null;
+ participation: {
+ role: StudyRole;
+ attendance: AttendanceStatus;
+ };
+ partner: {
+ memberId: number;
+ nickname: string;
+ profileImageUrl: string | null;
+ };
+}
+
+export interface PageableResponse {
+ statusCode: number;
+ timestamp: string;
+ content: {
+ content: T[];
+ pageable: {
+ pageNumber: number;
+ pageSize: number;
+ sort: {
+ sorted: boolean;
+ unsorted: boolean;
+ empty: boolean;
+ };
+ offset: number;
+ paged: boolean;
+ unpaged: boolean;
+ };
+ totalElements: number;
+ totalPages: number;
+ last: boolean;
+ first: boolean;
+ size: number;
+ number: number;
+ sort: {
+ sorted: boolean;
+ unsorted: boolean;
+ empty: boolean;
+ };
+ numberOfElements: number;
+ empty: boolean;
+ };
+ message: string;
+}
+
+// 프론트엔드 컴포넌트에서 사용하기 편하게 변환한 타입 (UI용)
+export interface StudyHistoryItem {
+ id: number;
+ date: string;
+ subject: string;
+ role: StudyRole;
+ attendance: 'ATTENDED' | 'NOT_STARTED'; // UI 표현용 (PRESENT -> ATTENDED, PENDING -> NOT_STARTED)
+ link: string | null;
+ status: 'COMPLETED' | 'IN_PROGRESS'; // UI 표현용
+ partner: {
+ id: number;
+ name: string;
+ profileImage: string | null;
+ };
+}
diff --git a/src/types/voting.ts b/src/types/voting.ts
new file mode 100644
index 00000000..800bbcc2
--- /dev/null
+++ b/src/types/voting.ts
@@ -0,0 +1,52 @@
+// Voting (투표형 토론) 관련 타입 정의
+
+export interface VotingOption {
+ id: number;
+ label: string;
+ voteCount: number;
+ percentage: number;
+}
+
+export interface VotingAuthor {
+ id: number;
+ nickname: string;
+ avatar?: string;
+}
+
+export interface VotingComment {
+ id: number;
+ author: VotingAuthor;
+ content: string;
+ createdAt: string;
+ isAuthor: boolean;
+ votedOption?: string; // 작성자가 어떤 선택지에 투표했는지
+}
+
+export interface DailyStatistic {
+ date: string; // 날짜 (예: "1일", "2일")
+ percentages: { [optionId: number]: number }; // 각 선택지의 퍼센트
+}
+
+export interface Voting {
+ id: number;
+ round: number; // 라운드 번호
+ title: string; // 투표 주제
+ description?: string; // 주제 설명 (선택)
+ options: VotingOption[]; // 선택지 목록 (2~5개)
+ totalVotes: number; // 총 투표 수
+ myVote?: number; // 내가 투표한 옵션 ID (null이면 아직 투표 안함)
+ commentCount: number;
+ comments: VotingComment[];
+ createdAt: string;
+ endsAt?: string; // 투표 마감 시간 (선택)
+ isActive: boolean; // 진행 중인지 종료되었는지
+ tags: string[];
+ dailyStats?: DailyStatistic[]; // 일별 통계
+ author: VotingAuthor; // 작성자 정보
+}
+
+export interface VotingListResponse {
+ items: Voting[];
+ total: number;
+ hasMore: boolean;
+}
diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts
index 7b3c3b4e..573dc75e 100644
--- a/src/utils/jwt.ts
+++ b/src/utils/jwt.ts
@@ -1,16 +1,26 @@
export const decodeJwt = (token: string) => {
if (!token) return null;
- const base64Url = token.split('.')[1];
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map(function (c) {
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
- })
- .join(''),
- );
+ // JWT 형식 검사 (x.y.z 형태여야 함)
+ const parts = token.split('.');
+ if (parts.length !== 3) {
+ return null; // 형식이 맞지 않으면 조용히 null 반환
+ }
- return JSON.parse(jsonPayload);
+ try {
+ const base64Url = parts[1];
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+ const jsonPayload = decodeURIComponent(
+ atob(base64)
+ .split('')
+ .map(function (c) {
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
+ })
+ .join(''),
+ );
+
+ return JSON.parse(jsonPayload);
+ } catch (e) {
+ return null; // 디코딩 실패 시 null 반환
+ }
};
diff --git a/src/widgets/home/calendar.tsx b/src/widgets/home/calendar.tsx
index fa5706b6..c9c68e4d 100644
--- a/src/widgets/home/calendar.tsx
+++ b/src/widgets/home/calendar.tsx
@@ -9,7 +9,7 @@ import {
} from 'react-day-picker';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
import { Calendar as ShadcnCalendar } from '@/components/ui/(shadcn)/ui/calendar';
-import { useMonthlyStudyCalendarQuery } from '@/features/study/schedule/model/use-schedule-query';
+import { useMonthlyStudyCalendarQuery } from '@/features/study/one-to-one/schedule/model/use-schedule-query';
interface CalendarDayProps extends HTMLAttributes {
day: DayPickerDay;
diff --git a/src/widgets/home/home-dashboard.tsx b/src/widgets/home/home-dashboard.tsx
new file mode 100644
index 00000000..ab06ae7d
--- /dev/null
+++ b/src/widgets/home/home-dashboard.tsx
@@ -0,0 +1,246 @@
+import Image from 'next/image';
+import Link from 'next/link';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import UserAvatar from '@/components/ui/avatar';
+import { getSincerityPresetByLevelName } from '@/config/sincerity-temp-presets';
+import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server';
+import StartStudyModal from '@/features/study/participation/ui/start-study-modal';
+import { getServerCookie } from '@/utils/server-cookie';
+import FeedbackLink from '@/widgets/home/feedback-link';
+import AccessTimeIcon from 'public/icons/access_time.svg';
+import AssignmentIcon from 'public/icons/assignment.svg';
+import CodeIcon from 'public/icons/code.svg';
+import SettingIcon from 'public/icons/setting.svg';
+
+export default async function HomeDashboard() {
+ const memberIdStr = await getServerCookie('memberId');
+ const memberId = Number(memberIdStr);
+
+ const userProfile = await getUserProfileInServer(memberId);
+ const temperPreset = getSincerityPresetByLevelName(
+ userProfile.sincerityTemp.levelName,
+ );
+
+ // 데이터 가공
+ const subject =
+ userProfile?.memberInfo.preferredStudySubject?.name || '미설정';
+ const timeSlots =
+ userProfile?.memberInfo.availableStudyTimes
+ ?.map((t) => t.label)
+ .join(', ') || '시간 협의 가능';
+ const techStacks =
+ userProfile?.memberProfile.techStacks
+ ?.slice(0, 3)
+ .map((t) => t.techStackName)
+ .join(' · ') || '미설정';
+ const extraTechCount = Math.max(
+ 0,
+ (userProfile?.memberProfile.techStacks?.length || 0) - 3,
+ );
+
+ return (
+
+ {/* 상단 프로필 바 */}
+
+
+
+
+
+ {userProfile.sincerityTemp.temperature.toFixed(1)}℃
+
+
+
+
+
+
+ {userProfile?.memberProfile.nickname || '비회원'}님
+
+
+ {userProfile.studyApplied ? '스터디 참여중' : '스터디 대기중'}
+
+
+
+ {userProfile?.memberProfile.simpleIntroduction ||
+ '오늘도 힘차게 공부해봐요! 🔥'}
+
+
+
+
+
+
+ 설정
+
+
+
+ {/* 메인 대시보드 그리드 */}
+
+ {/* 1. 스터디 정보 카드 */}
+
+
+
+
+
+
+ 선호 주제
+
+
+ {subject}
+
+
+
+
+ 가능 시간
+
+
+ {timeSlots}
+
+
+
+
+ 기술 스택
+
+
+
+ {techStacks}
+
+ {extraTechCount > 0 && (
+
+ +{extraTechCount}
+
+ )}
+
+
+
+
+
+ {/* 2. 액션 센터 */}
+
+ {userProfile.studyApplied ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ CS 스터디 시작하기
+
+
+ 매일 아침 1:1 매칭으로 루틴 만들기
+
+
+
+
+
+
+
+ }
+ />
+ >
+ )}
+
+
+ {/* 3. 피드백 & 퀵 액션 */}
+
+
+
+
+
+ {/* 추가 퀵 스탯 */}
+
+
+
+
+ );
+}
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx
index 1323a489..c09bb99f 100644
--- a/src/widgets/home/sidebar.tsx
+++ b/src/widgets/home/sidebar.tsx
@@ -1,4 +1,5 @@
import Image from 'next/image';
+import Link from 'next/link';
import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server';
import MyProfileCard from '@/entities/user/ui/my-profile-card';
import StartStudyModal from '@/features/study/participation/ui/start-study-modal';
@@ -34,6 +35,9 @@ export default async function Sidebar() {
studyApplied={userProfile?.studyApplied ?? false}
sincerityTemp={userProfile.sincerityTemp}
/>
+
+ {/* 1:1 인사이트 버튼 제거됨 - 이제 홈 페이지 탭에서 접근 가능 */}
+
{userProfile.studyApplied ? (
) : (
diff --git a/src/widgets/home/study-list-table.tsx b/src/widgets/home/study-list-table.tsx
index e18510f6..f3db456d 100644
--- a/src/widgets/home/study-list-table.tsx
+++ b/src/widgets/home/study-list-table.tsx
@@ -4,8 +4,8 @@ import { sendGTMEvent } from '@next/third-parties/google';
import UserAvatar from '@/components/ui/avatar';
import TableList from '@/components/ui/table';
import { getStatusBadge } from '@/features/study/interview/ui/status-badge-map';
-import { DailyStudy } from '@/features/study/schedule/api/schedule-types';
-import { useDailyStudiesQuery } from '@/features/study/schedule/model/use-schedule-query';
+import { DailyStudy } from '@/features/study/one-to-one/schedule/api/schedule-types';
+import { useDailyStudiesQuery } from '@/features/study/one-to-one/schedule/model/use-schedule-query';
import LinkIcon from 'public/icons/Link.svg';
const headers = [
diff --git a/yarn.lock b/yarn.lock
index 99c36a6a..83e3c7ad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5258,6 +5258,15 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
+framer-motion@^12.27.1:
+ version "12.27.1"
+ resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.27.1.tgz#bdd8e41f43df493977a0fc9d80ae3acd2731a741"
+ integrity sha512-cEAqO69kcZt3gL0TGua8WTgRQfv4J57nqt1zxHtLKwYhAwA0x9kDS/JbMa1hJbwkGY74AGJKvZ9pX/IqWZtZWQ==
+ dependencies:
+ motion-dom "^12.27.1"
+ motion-utils "^12.24.10"
+ tslib "^2.4.0"
+
fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4"
@@ -6933,6 +6942,18 @@ module-alias@^2.2.3:
resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.3.tgz#ec2e85c68973bda6ab71ce7c93b763ec96053221"
integrity sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==
+motion-dom@^12.27.1:
+ version "12.27.1"
+ resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.27.1.tgz#874bb0f49196fb7932173f333bfdc37676a1dbad"
+ integrity sha512-V/53DA2nBqKl9O2PMJleSUb/G0dsMMeZplZwgIQf5+X0bxIu7Q1cTv6DrjvTTGYRm3+7Y5wMlRZ1wT61boU/bQ==
+ dependencies:
+ motion-utils "^12.24.10"
+
+motion-utils@^12.24.10:
+ version "12.24.10"
+ resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.24.10.tgz#73d0bead3c08c4ba2965a5f7ee39dd53f661ae7e"
+ integrity sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==
+
mrmime@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
@@ -8214,7 +8235,16 @@ strict-event-emitter@^0.5.1:
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93"
integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==
-"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8324,7 +8354,14 @@ stringify-object@^5.0.0:
is-obj "^3.0.0"
is-regexp "^3.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6, strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@6, strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -9098,7 +9135,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -9116,6 +9153,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
+wrap-ansi@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"