diff --git a/docs/admin-migration.md b/docs/admin-migration.md deleted file mode 100644 index 3f280b5..0000000 --- a/docs/admin-migration.md +++ /dev/null @@ -1,549 +0,0 @@ -# 관리자 페이지 마이그레이션 계획서 - -## 개요 - -- **목표**: `frontend/src/pages/pc/admin/` → `frontend-temp/src/pages/admin/` -- **현재 규모**: 12개 파일, 약 7,535 LOC -- **방식**: 일반 페이지와 동일한 Strangler Fig Pattern 적용 -- **특이사항**: PC 전용 (모바일 관리자 페이지 없음) - ---- - -## 현재 구조 분석 - -### 관리자 페이지 (12개) - -| 파일 | LOC | 설명 | 복잡도 | -|------|-----|------|--------| -| AdminLogin.jsx | 168 | 로그인 폼 | 낮음 | -| AdminDashboard.jsx | 167 | 대시보드 (통계) | 낮음 | -| AdminMembers.jsx | 180 | 멤버 목록 | 낮음 | -| AdminMemberEdit.jsx | 382 | 멤버 수정 폼 | 중간 | -| AdminAlbums.jsx | 222 | 앨범 목록 | 낮음 | -| AdminAlbumForm.jsx | 666 | 앨범 생성/수정 폼 | 높음 | -| AdminAlbumPhotos.jsx | 1,538 | 사진 업로드 (SSE) | 매우 높음 | -| AdminSchedule.jsx | 1,465 | 일정 목록/검색 | 매우 높음 | -| AdminScheduleForm.jsx | 1,173 | 일정 생성/수정 폼 | 높음 | -| AdminScheduleCategory.jsx | 463 | 카테고리 관리 | 중간 | -| AdminScheduleDict.jsx | 664 | 일정 사전 관리 | 중간 | -| AdminScheduleBots.jsx | 447 | 봇 관리 | 중간 | - -### 관리자 컴포넌트 (3개) - -| 파일 | 설명 | -|------|------| -| AdminLayout.jsx | 관리자 레이아웃 (헤더 + 본문) | -| AdminHeader.jsx | 관리자 헤더 (네비게이션) | -| NumberPicker.jsx | 숫자 입력 컴포넌트 | - -### 관리자 API (8개) - -| 파일 | 설명 | -|------|------| -| auth.js | 로그인/인증 | -| albums.js | 앨범 CRUD | -| members.js | 멤버 CRUD | -| schedules.js | 일정 CRUD | -| categories.js | 카테고리 CRUD | -| suggestions.js | 검색어 추천 관리 | -| bots.js | 봇 관리 | -| stats.js | 통계 조회 | - ---- - -## 마이그레이션 전략 - -### 우선순위 - -복잡도와 의존성을 고려하여 순차적으로 진행: - -1. **1단계**: 폴더 구조 재편 (기존 파일을 새 구조로 이동) -2. **2단계**: 관리자 기반 설정 (레이아웃, API, 인증) -3. **3단계**: 간단한 페이지 (로그인, 대시보드, 멤버) -4. **4단계**: 앨범 관리 -5. **5단계**: 일정 관리 (가장 복잡) - -### 코딩 가이드라인 - -공개 영역과 동일한 패턴 적용: -- `useQuery` / `useMutation` 사용 -- 서비스 레이어 분리 -- 공통 컴포넌트 재사용 -- TypeScript JSDoc 타입 정의 - ---- - -## 1단계: 폴더 구조 재편 - -기존 frontend-temp 파일들을 새로운 구조로 이동합니다. - -### 1.1 현재 구조 → 새 구조 - -``` -현재 → 새 구조 -───────────────────────────────────────────────────────── -api/ -├── client.js → api/common/client.js -├── index.js → (삭제, 각 폴더별 index.js로 대체) -├── auth.js → api/pc/admin/auth.js -├── schedules.js → api/pc/public/schedules.js -├── albums.js → api/pc/public/albums.js -└── members.js → api/pc/common/members.js - -components/ -├── common/ → components/common/ -│ ├── Loading.jsx -│ ├── ErrorBoundary.jsx -│ ├── ErrorMessage.jsx -│ ├── Toast.jsx -│ ├── Lightbox.jsx -│ └── ... -├── pc/ → components/pc/public/ -│ ├── Layout.jsx -│ ├── Header.jsx -│ ├── Footer.jsx -│ ├── Calendar.jsx -│ └── ScheduleCard.jsx -└── mobile/ → components/mobile/ - ├── Layout.jsx - ├── Header.jsx - ├── BottomNav.jsx - └── ... - -hooks/ -├── useMediaQuery.js → hooks/common/useMediaQuery.js -├── useLightbox.js → hooks/common/useLightbox.js -├── useCalendar.js → hooks/common/useCalendar.js -├── useAdminAuth.js → hooks/pc/admin/useAdminAuth.js -└── ... - -pages/ -├── home/pc/ → pages/pc/public/home/ -├── home/mobile/ → pages/mobile/home/ -├── schedule/pc/ → pages/pc/public/schedule/ -├── schedule/mobile/ → pages/mobile/schedule/ -├── album/pc/ → pages/pc/public/album/ -├── album/mobile/ → pages/mobile/album/ -├── members/pc/ → pages/pc/public/members/ -└── members/mobile/ → pages/mobile/members/ -``` - -### 1.2 import 경로 업데이트 - -파일 이동 후 모든 import 경로를 새 구조에 맞게 수정합니다. - -```javascript -// Before -import { fetchApi } from '@/api/client'; -import Loading from '@/components/common/Loading'; -import Calendar from '@/components/pc/Calendar'; - -// After -import { fetchApi } from '@/api/common/client'; -import Loading from '@/components/common/Loading'; -import Calendar from '@/components/pc/public/Calendar'; -``` - -### 1.3 index.js 파일 생성 - -각 폴더에 re-export용 index.js 생성: - -```javascript -// api/pc/public/index.js -export * from './schedules'; -export * from './albums'; - -// api/pc/admin/index.js -export * from './auth'; -export * from './schedules'; -// ... -``` - ---- - -## 2단계: 관리자 기반 설정 - -### 2.1 관리자 API 추가 - -**원칙**: 조회(GET)는 공개 API 재사용, CUD(Create/Update/Delete)는 관리자 API - -``` -api/ -├── common/ # PC, Mobile 공통 -│ └── client.js # fetch 래퍼 (fetchApi, fetchAuthApi) -│ -├── pc/ -│ ├── common/ # PC 내 공통 (public + admin 공유) -│ │ └── members.js # 멤버 조회 (일정 폼에서도 사용) -│ ├── public/ # PC 공개 API -│ │ ├── schedules.js -│ │ └── albums.js -│ └── admin/ # PC 관리자 API -│ ├── auth.js -│ ├── schedules.js -│ ├── albums.js -│ ├── members.js -│ ├── categories.js -│ ├── suggestions.js -│ ├── bots.js -│ └── stats.js -│ -└── mobile/ # 모바일 (pc/public과 동일 API 사용 가능) -``` - -**사용 예시**: -```javascript -// PC 공개 페이지 -import { fetchSchedules } from '@/api/pc/public'; - -// PC 관리자 페이지 - 조회는 pc/common 또는 pc/public 재사용 -import { fetchMembers } from '@/api/pc/common'; -import { createSchedule } from '@/api/pc/admin'; - -// 모바일 페이지 -import { fetchSchedules } from '@/api/pc/public'; // 동일 API 재사용 -``` - -### 2.2 관리자 레이아웃 - -`components/pc/admin/` 폴더 구조: - -``` -components/pc/admin/ -├── Layout.jsx # AdminLayout -├── Header.jsx # AdminHeader (네비게이션) -└── NumberPicker.jsx -``` - -### 2.3 인증 훅 수정 - -현재 `useAdminAuth.js`를 관리자 전용으로 확장: -- 토큰 검증 -- 자동 로그아웃 -- 권한 체크 - ---- - -## 3단계: 간단한 페이지 - -### 3.1 AdminLogin - -- 로그인 폼 컴포넌트화 -- `useMutation`으로 로그인 처리 -- 에러 처리 표준화 - -### 3.2 AdminDashboard - -- 통계 API 호출 (`useQuery`) -- 차트/카드 컴포넌트화 -- 로딩/에러 상태 처리 - -### 3.3 AdminMembers / AdminMemberEdit - -- 멤버 목록 테이블 -- 멤버 수정 폼 -- 이미지 업로드 처리 - ---- - -## 4단계: 앨범 관리 - -### 4.1 AdminAlbums - -- 앨범 목록 테이블 -- 삭제 확인 모달 -- 정렬/필터링 - -### 4.2 AdminAlbumForm - -폼 필드: -- 기본 정보 (이름, 발매일, 타입) -- 트랙 목록 (동적 추가/삭제) -- 티저 목록 (동적 추가/삭제) -- 앨범 커버 업로드 - -컴포넌트 분리: -``` -components/pc/admin/album/ -├── AlbumBasicForm.jsx # 기본 정보 -├── TrackListForm.jsx # 트랙 관리 -├── TeaserListForm.jsx # 티저 관리 -└── CoverUploader.jsx # 커버 업로드 -``` - -### 4.3 AdminAlbumPhotos - -가장 복잡한 페이지: -- SSE 스트리밍 업로드 -- 진행률 표시 -- 멤버 태깅 -- 드래그 앤 드롭 - -훅 분리: -``` -hooks/pc/admin/ -├── usePhotoUpload.js # SSE 업로드 로직 -├── usePhotoList.js # 사진 목록 관리 -└── useMemberTag.js # 멤버 태깅 -``` - ---- - -## 5단계: 일정 관리 - -### 5.1 AdminSchedule - -기능: -- 일정 목록 (가상 스크롤) -- 검색/필터링 -- 일괄 삭제 - -훅 재사용: -- `useScheduleSearch` (공개 영역과 동일) -- `useScheduleFiltering` (공개 영역과 동일) - -### 5.2 AdminScheduleForm - -폼 필드: -- 기본 정보 (제목, 날짜, 시간) -- 카테고리 선택 -- 멤버 선택 -- 출처 정보 -- 카테고리별 추가 필드 (YouTube, X) - -컴포넌트 분리: -``` -components/pc/admin/schedule/ -├── ScheduleBasicForm.jsx # 기본 정보 -├── CategorySelect.jsx # 카테고리 선택 -├── MemberSelect.jsx # 멤버 다중 선택 -├── SourceInput.jsx # 출처 입력 -├── YouTubeForm.jsx # YouTube 영상 정보 -└── XForm.jsx # X 게시글 정보 -``` - -### 5.3 AdminScheduleCategory - -- 카테고리 CRUD -- 색상 선택기 -- 순서 변경 (드래그) - -### 5.4 AdminScheduleDict - -- 일정 사전 관리 -- 자동완성 데이터 - -### 5.5 AdminScheduleBots - -- 봇 목록/상태 -- 동기화 실행 -- 로그 확인 - ---- - -## 목표 구조 - -``` -frontend-temp/src/ -├── api/ -│ ├── common/ # PC, Mobile 공통 -│ │ └── client.js # fetch 래퍼 -│ ├── pc/ -│ │ ├── common/ # PC 내 공통 (public + admin 공유) -│ │ │ └── members.js # 멤버 조회 (폼에서 공유) -│ │ ├── public/ # PC 공개 API -│ │ │ ├── schedules.js -│ │ │ └── albums.js -│ │ └── admin/ # PC 관리자 API -│ │ ├── auth.js -│ │ ├── schedules.js -│ │ ├── albums.js -│ │ ├── members.js -│ │ ├── categories.js -│ │ ├── suggestions.js -│ │ ├── bots.js -│ │ └── stats.js -│ └── mobile/ # 모바일 API -│ └── (public과 동일하게 사용) -│ -├── components/ -│ ├── common/ # PC, Mobile 공통 -│ │ ├── Loading.jsx -│ │ ├── ErrorBoundary.jsx -│ │ ├── ErrorMessage.jsx -│ │ ├── Toast.jsx -│ │ └── Lightbox/ -│ ├── pc/ -│ │ ├── common/ # PC 내 공통 -│ │ │ └── (필요시) -│ │ ├── public/ # PC 공개 컴포넌트 -│ │ │ ├── Layout.jsx -│ │ │ ├── Header.jsx -│ │ │ ├── Footer.jsx -│ │ │ ├── Calendar.jsx -│ │ │ └── ScheduleCard.jsx -│ │ └── admin/ # PC 관리자 컴포넌트 -│ │ ├── Layout.jsx -│ │ ├── Header.jsx -│ │ ├── NumberPicker.jsx -│ │ ├── ConfirmDialog.jsx -│ │ ├── album/ -│ │ │ ├── AlbumBasicForm.jsx -│ │ │ ├── TrackListForm.jsx -│ │ │ ├── TeaserListForm.jsx -│ │ │ └── CoverUploader.jsx -│ │ └── schedule/ -│ │ ├── ScheduleBasicForm.jsx -│ │ ├── CategorySelect.jsx -│ │ ├── MemberSelect.jsx -│ │ ├── SourceInput.jsx -│ │ ├── YouTubeForm.jsx -│ │ └── XForm.jsx -│ └── mobile/ # 모바일 컴포넌트 -│ ├── Layout.jsx -│ ├── Header.jsx -│ ├── BottomNav.jsx -│ ├── Calendar.jsx -│ └── ScheduleCard.jsx -│ -├── hooks/ -│ ├── common/ # PC, Mobile 공통 -│ │ ├── useMediaQuery.js -│ │ ├── useLightbox.js -│ │ └── useCalendar.js -│ ├── pc/ -│ │ ├── common/ # PC 내 공통 -│ │ │ └── (필요시) -│ │ ├── public/ # PC 공개 훅 -│ │ │ └── (필요시) -│ │ └── admin/ # PC 관리자 훅 -│ │ ├── useAdminAuth.js -│ │ ├── usePhotoUpload.js -│ │ ├── usePhotoList.js -│ │ └── useMemberTag.js -│ └── mobile/ # 모바일 훅 -│ └── (필요시) -│ -├── pages/ -│ ├── pc/ -│ │ ├── public/ # PC 공개 페이지 -│ │ │ ├── Home.jsx -│ │ │ ├── Schedule.jsx -│ │ │ ├── Album.jsx -│ │ │ └── Members.jsx -│ │ └── admin/ # PC 관리자 페이지 -│ │ ├── Login.jsx -│ │ ├── Dashboard.jsx -│ │ ├── Members.jsx -│ │ ├── MemberEdit.jsx -│ │ ├── Albums.jsx -│ │ ├── AlbumForm.jsx -│ │ ├── AlbumPhotos.jsx -│ │ ├── Schedule.jsx -│ │ ├── ScheduleForm.jsx -│ │ ├── ScheduleCategory.jsx -│ │ ├── ScheduleDict.jsx -│ │ └── ScheduleBots.jsx -│ └── mobile/ # 모바일 페이지 -│ ├── Home.jsx -│ ├── Schedule.jsx -│ ├── Album.jsx -│ └── Members.jsx -│ -├── stores/ # 전역 상태 (플랫) -│ ├── useAuthStore.js -│ ├── useScheduleStore.js -│ └── useUIStore.js -│ -├── utils/ # 유틸리티 (플랫) -│ ├── date.js -│ ├── format.js -│ └── schedule.js -│ -└── constants/ # 상수 (플랫) - └── index.js -``` - ---- - -## 작업 체크리스트 - -### 1단계: 폴더 구조 재편 ✅ -- [x] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/) -- [x] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/) -- [x] hooks/ 구조 변경 (common/, pc/admin/) -- [x] pages/ 구조 변경 (pc/public/, mobile/) -- [x] 모든 import 경로 업데이트 -- [x] 각 폴더에 index.js 생성 (re-export) -- [x] 개발 서버 동작 확인 - -### 2단계: 관리자 기반 설정 ✅ -- [x] 관리자 API 마이그레이션 (api/pc/admin/) -- [x] AdminLayout 마이그레이션 (components/pc/admin/) -- [x] AdminHeader 마이그레이션 (components/pc/admin/) -- [x] useAdminAuth 훅 이동 (hooks/pc/admin/) -- [x] 관리자 라우트 설정 (App.jsx) -- [x] AdminLogin 페이지 마이그레이션 - -### 3단계: 간단한 페이지 ✅ -- [x] AdminLogin 마이그레이션 (2단계에서 완료) -- [x] AdminDashboard 마이그레이션 -- [x] AdminMembers 마이그레이션 -- [x] AdminMemberEdit 마이그레이션 -- [x] useToast 훅 추가 - -### 4단계: 앨범 관리 ✅ -- [x] AdminAlbums 마이그레이션 -- [x] AdminAlbumForm 마이그레이션 -- [x] AdminAlbumPhotos 마이그레이션 (SSE 처리 포함) - -### 5단계: 일정 관리 ✅ -- [x] AdminSchedule 마이그레이션 -- [x] AdminScheduleForm 마이그레이션 -- [x] AdminScheduleCategory 마이그레이션 -- [x] AdminScheduleDict 마이그레이션 -- [x] AdminScheduleBots 마이그레이션 -- [x] 카테고리별 폼 분기 페이지 마이그레이션 (form/index.jsx) -- [x] YouTubeForm 마이그레이션 (form/YouTubeForm.jsx) -- [x] XForm 마이그레이션 (form/XForm.jsx) -- [x] CategorySelector 마이그레이션 (form/components/CategorySelector.jsx) -- [x] YouTubeEditForm 마이그레이션 (edit/YouTubeEditForm.jsx) - -### 6단계: 검증 -- [ ] 공개 페이지 동작 확인 (PC/Mobile) -- [ ] 로그인/로그아웃 테스트 -- [ ] 멤버 CRUD 테스트 -- [ ] 앨범 CRUD 테스트 -- [ ] 앨범 사진 업로드 테스트 -- [ ] 일정 CRUD 테스트 -- [ ] YouTube/X 일정 등록 테스트 -- [ ] 카테고리 관리 테스트 -- [ ] 봇 관리 테스트 - ---- - -## 예상 효과 - -| 항목 | Before | After | 개선 | -|------|--------|-------|------| -| 총 LOC | ~7,535 | ~5,500 | -27% | -| 중복 코드 | 30%+ | <10% | 공통 훅/컴포넌트 활용 | -| 컴포넌트 분리 | 12개 대형 파일 | 30개+ 작은 파일 | 유지보수성 향상 | - ---- - -## 주의사항 - -1. **SSE 스트리밍**: AdminAlbumPhotos의 SSE 업로드는 별도 훅으로 분리하여 처리 -2. **인증 처리**: 모든 관리자 API 호출에 토큰 자동 첨부 -3. **에러 처리**: 401 에러 시 자동 로그아웃 및 리다이렉트 -4. **폼 검증**: React Hook Form 또는 자체 검증 로직 사용 -5. **낙관적 업데이트**: 목록 삭제 등에 useMutation의 onMutate 활용 - ---- - -## 참고 - -- 기존 문서: [frontend-refactoring.md](./frontend-refactoring.md) -- API 명세: [api.md](./api.md) -- 개발 가이드: [development.md](./development.md) diff --git a/docs/frontend-improvement.md b/docs/frontend-improvement.md new file mode 100644 index 0000000..87049c7 --- /dev/null +++ b/docs/frontend-improvement.md @@ -0,0 +1,179 @@ +# 프론트엔드 개선 계획서 + +## 1. 현재 문제점 + +### 1.1 API 구조 +``` +현재 구조: +api/ +├── common/client.js # fetch 래퍼 +├── index.js # 통합 export (혼란) +├── pc/ +│ ├── admin/ # 관리자 API +│ ├── common/members.js # PC 공통? +│ └── public/ # 공개 API (어드민 함수도 섞여있음) +``` + +**문제점:** +- PC와 모바일이 같은 API를 사용하는데 `pc/` 폴더 안에 있음 +- `api/pc/public/schedules.js`에 어드민 API 함수도 포함됨 +- `transformSchedule` 함수가 여러 파일에 중복 +- `api/index.js`에서 같은 함수가 여러 번 export됨 + +### 1.2 컴포넌트 구조 +``` +현재 구조: +components/ +├── common/ # 공통 (OK) +├── mobile/ # 모바일 (폴더 구분 없음) +├── pc/ +│ ├── admin/ # 관리자 (schedule 폴더만 있음) +│ └── public/ # 공개 (폴더 구분 없음) +``` + +**문제점:** +- `admin/`과 `public/` 내부에 기능별 폴더 구분이 없음 +- 파일이 많아지면 찾기 어려움 + +### 1.3 중복 코드 +- `getMemberList`: 16개 파일에서 사용, 일부는 로컬 정의 +- `decodeHtmlEntities`: 여러 파일에 중복 정의 +- `transformSchedule`: api/pc/public/schedules.js, api/pc/admin/schedules.js에 중복 +- `Schedules.jsx`에 유틸 함수들이 로컬로 재정의됨 + +### 1.4 대형 파일 +| 파일 | 라인 수 | 문제 | +|------|---------|------| +| AlbumPhotos.jsx | 1536 | 업로드, 관리, 일괄편집이 한 파일에 | +| Schedules.jsx | 1471 | 중복 유틸 함수, 컴포넌트 미분리 | +| ScheduleForm.jsx | 1046 | 폼 로직과 UI가 섞여있음 | +| ScheduleDict.jsx | 714 | 테이블과 모달이 한 파일에 | +| AlbumForm.jsx | 631 | 트랙/티저 관리가 인라인 | + +--- + +## 2. 개선 계획 + +### 2.1 API 구조 개선 + +``` +개선 후: +api/ +├── client.js # fetch 래퍼 +├── transforms.js # 공통 변환 함수 +├── public/ # 공개 API (PC/Mobile 공용) +│ ├── schedules.js +│ ├── albums.js +│ └── members.js +└── admin/ # 관리자 API + ├── schedules.js + ├── albums.js + ├── members.js + ├── categories.js + ├── bots.js + └── stats.js +``` + +### 2.2 컴포넌트 구조 개선 + +``` +개선 후: +components/ +├── common/ # 공통 (유지) +├── mobile/ +│ ├── layout/ # Layout, Header, BottomNav +│ └── schedule/ # ScheduleCard, BirthdayCard 등 +├── pc/ +│ ├── public/ +│ │ ├── layout/ # Layout, Header, Footer +│ │ └── schedule/ # Calendar, ScheduleCard 등 +│ └── admin/ +│ ├── layout/ # Layout, Header +│ ├── common/ # ConfirmDialog, DatePicker 등 +│ ├── schedule/ # CategorySelector 등 +│ └── album/ # PhotoUploader 등 +``` + +### 2.3 중복 코드 제거 + +#### utils/schedule.js 통합 사용 +- `Schedules.jsx`의 로컬 함수들 제거 +- 모든 곳에서 `@/utils/schedule` import + +#### utils/format.js에 decodeHtmlEntities 통합 +- 중복 정의 제거 + +#### api/transforms.js 생성 +- `transformSchedule` 함수 통합 + +### 2.4 대형 파일 분리 + +#### AlbumPhotos.jsx (1536줄) +``` +pages/pc/admin/albums/ +├── AlbumPhotos.jsx # 메인 (상태 관리) +├── components/ +│ ├── PhotoUploader.jsx # 업로드 UI +│ ├── PhotoGrid.jsx # 사진 그리드 +│ ├── PhotoPreview.jsx # 미리보기 모달 +│ └── BulkEditTools.jsx # 일괄 편집 도구 +└── hooks/ + └── usePhotoUpload.js # 업로드 로직 +``` + +#### Schedules.jsx (1471줄) +``` +pages/pc/admin/schedules/ +├── Schedules.jsx # 메인 +├── components/ +│ ├── ScheduleList.jsx # 목록 (가상화) +│ ├── ScheduleSearch.jsx # 검색 UI +│ └── MonthNavigator.jsx # 월 선택 +└── hooks/ + └── useScheduleList.js # 목록 조회 로직 +``` + +#### ScheduleForm.jsx (1046줄) +``` +pages/pc/admin/schedules/ +├── ScheduleForm.jsx # 메인 +└── components/ + ├── BasicInfoForm.jsx # 기본 정보 + ├── MemberSelector.jsx # 멤버 선택 + └── SourceInput.jsx # 출처 입력 +``` + +--- + +## 3. 작업 순서 + +### Phase 1: 구조 정리 (비파괴적) +1. [ ] API 구조 개선 (폴더 재배치, transforms.js 생성) +2. [ ] 중복 유틸 함수 제거 (Schedules.jsx 등) +3. [ ] 컴포넌트 폴더 구조화 + +### Phase 2: 대형 파일 분리 +1. [ ] Schedules.jsx 분리 +2. [ ] ScheduleForm.jsx 분리 +3. [ ] AlbumPhotos.jsx 분리 +4. [ ] ScheduleDict.jsx 분리 +5. [ ] AlbumForm.jsx 분리 + +### Phase 3: 검증 +1. [ ] 모든 페이지 동작 확인 +2. [ ] 빌드 확인 + +--- + +## 4. 삭제할 항목 + +### constants/index.js에서 삭제됨 +- `CATEGORY_NAMES` (미사용) +- `ALBUM_TYPES` (미사용) +- `SOCIAL_LINKS.tiktok` (불필요) +- `SOCIAL_LINKS.fancafe` (불필요) + +### 삭제 예정 +- `api/pc/` 폴더 구조 (→ `api/public/`, `api/admin/`으로 변경) +- `api/pc/common/` (→ `api/public/`으로 통합) +- 각 파일의 중복 유틸 함수 diff --git a/docs/frontend-refactoring.md b/docs/frontend-refactoring.md deleted file mode 100644 index 576077d..0000000 --- a/docs/frontend-refactoring.md +++ /dev/null @@ -1,1655 +0,0 @@ -# Frontend Refactoring Plan - -프론트엔드 완전 리팩토링 계획서 - -## 개요 - -- **대상**: `/docker/fromis_9/frontend/src/` -- **현재 규모**: 75개 파일, ~18,600 LOC -- **목표**: 완전한 코드 재구성, 중복 제거, 유지보수성 극대화 -- **방식**: `frontend-temp/` 폴더에 새 구조 구축 후 마이그레이션 -- **예상 효과**: LOC 40% 감소, 중복 코드 90% 제거 - ---- - -## 1. 마이그레이션 전략 - -### Strangler Fig Pattern - -기존 코드를 건드리지 않고 새 구조를 먼저 구축한 후, 검증 완료 시 교체하는 방식 - -``` -frontend/ (기존 - 운영 중) -frontend-temp/ (신규 - 개발 중) - ↓ 검증 완료 후 -frontend-backup/ (기존 백업) -frontend/ (신규로 교체) -``` - -### 장점 -- 기존 코드 안전 유지 (작업 중에도 사이트 정상 동작) -- 깔끔한 새 구조로 시작 -- 페이지 단위로 점진적 마이그레이션 및 검증 -- 문제 발생 시 즉시 롤백 가능 - -### 코딩 가이드라인 - -#### useEffect 대신 useQuery 사용 - -**데이터 페칭에는 반드시 React Query (useQuery)를 사용** - -```javascript -// ❌ BAD - useEffect로 데이터 페칭 -const [data, setData] = useState(null); -const [loading, setLoading] = useState(true); -const [error, setError] = useState(null); - -useEffect(() => { - fetchData() - .then(setData) - .catch(setError) - .finally(() => setLoading(false)); -}, []); - -// ✅ GOOD - useQuery로 데이터 페칭 -const { data, isLoading, error } = useQuery({ - queryKey: ['dataKey'], - queryFn: fetchData, -}); -``` - -**useEffect 사용이 허용되는 경우:** -- DOM 이벤트 리스너 등록/해제 (resize, scroll, keydown 등) -- 외부 라이브러리 초기화/정리 -- 브라우저 API 동기화 (document.title, localStorage 등) -- 타이머 설정 (setTimeout, setInterval) - -**useQuery 사용의 장점:** -- 자동 캐싱 및 재검증 -- 로딩/에러 상태 자동 관리 -- 중복 요청 방지 -- 백그라운드 리프레시 -- 개발자 도구 지원 - ---- - -## 2. 현재 구조 분석 - -### 디렉토리 구조 - -``` -src/ -├── api/ # API 호출 (12개 파일, ~300 LOC) -├── components/ # UI 컴포넌트 (~900 LOC) -│ ├── pc/ # PC 전용 (Header, Layout, Footer) -│ ├── mobile/ # 모바일 전용 (Layout) -│ ├── admin/ # 관리자 전용 (6개) -│ └── common/ # 공통 (Lightbox, Toast, Tooltip) -├── hooks/ # 커스텀 훅 (3개, ~100 LOC) -├── pages/ # 페이지 (~15,000 LOC) ⚠️ 대부분 여기 집중 -│ ├── pc/public/ # PC 공개 (14개) -│ ├── mobile/public/ # 모바일 공개 (8개) -│ └── pc/admin/ # 관리자 (~20개) -├── stores/ # Zustand (1개, 42 LOC) -├── utils/ # 유틸리티 (1개, 107 LOC) -└── App.jsx # 라우팅 (122 LOC) -``` - -### 주요 문제 파일 (LOC 기준) - -| 파일 | LOC | 문제점 | -|------|-----|--------| -| AdminAlbumPhotos.jsx | 1,538 | SSE 스트리밍, 복잡한 상태 | -| Mobile Schedule.jsx | 1,530 | PC와 70%+ 중복 | -| AdminSchedule.jsx | 1,469 | 검색, 필터링 로직 복잡 | -| PC Schedule.jsx | 1,419 | Mobile과 70%+ 중복 | -| AdminScheduleForm.jsx | 1,173 | 폼 검증, 다양한 입력 | -| AdminScheduleDict.jsx | 664 | 사전 관리 | -| AdminAlbumForm.jsx | 666 | 앨범 폼 | -| AlbumDetail.jsx (PC) | 593 | Mobile과 60% 중복 | -| AlbumDetail.jsx (Mobile) | 465 | PC와 60% 중복 | -| AlbumGallery.jsx (PC) | 381 | Mobile과 70% 중복 | -| AlbumGallery.jsx (Mobile) | 351 | PC와 70% 중복 | - -### 중복 코드 분석 - -| 영역 | PC | Mobile | 중복도 | 절감 가능 LOC | -|------|----|----|-------|--------------| -| Schedule.jsx | 1,419 | 1,530 | 70%+ | ~1,000 | -| AlbumDetail.jsx | 593 | 465 | 60% | ~300 | -| AlbumGallery.jsx | 381 | 351 | 70% | ~250 | -| Home.jsx | ~300 | ~250 | 50% | ~130 | -| Members.jsx | ~200 | ~180 | 60% | ~110 | -| 유틸리티 함수 | 다수 | 다수 | 100% | ~200 | -| **총계** | | | | **~2,000** | - ---- - -## 3. 목표 구조 - -``` -frontend-temp/src/ -├── api/ # API 계층 -│ ├── index.js # 공통 fetch 래퍼 -│ ├── normalizers.js # 응답 정규화 -│ ├── public/ -│ │ ├── schedules.js -│ │ ├── albums.js -│ │ └── members.js -│ └── admin/ -│ ├── schedules.js -│ ├── albums.js -│ ├── members.js -│ ├── auth.js -│ └── ... -│ -├── components/ # UI 컴포넌트 -│ ├── common/ # 공통 컴포넌트 -│ │ ├── ErrorBoundary.jsx -│ │ ├── QueryErrorHandler.jsx -│ │ ├── Loading.jsx -│ │ ├── Toast.jsx -│ │ ├── Tooltip.jsx -│ │ ├── Lightbox/ -│ │ │ ├── index.jsx -│ │ │ └── Indicator.jsx -│ │ └── ScrollToTop.jsx -│ │ -│ ├── layout/ # 레이아웃 컴포넌트 -│ │ ├── pc/ -│ │ │ ├── Layout.jsx -│ │ │ ├── Header.jsx -│ │ │ └── Footer.jsx -│ │ ├── mobile/ -│ │ │ ├── Layout.jsx -│ │ │ └── BottomNav.jsx -│ │ └── admin/ -│ │ ├── AdminLayout.jsx -│ │ └── AdminHeader.jsx -│ │ -│ ├── schedule/ # 일정 관련 (PC/Mobile 공유) -│ │ ├── ScheduleCard.jsx # 일정 카드 -│ │ ├── ScheduleList.jsx # 일정 목록 -│ │ ├── BirthdayCard.jsx # 생일 카드 -│ │ ├── CalendarPicker.jsx # 달력 선택기 -│ │ ├── CategoryFilter.jsx # 카테고리 필터 -│ │ ├── SearchBar.jsx # 검색바 -│ │ └── ScheduleDetail/ # 상세 섹션 -│ │ ├── DefaultSection.jsx -│ │ ├── YouTubeSection.jsx -│ │ └── XSection.jsx -│ │ -│ ├── album/ # 앨범 관련 (PC/Mobile 공유) -│ │ ├── AlbumCard.jsx -│ │ ├── AlbumGrid.jsx -│ │ ├── TrackList.jsx -│ │ ├── PhotoGallery.jsx -│ │ └── TeaserList.jsx -│ │ -│ ├── member/ # 멤버 관련 (PC/Mobile 공유) -│ │ ├── MemberCard.jsx -│ │ └── MemberGrid.jsx -│ │ -│ └── admin/ # 관리자 전용 -│ ├── ConfirmDialog.jsx -│ ├── CustomDatePicker.jsx -│ ├── CustomTimePicker.jsx -│ ├── NumberPicker.jsx -│ └── forms/ -│ ├── ScheduleForm/ -│ │ ├── index.jsx -│ │ ├── YouTubeForm.jsx -│ │ └── XForm.jsx -│ └── AlbumForm.jsx -│ -├── hooks/ # 커스텀 훅 -│ ├── useToast.js -│ ├── useAdminAuth.js -│ ├── useMediaQuery.js # PC/Mobile 감지 -│ ├── useInfiniteScroll.js # 무한 스크롤 -│ ├── schedule/ -│ │ ├── useScheduleData.js # 일정 데이터 페칭 -│ │ ├── useScheduleSearch.js # 검색 로직 -│ │ ├── useScheduleFiltering.js # 필터링 로직 -│ │ └── useCalendar.js # 달력 로직 -│ └── album/ -│ ├── useAlbumData.js -│ └── useAlbumGallery.js -│ -├── pages/ # 페이지 (렌더링만 담당) -│ ├── pc/ -│ │ ├── public/ -│ │ │ ├── Home.jsx -│ │ │ ├── Schedule.jsx # 훅 + 컴포넌트 조합만 -│ │ │ ├── ScheduleDetail.jsx -│ │ │ ├── Album.jsx -│ │ │ ├── AlbumDetail.jsx -│ │ │ ├── AlbumGallery.jsx -│ │ │ ├── Members.jsx -│ │ │ ├── TrackDetail.jsx -│ │ │ └── NotFound.jsx -│ │ └── admin/ -│ │ ├── Dashboard.jsx -│ │ ├── Schedule.jsx -│ │ ├── ScheduleForm.jsx -│ │ ├── Albums.jsx -│ │ ├── AlbumForm.jsx -│ │ ├── AlbumPhotos.jsx -│ │ ├── Members.jsx -│ │ └── ... -│ └── mobile/ -│ └── public/ -│ ├── Home.jsx -│ ├── Schedule.jsx # PC와 같은 훅 사용, UI만 다름 -│ ├── ScheduleDetail.jsx -│ ├── Album.jsx -│ ├── AlbumDetail.jsx -│ ├── AlbumGallery.jsx -│ └── Members.jsx -│ -├── stores/ # Zustand 상태 관리 -│ ├── useAuthStore.js # 인증 상태 -│ ├── useScheduleStore.js # 일정 UI 상태 -│ └── useUIStore.js # 전역 UI 상태 -│ -├── utils/ # 유틸리티 함수 -│ ├── date.js # 날짜 처리 -│ ├── schedule.js # 일정 헬퍼 -│ ├── format.js # 포맷팅 -│ ├── confetti.js # 폭죽 애니메이션 -│ └── cn.js # className 유틸 (clsx) -│ -├── constants/ # 상수 정의 -│ ├── schedule.js -│ ├── album.js -│ └── routes.js -│ -├── types/ # JSDoc 타입 정의 -│ └── index.js -│ -├── styles/ # 스타일 -│ ├── index.css # Tailwind 기본 -│ ├── pc.css -│ └── mobile.css -│ -├── App.jsx # 라우팅 -└── main.jsx # 엔트리포인트 -``` - ---- - -## 4. 리팩토링 단계 - -### Phase 1: 프로젝트 기반 설정 - -**목표**: frontend-temp 폴더 생성 및 기본 설정 - -#### 작업 내용 -- [ ] `frontend-temp/` 디렉토리 생성 -- [ ] `package.json` 복사 및 의존성 정리 -- [ ] `vite.config.js` 설정 -- [ ] `tailwind.config.js` 설정 -- [ ] `index.html` 복사 -- [ ] 디렉토리 구조 생성 - -#### 정리할 의존성 -```json -{ - "dependencies": { - "@tanstack/react-query": "^5.x", - "@tanstack/react-virtual": "^3.x", - "framer-motion": "^11.x", - "lucide-react": "^0.x", - "zustand": "^5.x", - "dayjs": "^1.x", - "canvas-confetti": "^1.x", - "react-router-dom": "^6.x", - "clsx": "^2.x" - } -} -``` - -**제거 검토 대상**: -- `react-device-detect` → `useMediaQuery` 훅으로 대체 - ---- - -### Phase 2: 유틸리티 및 상수 - -**목표**: 공통 유틸리티와 상수 정의 - -#### 2.1 `constants/schedule.js` - -```javascript -export const BIRTHDAY_CONFETTI_COLORS = [ - '#ff69b4', '#ff1493', '#da70d6', '#ba55d3', - '#9370db', '#8a2be2', '#ffd700', '#ff6347' -]; - -export const SEARCH_LIMIT = 20; -export const ESTIMATED_ITEM_HEIGHT = 120; -export const MIN_YEAR = 2017; - -export const CATEGORY_IDS = { - YOUTUBE: 2, - X: 3, -}; - -export const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; -export const MONTH_NAMES = ['1월', '2월', '3월', '4월', '5월', '6월', - '7월', '8월', '9월', '10월', '11월', '12월']; -``` - -#### 2.2 `utils/schedule.js` - -```javascript -/** - * @typedef {Object} Schedule - * @property {number} id - * @property {string} title - * @property {string} [date] - * @property {string} [datetime] - * @property {string} [time] - * @property {Object} [category] - * @property {number} [category_id] - */ - -/** HTML 엔티티 디코딩 */ -export const decodeHtmlEntities = (text) => { - if (!text) return ''; - const textarea = document.createElement('textarea'); - textarea.innerHTML = text; - return textarea.value; -}; - -/** 멤버 리스트 추출 */ -export const getMemberList = (schedule) => { - if (schedule.member_names) { - return schedule.member_names.split(',').map(n => n.trim()).filter(Boolean); - } - if (Array.isArray(schedule.members) && schedule.members.length > 0) { - if (typeof schedule.members[0] === 'string') { - return schedule.members.filter(Boolean); - } - return schedule.members.map(m => m.name).filter(Boolean); - } - return []; -}; - -/** 일정 날짜 추출 */ -export const getScheduleDate = (schedule) => { - if (schedule.datetime) return new Date(schedule.datetime); - if (schedule.date) return new Date(schedule.date); - return new Date(); -}; - -/** 일정 시간 추출 */ -export const getScheduleTime = (schedule) => { - if (schedule.time) return schedule.time.slice(0, 5); - if (schedule.datetime?.includes('T')) { - const timePart = schedule.datetime.split('T')[1]; - return timePart ? timePart.slice(0, 5) : null; - } - return null; -}; - -/** 카테고리 ID 추출 */ -export const getCategoryId = (schedule) => { - if (schedule.category_id !== undefined) return schedule.category_id; - if (schedule.category?.id !== undefined) return schedule.category.id; - return null; -}; - -/** 카테고리 정보 추출 */ -export const getCategoryInfo = (schedule, categories) => { - const catId = getCategoryId(schedule); - if (schedule.category?.name && schedule.category?.color) { - return schedule.category; - } - return categories.find(c => c.id === catId) || - { id: catId, name: '미분류', color: '#6b7280' }; -}; - -/** 생일 여부 확인 */ -export const isBirthdaySchedule = (schedule) => { - return schedule.is_birthday || String(schedule.id).startsWith('birthday-'); -}; -``` - -#### 2.3 `utils/confetti.js` - -```javascript -import confetti from 'canvas-confetti'; -import { BIRTHDAY_CONFETTI_COLORS } from '../constants/schedule'; - -export const fireBirthdayConfetti = () => { - const duration = 3000; - const end = Date.now() + duration; - - const frame = () => { - confetti({ - particleCount: 3, - angle: 60, - spread: 55, - origin: { x: 0 }, - colors: BIRTHDAY_CONFETTI_COLORS, - }); - confetti({ - particleCount: 3, - angle: 120, - spread: 55, - origin: { x: 1 }, - colors: BIRTHDAY_CONFETTI_COLORS, - }); - - if (Date.now() < end) { - requestAnimationFrame(frame); - } - }; - - frame(); -}; -``` - -#### 2.4 `utils/cn.js` - -```javascript -import { clsx } from 'clsx'; - -/** className 병합 유틸리티 */ -export function cn(...inputs) { - return clsx(inputs); -} -``` - ---- - -### Phase 3: Stores 구축 - -**목표**: Zustand 스토어 정의 - -#### 3.1 `stores/useAuthStore.js` - -```javascript -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -const useAuthStore = create( - persist( - (set, get) => ({ - token: null, - user: null, - - get isAuthenticated() { - return !!get().token; - }, - - setAuth: (token, user) => set({ token, user }), - - logout: () => set({ token: null, user: null }), - - getToken: () => get().token, - }), - { - name: 'auth-storage', - partialize: (state) => ({ token: state.token }), - } - ) -); - -export default useAuthStore; -``` - -#### 3.2 `stores/useScheduleStore.js` - -```javascript -import { create } from 'zustand'; - -const useScheduleStore = create((set) => ({ - // 검색 상태 - searchInput: '', - searchTerm: '', - isSearchMode: false, - - // 필터 상태 - selectedCategories: [], - selectedDate: undefined, - currentDate: new Date(), - - // UI 상태 - scrollPosition: 0, - - // Actions - setSearchInput: (value) => set({ searchInput: value }), - setSearchTerm: (value) => set({ searchTerm: value }), - setIsSearchMode: (value) => set({ isSearchMode: value }), - setSelectedCategories: (value) => set({ selectedCategories: value }), - setSelectedDate: (value) => set({ selectedDate: value }), - setCurrentDate: (value) => set({ currentDate: value }), - setScrollPosition: (value) => set({ scrollPosition: value }), - - // 복합 Actions - enterSearchMode: () => set({ isSearchMode: true }), - - exitSearchMode: () => set({ - isSearchMode: false, - searchInput: '', - searchTerm: '' - }), - - reset: () => set({ - searchInput: '', - searchTerm: '', - isSearchMode: false, - selectedCategories: [], - selectedDate: undefined, - currentDate: new Date(), - scrollPosition: 0, - }), -})); - -export default useScheduleStore; -``` - -#### 3.3 `stores/useUIStore.js` - -```javascript -import { create } from 'zustand'; - -const useUIStore = create((set) => ({ - // Toast - toast: null, - setToast: (toast) => set({ toast }), - clearToast: () => set({ toast: null }), - - // Global loading - isLoading: false, - setIsLoading: (value) => set({ isLoading: value }), -})); - -export default useUIStore; -``` - ---- - -### Phase 4: API 계층 - -**목표**: API 호출 및 응답 정규화 - -#### 4.1 `api/index.js` - -```javascript -import useAuthStore from '../stores/useAuthStore'; - -const BASE_URL = ''; - -export class ApiError extends Error { - constructor(message, status, data) { - super(message); - this.status = status; - this.data = data; - } -} - -export async function fetchApi(url, options = {}) { - const headers = { ...options.headers }; - - if (options.body && !(options.body instanceof FormData)) { - headers['Content-Type'] = 'application/json'; - } - - const response = await fetch(`${BASE_URL}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: '요청 실패' })); - throw new ApiError( - error.error || `HTTP ${response.status}`, - response.status, - error - ); - } - - return response.json(); -} - -export async function fetchAdminApi(url, options = {}) { - const token = useAuthStore.getState().getToken(); - - return fetchApi(url, { - ...options, - headers: { - ...options.headers, - Authorization: `Bearer ${token}`, - }, - }); -} - -export async function fetchAdminFormData(url, formData, method = 'POST') { - const token = useAuthStore.getState().getToken(); - - const response = await fetch(`${BASE_URL}${url}`, { - method, - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: '요청 실패' })); - throw new ApiError( - error.error || `HTTP ${response.status}`, - response.status, - error - ); - } - - return response.json(); -} -``` - -#### 4.2 `api/normalizers.js` - -```javascript -/** - * 일정 응답 정규화 (날짜별 그룹 → 플랫 배열) - */ -export function normalizeSchedulesResponse(data) { - const schedules = []; - - for (const [date, dayData] of Object.entries(data)) { - for (const schedule of dayData.schedules) { - const category = schedule.category || {}; - schedules.push({ - ...schedule, - date, - categoryId: category.id, - categoryName: category.name, - categoryColor: category.color, - // 하위 호환성 - category_id: category.id, - category_name: category.name, - category_color: category.color, - }); - } - } - - return schedules; -} - -/** - * 검색 결과 정규화 - */ -export function normalizeSearchResult(schedule) { - return { - ...schedule, - categoryId: schedule.category?.id, - categoryName: schedule.category?.name, - categoryColor: schedule.category?.color, - category_id: schedule.category?.id, - category_name: schedule.category?.name, - category_color: schedule.category?.color, - }; -} -``` - ---- - -### Phase 5: 커스텀 훅 - -**목표**: 재사용 가능한 로직을 훅으로 추출 - -#### 5.1 `hooks/useMediaQuery.js` - -```javascript -import { useState, useEffect } from 'react'; - -export function useMediaQuery(query) { - const [matches, setMatches] = useState(() => { - if (typeof window !== 'undefined') { - return window.matchMedia(query).matches; - } - return false; - }); - - useEffect(() => { - const mediaQuery = window.matchMedia(query); - const handler = (e) => setMatches(e.matches); - - mediaQuery.addEventListener('change', handler); - return () => mediaQuery.removeEventListener('change', handler); - }, [query]); - - return matches; -} - -export const useIsMobile = () => useMediaQuery('(max-width: 768px)'); -export const useIsDesktop = () => useMediaQuery('(min-width: 769px)'); -``` - -#### 5.2 `hooks/schedule/useScheduleData.js` - -```javascript -import { useQuery } from '@tanstack/react-query'; -import { fetchApi } from '../../api'; -import { normalizeSchedulesResponse } from '../../api/normalizers'; - -export function useScheduleData(year, month) { - return useQuery({ - queryKey: ['schedules', year, month], - queryFn: async () => { - const data = await fetchApi(`/api/schedules?year=${year}&month=${month}`); - return normalizeSchedulesResponse(data); - }, - }); -} - -export function useScheduleDetail(id) { - return useQuery({ - queryKey: ['schedule', id], - queryFn: () => fetchApi(`/api/schedules/${id}`), - enabled: !!id, - }); -} -``` - -#### 5.3 `hooks/schedule/useScheduleSearch.js` - -```javascript -import { useState, useMemo } from 'react'; -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { fetchApi } from '../../api'; -import { normalizeSearchResult } from '../../api/normalizers'; -import { SEARCH_LIMIT } from '../../constants/schedule'; - -export function useScheduleSearch() { - const [searchInput, setSearchInput] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [isSearchMode, setIsSearchMode] = useState(false); - - // 검색 결과 (무한 스크롤) - const searchQuery = useInfiniteQuery({ - queryKey: ['scheduleSearch', searchTerm], - queryFn: async ({ pageParam = 0, signal }) => { - const response = await fetch( - `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`, - { signal } - ); - if (!response.ok) throw new Error('Search failed'); - const data = await response.json(); - return { - ...data, - schedules: data.schedules.map(normalizeSearchResult), - }; - }, - getNextPageParam: (lastPage) => { - return lastPage.hasMore - ? lastPage.offset + lastPage.schedules.length - : undefined; - }, - enabled: !!searchTerm && isSearchMode, - }); - - // 검색어 추천 - const suggestionsQuery = useQuery({ - queryKey: ['scheduleSuggestions', searchInput], - queryFn: async ({ signal }) => { - const response = await fetch( - `/api/schedules/suggestions?q=${encodeURIComponent(searchInput)}&limit=10`, - { signal } - ); - if (!response.ok) return []; - const data = await response.json(); - return data.suggestions || []; - }, - enabled: !!searchInput && searchInput.trim().length > 0, - staleTime: 5 * 60 * 1000, - }); - - const searchResults = useMemo(() => { - return searchQuery.data?.pages?.flatMap(page => page.schedules) || []; - }, [searchQuery.data]); - - const searchTotal = searchQuery.data?.pages?.[0]?.total || 0; - - const executeSearch = () => { - if (searchInput.trim()) { - setSearchTerm(searchInput); - } - }; - - const clearSearch = () => { - setSearchInput(''); - setSearchTerm(''); - setIsSearchMode(false); - }; - - return { - // 상태 - searchInput, - searchTerm, - isSearchMode, - searchResults, - searchTotal, - suggestions: suggestionsQuery.data || [], - isLoading: searchQuery.isLoading, - hasNextPage: searchQuery.hasNextPage, - isFetchingNextPage: searchQuery.isFetchingNextPage, - - // 액션 - setSearchInput, - setIsSearchMode, - executeSearch, - clearSearch, - fetchNextPage: searchQuery.fetchNextPage, - }; -} -``` - -#### 5.4 `hooks/schedule/useScheduleFiltering.js` - -```javascript -import { useMemo } from 'react'; -import { formatDate } from '../../utils/date'; -import { getCategoryId, isBirthdaySchedule } from '../../utils/schedule'; - -export function useScheduleFiltering(schedules, options = {}) { - const { - categoryIds = [], - selectedDate, - birthdayFirst = true, - } = options; - - return useMemo(() => { - let result = schedules.filter(schedule => { - // 카테고리 필터 - if (categoryIds.length > 0) { - const catId = getCategoryId(schedule); - if (!categoryIds.includes(catId)) return false; - } - - // 날짜 필터 - if (selectedDate) { - const scheduleDate = formatDate(schedule.date || schedule.datetime); - if (scheduleDate !== selectedDate) return false; - } - - return true; - }); - - // 생일 우선 정렬 - if (birthdayFirst) { - result.sort((a, b) => { - const aIsBirthday = isBirthdaySchedule(a); - const bIsBirthday = isBirthdaySchedule(b); - if (aIsBirthday && !bIsBirthday) return -1; - if (!aIsBirthday && bIsBirthday) return 1; - return 0; - }); - } - - return result; - }, [schedules, categoryIds, selectedDate, birthdayFirst]); -} -``` - -#### 5.5 `hooks/schedule/useCalendar.js` - -```javascript -import { useState, useMemo, useCallback } from 'react'; -import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '../../constants/schedule'; -import { getTodayKST } from '../../utils/date'; - -export function useCalendar(initialDate = new Date()) { - const [currentDate, setCurrentDate] = useState(initialDate); - const [selectedDate, setSelectedDate] = useState(getTodayKST()); - - const year = currentDate.getFullYear(); - const month = currentDate.getMonth(); - - const calendarData = useMemo(() => { - const firstDay = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const prevMonthDays = new Date(year, month, 0).getDate(); - - return { - year, - month, - firstDay, - daysInMonth, - prevMonthDays, - weekdays: WEEKDAYS, - monthNames: MONTH_NAMES, - }; - }, [year, month]); - - const canGoPrevMonth = !(year === MIN_YEAR && month === 0); - - const goToPrevMonth = useCallback(() => { - if (!canGoPrevMonth) return; - const newDate = new Date(year, month - 1, 1); - setCurrentDate(newDate); - updateSelectedDate(newDate); - }, [year, month, canGoPrevMonth]); - - const goToNextMonth = useCallback(() => { - const newDate = new Date(year, month + 1, 1); - setCurrentDate(newDate); - updateSelectedDate(newDate); - }, [year, month]); - - const goToMonth = useCallback((newYear, newMonth) => { - const newDate = new Date(newYear, newMonth, 1); - setCurrentDate(newDate); - updateSelectedDate(newDate); - }, []); - - const updateSelectedDate = (newDate) => { - const today = new Date(); - if (newDate.getFullYear() === today.getFullYear() && - newDate.getMonth() === today.getMonth()) { - setSelectedDate(getTodayKST()); - } else { - const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; - setSelectedDate(firstDay); - } - }; - - const selectDate = useCallback((day) => { - const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - setSelectedDate(dateStr); - }, [year, month]); - - return { - ...calendarData, - currentDate, - selectedDate, - canGoPrevMonth, - goToPrevMonth, - goToNextMonth, - goToMonth, - selectDate, - setSelectedDate, - }; -} -``` - ---- - -### Phase 6: 공통 컴포넌트 - -**목표**: PC/Mobile 공유 가능한 컴포넌트 구축 - -#### 6.1 `components/common/ErrorBoundary.jsx` - -```javascript -import { Component } from 'react'; -import { AlertTriangle, RefreshCw } from 'lucide-react'; - -class ErrorBoundary extends Component { - state = { hasError: false, error: null }; - - static getDerivedStateFromError(error) { - return { hasError: true, error }; - } - - componentDidCatch(error, errorInfo) { - console.error('ErrorBoundary:', error, errorInfo); - } - - render() { - if (this.state.hasError) { - return ( -
- {this.state.error?.message || '알 수 없는 오류'} -
- -{memberName}
-생일 축하해요!
-오늘은
-생일 축하해요!
-