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 || '알 수 없는 오류'} -

- -
- ); - } - - return this.props.children; - } -} - -export default ErrorBoundary; -``` - -#### 6.2 `components/common/Loading.jsx` - -```javascript -export default function Loading({ size = 'md', className = '' }) { - const sizeClasses = { - sm: 'h-6 w-6 border-2', - md: 'h-10 w-10 border-3', - lg: 'h-12 w-12 border-4', - }; - - return ( -
-
-
- ); -} -``` - -#### 6.3 `components/schedule/ScheduleCard.jsx` - -```javascript -import { memo } from 'react'; -import { Clock, Tag, Link2 } from 'lucide-react'; -import { cn } from '../../utils/cn'; -import { - decodeHtmlEntities, - getMemberList, - getScheduleDate, - getScheduleTime, - getCategoryInfo, -} from '../../utils/schedule'; -import { WEEKDAYS } from '../../constants/schedule'; - -const ScheduleCard = memo(function ScheduleCard({ - schedule, - categories = [], - variant = 'card', // 'card' | 'list' | 'compact' - showYear = false, - onClick, - actions, - className, -}) { - const scheduleDate = getScheduleDate(schedule); - const categoryInfo = getCategoryInfo(schedule, categories); - const timeStr = getScheduleTime(schedule); - const memberList = getMemberList(schedule); - const title = decodeHtmlEntities(schedule.title); - - const MemberBadges = () => { - if (memberList.length === 0) return null; - - if (memberList.length >= 5) { - return ( - - 프로미스나인 - - ); - } - - return memberList.map((name, i) => ( - - {name} - - )); - }; - - if (variant === 'list') { - return ( -
-
-
- {showYear && ( -
- {scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1} -
- )} -
- {scheduleDate.getDate()} -
-
- {WEEKDAYS[scheduleDate.getDay()]}요일 -
-
- -
- -
-

{title}

-
- {timeStr && ( - - - {timeStr} - - )} - - - {categoryInfo.name} - - {schedule.source?.name && ( - - - {schedule.source.name} - - )} -
- {memberList.length > 0 && ( -
- -
- )} -
- - {actions && ( -
- {actions} -
- )} -
-
- ); - } - - // Card variant (default) - return ( -
-
- {showYear && ( - - {scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1} - - )} - {scheduleDate.getDate()} - - {WEEKDAYS[scheduleDate.getDay()]} - -
-
-

{title}

-
- {timeStr && ( - - - {timeStr} - - )} - - - {categoryInfo.name} - -
- {memberList.length > 0 && ( -
- {memberList.slice(0, 3).map((name, i) => ( - - {name} - - ))} - {memberList.length > 3 && ( - - +{memberList.length - 3} - - )} -
- )} -
-
- ); -}); - -export default ScheduleCard; -``` - -#### 6.4 `components/schedule/BirthdayCard.jsx` - -```javascript -import { memo } from 'react'; -import { Cake } from 'lucide-react'; -import { cn } from '../../utils/cn'; -import { decodeHtmlEntities } from '../../utils/schedule'; - -const BirthdayCard = memo(function BirthdayCard({ - schedule, - variant = 'default', // 'default' | 'compact' - onClick, - className, -}) { - const title = decodeHtmlEntities(schedule.title); - const memberName = title.replace(' 생일', '').replace('의', ''); - - if (variant === 'compact') { - return ( -
-
- -
-
-

{memberName}

-

생일 축하해요!

-
-
- ); - } - - return ( -
-
-
-
-
- -
-
-

오늘은

-

{memberName} 생일!

-
-
-

생일 축하해요!

-
-
- ); -}); - -export default BirthdayCard; -``` - ---- - -### Phase 7: Schedule 페이지 마이그레이션 - -**목표**: 가장 큰 중복 영역인 Schedule 페이지 통합 - -#### 페이지 구조 - -``` -pages/ -├── pc/public/Schedule.jsx # PC UI + 공통 훅 -└── mobile/public/Schedule.jsx # Mobile UI + 공통 훅 (동일) -``` - -#### 7.1 PC Schedule 페이지 예시 - -```javascript -// pages/pc/public/Schedule.jsx -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { AnimatePresence, motion } from 'framer-motion'; - -// Hooks -import { useScheduleData } from '../../../hooks/schedule/useScheduleData'; -import { useScheduleSearch } from '../../../hooks/schedule/useScheduleSearch'; -import { useScheduleFiltering } from '../../../hooks/schedule/useScheduleFiltering'; -import { useCalendar } from '../../../hooks/schedule/useCalendar'; -import useScheduleStore from '../../../stores/useScheduleStore'; - -// Components -import Layout from '../../../components/layout/pc/Layout'; -import ScheduleCard from '../../../components/schedule/ScheduleCard'; -import BirthdayCard from '../../../components/schedule/BirthdayCard'; -import CalendarPicker from '../../../components/schedule/CalendarPicker'; -import CategoryFilter from '../../../components/schedule/CategoryFilter'; -import SearchBar from '../../../components/schedule/SearchBar'; -import Loading from '../../../components/common/Loading'; - -// Utils -import { fireBirthdayConfetti } from '../../../utils/confetti'; -import { isBirthdaySchedule } from '../../../utils/schedule'; - -export default function Schedule() { - const navigate = useNavigate(); - const { selectedCategories } = useScheduleStore(); - - // 달력 훅 - const calendar = useCalendar(); - - // 데이터 페칭 - const { data: schedules = [], isLoading } = useScheduleData( - calendar.year, - calendar.month + 1 - ); - - // 검색 훅 - const search = useScheduleSearch(); - - // 필터링 훅 - const filteredSchedules = useScheduleFiltering( - search.isSearchMode ? search.searchResults : schedules, - { - categoryIds: selectedCategories, - selectedDate: search.isSearchMode ? undefined : calendar.selectedDate, - } - ); - - // 카테고리 추출 - const categories = useMemo(() => { - const map = new Map(); - schedules.forEach(s => { - if (s.category_id && !map.has(s.category_id)) { - map.set(s.category_id, { - id: s.category_id, - name: s.category_name, - color: s.category_color, - }); - } - }); - return Array.from(map.values()); - }, [schedules]); - - // 생일 폭죽 - useEffect(() => { - const hasBirthday = filteredSchedules.some(isBirthdaySchedule); - if (hasBirthday && !search.isSearchMode) { - fireBirthdayConfetti(); - } - }, [calendar.selectedDate]); - - const handleScheduleClick = (schedule) => { - if (isBirthdaySchedule(schedule)) return; - navigate(`/schedule/${schedule.id}`); - }; - - return ( - -
- {/* 왼쪽: 달력 + 카테고리 */} - - - {/* 오른쪽: 일정 목록 */} -
- - - {isLoading ? ( - - ) : ( -
- - {filteredSchedules.map((schedule, index) => ( - - {isBirthdaySchedule(schedule) ? ( - handleScheduleClick(schedule)} - /> - ) : ( - handleScheduleClick(schedule)} - /> - )} - - ))} - -
- )} -
-
-
- ); -} -``` - ---- - -### Phase 8: Album 페이지 마이그레이션 - -**목표**: Album 관련 페이지 통합 - -- [ ] `hooks/album/useAlbumData.js` 생성 -- [ ] `hooks/album/useAlbumGallery.js` 생성 -- [ ] `components/album/AlbumCard.jsx` 생성 -- [ ] `components/album/AlbumGrid.jsx` 생성 -- [ ] `components/album/TrackList.jsx` 생성 -- [ ] `components/album/PhotoGallery.jsx` 생성 -- [ ] PC/Mobile Album.jsx 마이그레이션 -- [ ] PC/Mobile AlbumDetail.jsx 마이그레이션 -- [ ] PC/Mobile AlbumGallery.jsx 마이그레이션 - ---- - -### Phase 9: 기타 Public 페이지 마이그레이션 - -- [ ] `components/member/MemberCard.jsx` 생성 -- [ ] `components/member/MemberGrid.jsx` 생성 -- [ ] PC/Mobile Home.jsx 마이그레이션 -- [ ] PC/Mobile Members.jsx 마이그레이션 -- [ ] PC/Mobile TrackDetail.jsx 마이그레이션 -- [ ] PC/Mobile ScheduleDetail.jsx 마이그레이션 - ---- - -### Phase 10: Admin 페이지 마이그레이션 - -- [ ] AdminLayout 마이그레이션 -- [ ] AdminSchedule.jsx 마이그레이션 -- [ ] AdminScheduleForm.jsx 마이그레이션 -- [ ] AdminAlbums.jsx 마이그레이션 -- [ ] AdminAlbumForm.jsx 마이그레이션 -- [ ] AdminAlbumPhotos.jsx 마이그레이션 -- [ ] AdminMembers.jsx 마이그레이션 -- [ ] 기타 관리자 페이지 마이그레이션 - ---- - -### Phase 11: 최종 검증 및 교체 - -#### 검증 체크리스트 - -**Public 페이지** -- [ ] 홈 페이지 (PC/Mobile) -- [ ] 일정 페이지 - 달력 탐색 (PC/Mobile) -- [ ] 일정 페이지 - 검색 (PC/Mobile) -- [ ] 일정 페이지 - 카테고리 필터 (PC/Mobile) -- [ ] 일정 상세 페이지 (PC/Mobile) -- [ ] 앨범 목록 페이지 (PC/Mobile) -- [ ] 앨범 상세 페이지 (PC/Mobile) -- [ ] 앨범 갤러리 페이지 (PC/Mobile) -- [ ] 트랙 상세 페이지 (PC/Mobile) -- [ ] 멤버 페이지 (PC/Mobile) - -**Admin 페이지** -- [ ] 로그인/로그아웃 -- [ ] 대시보드 -- [ ] 일정 목록/검색 -- [ ] 일정 생성/수정/삭제 -- [ ] YouTube/X 일정 등록 -- [ ] 앨범 목록 -- [ ] 앨범 생성/수정/삭제 -- [ ] 앨범 사진 업로드 -- [ ] 멤버 관리 - -#### 교체 절차 - -```bash -# 1. 기존 frontend 백업 -mv frontend frontend-backup - -# 2. 새 frontend로 교체 -mv frontend-temp frontend - -# 3. Docker 재빌드 -docker compose up -d --build fromis9-frontend - -# 4. 검증 후 백업 삭제 -rm -rf frontend-backup -``` - ---- - -## 5. 예상 효과 - -| 항목 | Before | After | 개선 | -|------|--------|-------|------| -| **총 LOC** | ~18,600 | ~11,000 | **-40%** | -| **중복 코드** | 60-70% | <10% | **-60%p** | -| **파일 수** | 75개 | ~65개 | -10개 | -| **Store 수** | 1개 | 3개 | 명확한 책임 분리 | -| **커스텀 훅** | 3개 | 12개+ | 재사용성 극대화 | -| **공유 컴포넌트** | 5개 | 20개+ | 일관성 확보 | - ---- - -## 6. 진행 상황 - -| Phase | 작업 | 상태 | -|-------|------|------| -| 1 | 프로젝트 기반 설정 | ✅ 완료 | -| 2 | 유틸리티 및 상수 | ✅ 완료 | -| 3 | Stores 구축 | ✅ 완료 | -| 4 | API 계층 (공개 API) | ✅ 완료 | -| 5 | 커스텀 훅 | ✅ 완료 | -| 6 | 공통 컴포넌트 | ✅ 완료 | -| 7 | Schedule 페이지 | ✅ 완료 | -| 8 | Album 페이지 | ✅ 완료 | -| 9 | 기타 Public 페이지 (Home, Members) | ✅ 완료 | -| 9-1 | NotFound 페이지 | ✅ 완료 | -| 9-2 | 코드 품질 개선 - Critical | ✅ 완료 | -| 9-3 | 코드 품질 개선 - High | ✅ 완료 | -| 10 | Admin 페이지 | ⬜ 대기 | -| 11 | 최종 검증 및 교체 | ⬜ 대기 | - -### 세부 완료 현황 - -**레이아웃 컴포넌트** -- ✅ PC: Layout, Header, Footer -- ✅ Mobile: Layout - -**공개 페이지** -- ✅ Home (PC/Mobile) -- ✅ Members (PC/Mobile) -- ✅ Album, AlbumDetail, AlbumGallery, TrackDetail (PC/Mobile) -- ✅ Schedule, ScheduleDetail, Birthday (PC/Mobile) -- ✅ NotFound (PC/Mobile) - -**일정 컴포넌트** -- ✅ Calendar, MobileCalendar -- ✅ ScheduleCard, MobileScheduleCard, MobileScheduleListCard, MobileScheduleSearchCard -- ✅ BirthdayCard, CategoryFilter, AdminScheduleCard - -**공통 컴포넌트** -- ✅ Loading, ErrorBoundary, Toast, Lightbox -- ✅ LightboxIndicator, Tooltip, ScrollToTop - -**훅** -- ✅ useAlbumData, useMemberData, useScheduleData, useScheduleSearch -- ✅ useScheduleFiltering, useCalendar, useMediaQuery, useAdminAuth -- ⬜ useToast (관리자 영역 마이그레이션 시 진행) -- ⬜ useLightbox (코드 품질 개선 시 추가 예정) - -**스토어** -- ✅ useAuthStore, useScheduleStore, useUIStore - -**관리자 (별도 요청 시 진행)** -- ⬜ 관리자 API 전체 -- ⬜ 관리자 컴포넌트 전체 -- ⬜ 관리자 페이지 전체 - ---- - -## 7. 참고 사항 - -### 의존성 정리 - -**유지**: -- `@tanstack/react-query` - 데이터 페칭 -- `@tanstack/react-virtual` - 가상 스크롤 -- `framer-motion` - 애니메이션 -- `lucide-react` - 아이콘 -- `zustand` - 상태 관리 -- `dayjs` - 날짜 처리 -- `canvas-confetti` - 폭죽 -- `react-router-dom` - 라우팅 -- `clsx` - className 유틸 (신규 추가) - -**제거 검토**: -- `react-device-detect` → `useMediaQuery` 훅으로 대체 - -### 하위 호환성 - -- API 응답 필드명은 snake_case와 camelCase 모두 제공 -- 기존 URL 구조 유지 - -### 롤백 계획 - -- `frontend-backup/` 폴더로 즉시 복원 가능 -- Docker 이미지 태그로 이전 버전 배포 가능 - ---- - -## 8. 코드 품질 개선 (Phase 9-2) - -공개 영역 마이그레이션 완료 후 코드 품질 검토를 통해 발견된 개선 사항입니다. -상세 내용은 [code-improvements.md](./code-improvements.md) 참조. - -### Critical (즉시 수정) - -| 항목 | 파일 | 상태 | -|-----|------|------| -| useAdminAuth 무한 루프 위험 | `hooks/useAdminAuth.js` | ✅ 완료 | -| queryKey 충돌 | `hooks/useAdminAuth.js` | ✅ 완료 | -| 카드 컴포넌트 memo 적용 | `components/*/ScheduleCard.jsx` 등 | ✅ 완료 | -| 접근성(a11y) 개선 | 모든 컴포넌트 | ✅ 완료 | -| 스토어 미사용 코드 삭제 | `stores/useAuthStore.js`, `stores/useUIStore.js` | ✅ 완료 | - -### High (높은 우선순위) - -| 항목 | 파일 | 상태 | -|-----|------|------| -| 중복 함수 유틸리티화 | `utils/youtube.js`, `utils/format.js` | ✅ 완료 | -| useLightbox 훅 생성 | `hooks/useLightbox.js` | ✅ 완료 | -| ErrorMessage 컴포넌트 | `components/common/ErrorMessage.jsx` | ✅ 완료 | -| Loading 컴포넌트 size prop | `components/common/Loading.jsx` | ✅ 완료 | diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index 6aee18f..0000000 --- a/docs/migration.md +++ /dev/null @@ -1,455 +0,0 @@ -# Express → Fastify 마이그레이션 - -## 개요 - -`backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 완료 - -## 완료된 작업 - -### 서버 기반 -- [x] Fastify 앱 구조 (`src/app.js`, `src/server.js`) -- [x] 플러그인 시스템 (`src/plugins/`) - - db.js (MariaDB) - - redis.js - - auth.js (JWT) - - meilisearch.js - - scheduler.js (봇 스케줄러) - -### API 라우트 (`src/routes/`) -- [x] 인증 (`/api/auth`) - - POST /login - 로그인 - - GET /verify - 토큰 검증 -- [x] 멤버 (`/api/members`) - - GET / - 목록 조회 - - GET /:name - 상세 조회 - - PUT /:name - 수정 (이미지 업로드 포함) -- [x] 앨범 (`/api/albums`) - - GET / - 목록 조회 - - GET /:id - ID로 조회 - - GET /by-name/:name - 이름으로 조회 - - GET /by-name/:albumName/track/:trackTitle - 트랙 조회 - - POST / - 생성 - - PUT /:id - 수정 - - DELETE /:id - 삭제 - - 사진 관리 (`/api/albums/:id/photos`) - - GET / - 목록 - - POST / - 업로드 - - PUT /:photoId - 수정 - - DELETE /:photoId - 삭제 - - 티저 관리 (`/api/albums/:id/teasers`) - - GET / - 목록 - - POST / - 업로드 - - DELETE /:teaserId - 삭제 -- [x] 일정 (`/api/schedules`) - - GET / - 월별 조회 (생일 포함) - - GET /?search= - Meilisearch 검색 - - GET /:id - 상세 조회 - - DELETE /:id - 삭제 - - POST /sync-search - Meilisearch 동기화 -- [x] 추천 검색어 (`/api/schedules/suggestions`) - - GET / - 추천 검색어 조회 - - GET /popular - 인기 검색어 조회 - - POST /save - 검색어 저장 - - GET /dict - 사용자 사전 조회 (관리자) - - PUT /dict - 사용자 사전 저장 (관리자) -- [x] 통계 (`/api/stats`) - - GET / - 대시보드 통계 - -### 관리자 API (`src/routes/admin/`) -- [x] 봇 관리 (`/api/admin/bots`) - - GET / - 봇 목록 - - POST /:id/start - 봇 시작 - - POST /:id/stop - 봇 정지 - - POST /:id/sync-all - 전체 동기화 - - GET /quota-warning - 할당량 경고 조회 - - DELETE /quota-warning - 할당량 경고 해제 -- [x] YouTube 관리 (`/api/admin/youtube`) - - GET /video-info - 영상 정보 조회 - - POST /schedule - 일정 저장 - - PUT /schedule/:id - 일정 수정 -- [x] X 관리 (`/api/admin/x`) - - GET /post-info - 게시글 정보 조회 - - POST /schedule - 일정 저장 - -### 서비스 (`src/services/`) -- [x] YouTube 봇 (`services/youtube/`) - - 영상 자동 수집 - - 채널별 필터링 (제목 필터, 멤버 추출) -- [x] X(Twitter) 봇 (`services/x/`) - - Nitter 스크래핑 - - 이미지 URL 추출 -- [x] Meilisearch 검색 (`services/meilisearch/`) - - 일정 검색 - - 전체 동기화 -- [x] 추천 검색어 (`services/suggestions/`) - - 형태소 분석 (kiwi-nlp) - - bi-gram 빈도 - - 초성 검색 - - 사용자 사전 관리 -- [x] 이미지 업로드 (`services/image.js`) - - 앨범 커버 - - 멤버 이미지 - - 앨범 사진/티저 - -## 남은 작업 (미구현) - -### 일반 일정 CRUD -- [ ] POST /api/schedules - 일정 생성 (일반) -- [ ] PUT /api/schedules/:id - 일정 수정 (일반) - -※ 현재는 YouTube/X 전용 일정 생성 API만 구현됨 - -### 카테고리 관리 -- [ ] POST /api/schedule-categories - 생성 -- [ ] PUT /api/schedule-categories/:id - 수정 -- [ ] DELETE /api/schedule-categories/:id - 삭제 -- [ ] PUT /api/schedule-categories-order - 순서 변경 - -※ GET은 구현됨 (목록 조회) - -### 기타 -- [ ] GET /api/kakao/places - 카카오 장소 검색 프록시 - -## 파일 비교표 - -| Express (backend-backup) | Fastify (backend) | 상태 | -|--------------------------|-------------------|------| -| routes/admin.js (로그인) | routes/auth.js | 완료 | -| routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 | -| routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 | -| routes/admin.js (멤버 수정) | routes/members/index.js | 완료 | -| routes/admin.js (일정 삭제) | routes/schedules/index.js | 완료 | -| routes/admin.js (일정 생성/수정) | - | 미완료 | -| routes/admin.js (카테고리 CUD) | - | 미완료 | -| routes/admin.js (봇 관리) | routes/admin/bots.js | 완료 | -| routes/admin.js (할당량) | routes/admin/bots.js | 완료 | -| routes/admin.js (카카오) | - | 미완료 | -| - | routes/admin/youtube.js | 신규 | -| - | routes/admin/x.js | 신규 | -| routes/albums.js | routes/albums/index.js | 완료 | -| routes/members.js | routes/members/index.js | 완료 | -| routes/schedules.js | routes/schedules/index.js | 완료 | -| routes/stats.js | routes/stats/index.js | 완료 | -| services/youtube-bot.js | services/youtube/ | 완료 | -| services/youtube-scheduler.js | plugins/scheduler.js | 완료 | -| services/x-bot.js | services/x/ | 완료 | -| services/meilisearch.js | services/meilisearch/ | 완료 | -| services/meilisearch-bot.js | services/meilisearch/ | 완료 | -| services/suggestions.js | services/suggestions/ | 완료 | - -## 참고 사항 - -- 기존 Express 코드는 `backend-backup/` 폴더에 보존 -- 마이그레이션 시 기존 코드 참조하여 동일 기능 구현 -- DB 스키마 변경 사항: - - `tracks` → `album_tracks` (이름 변경) - - `venues` → `concert_venues` (이름 변경) - ---- - -# 프론트엔드 마이그레이션 (Strangler Fig) - -## 개요 - -`frontend/` (레거시) → `frontend-temp/` (신규)로 점진적 마이그레이션 - -## 설계 원칙 - -1. **react-device-detect 사용** - - App.jsx에서 `BrowserView`/`MobileView`로 PC/Mobile 완전 분리 - - User Agent 기반 디바이스 감지 - -2. **기능별 폴더 + pc/mobile 하위 폴더** - - 각 페이지 폴더 내에 `pc/`, `mobile/` 서브폴더 - - 비즈니스 로직(api, hooks, stores)은 최상위에서 공유 - -3. **React Query 사용** - - `useQuery`로 데이터 패칭 및 캐싱 - -4. **관리자 페이지는 PC 전용** - - mobile 폴더 없이 단일 구조 - -## App.jsx 라우팅 구조 - -```jsx -import { BrowserView, MobileView } from 'react-device-detect'; - -function App() { - return ( - - - - {/* Admin routes */} - } /> - } /> - - {/* Public routes with PC Layout */} - }> - } /> - } /> - } /> - } /> - ... - - - - - - - }> - } /> - } /> - ... - - - - - ); -} -``` - -## 전체 마이그레이션 체크리스트 - -### API 계층 - -#### 공개 API -- [x] client.js (fetchApi, fetchAuthApi) -- [x] albums.js -- [x] members.js -- [x] schedules.js -- [x] auth.js - -#### 관리자 API (`api/pc/admin/`) -- [x] albums.js -- [x] members.js -- [x] schedules.js -- [x] categories.js -- [x] stats.js -- [x] bots.js -- [x] suggestions.js - -### 훅 (hooks/) -- [x] useAlbumData.js -- [x] useMemberData.js -- [x] useScheduleData.js -- [x] useScheduleSearch.js -- [x] useScheduleFiltering.js -- [x] useCalendar.js -- [x] useMediaQuery.js -- [x] useAdminAuth.js - -### 관리자 훅 (hooks/pc/admin/, hooks/common/) -- [x] useAdminAuth.js (hooks/pc/admin/) -- [x] useToast.js (hooks/common/) - -### 스토어 (stores/) -- [x] useScheduleStore.js -- [x] useAuthStore.js -- [x] useUIStore.js - -### 상수 (constants/) -- [x] index.js (CATEGORY_ID, MIN_YEAR, SEARCH_LIMIT 등) - -### 유틸리티 (utils/) -- [x] index.js -- [x] date.js -- [x] format.js -- [x] cn.js (className 유틸) -- [x] schedule.js (일정 관련 유틸) - -### 공통 컴포넌트 (components/common/) -- [x] Loading.jsx -- [x] ErrorBoundary.jsx -- [x] Toast.jsx -- [x] Lightbox.jsx (PC용) -- [x] MobileLightbox.jsx (Mobile용, Swiper 기반) -- [x] LightboxIndicator.jsx -- [x] Tooltip.jsx -- [x] ScrollToTop.jsx - -### PC 컴포넌트 (components/pc/) -- [x] Layout.jsx -- [x] Header.jsx -- [x] Footer.jsx -- [x] Calendar.jsx -- [x] ScheduleCard.jsx -- [x] BirthdayCard.jsx -- [x] CategoryFilter.jsx - -### Mobile 컴포넌트 (components/mobile/) -- [x] Layout.jsx -- [x] Header.jsx (MobileHeader) -- [x] BottomNav.jsx (MobileBottomNav) -- [x] Calendar.jsx -- [x] ScheduleCard.jsx -- [x] ScheduleListCard.jsx -- [x] ScheduleSearchCard.jsx -- [x] BirthdayCard.jsx - -### 공용 일정 컴포넌트 (components/schedule/) -- [x] confetti.js (fireBirthdayConfetti) -- [x] AdminScheduleCard.jsx - -### 관리자 컴포넌트 (components/pc/admin/) -- [x] Layout.jsx -- [x] Header.jsx -- [x] ConfirmDialog.jsx -- [x] CustomDatePicker.jsx -- [x] CustomTimePicker.jsx -- [x] NumberPicker.jsx - -### 페이지 - Home (pages/home/) -- [x] pc/Home.jsx -- [x] mobile/Home.jsx - -### 페이지 - Members (pages/members/) -- [x] pc/Members.jsx -- [x] mobile/Members.jsx - -### 페이지 - Album (pages/album/) -- [x] pc/Album.jsx -- [x] pc/AlbumDetail.jsx -- [x] pc/AlbumGallery.jsx -- [x] pc/TrackDetail.jsx -- [x] mobile/Album.jsx -- [x] mobile/AlbumDetail.jsx -- [x] mobile/AlbumGallery.jsx -- [x] mobile/TrackDetail.jsx - -### 페이지 - Schedule (pages/schedule/) -- [x] sections/DefaultSection.jsx -- [x] sections/XSection.jsx -- [x] sections/YoutubeSection.jsx -- [x] sections/utils.js -- [x] sections/index.js -- [x] pc/Schedule.jsx -- [x] pc/ScheduleDetail.jsx -- [x] pc/Birthday.jsx -- [x] mobile/Schedule.jsx -- [x] mobile/ScheduleDetail.jsx -- [x] mobile/Birthday.jsx - -### 페이지 - Common (pages/common/) -- [x] pc/NotFound.jsx -- [x] mobile/NotFound.jsx - -### 페이지 - Admin (pages/pc/admin/) - PC 전용 -- [x] login/Login.jsx -- [x] dashboard/Dashboard.jsx -- [x] members/Members.jsx -- [x] albums/Albums.jsx -- [x] albums/AlbumForm.jsx -- [x] albums/AlbumPhotos.jsx -- [x] albums/AlbumTeasers.jsx -- [x] schedules/Schedules.jsx -- [x] schedules/ScheduleForm.jsx -- [x] schedules/ScheduleCategory.jsx -- [x] schedules/ScheduleDict.jsx -- [x] schedules/ScheduleBots.jsx - -### 기타 -- [x] App.jsx (BrowserView/MobileView 라우팅) -- [x] main.jsx - -### CSS 파일 -- [x] index.css -- [x] mobile.css (모바일 전용 스타일, 달력 등) -- [x] pc.css (PC 전용 스타일) - -### 기타 파일 -- [ ] data/dummy.js (개발용 더미 데이터, 필요 시 추가) -- [ ] .env (VITE_KAKAO_JS_KEY - 콘서트 장소 검색용, 관리자 기능에서 사용) -- [ ] public/favicon.ico (필요 시 추가) - -## 사용 라이브러리 (package.json) - -| 라이브러리 | 용도 | 사용 위치 | -|-----------|------|----------| -| react-device-detect | PC/Mobile 분기 | App.jsx | -| @tanstack/react-query | 데이터 패칭 | 모든 페이지 | -| @tanstack/react-virtual | 가상화 리스트 | 관리자 일정 목록 | -| react-calendar | 캘린더 | 일정 페이지 | -| react-colorful | 색상 선택 | 카테고리 관리 | -| react-photo-album | 앨범 갤러리 | 앨범 갤러리 | -| react-infinite-scroll-component | 무한 스크롤 | 일정 검색 | -| react-intersection-observer | 뷰포트 감지 | 애니메이션 | -| react-ios-time-picker | 시간 선택 | 일정 폼 | -| react-linkify | URL 자동 링크 | 일정 상세 | -| react-window | 가상화 리스트 | 긴 목록 | -| swiper | 슬라이더 | 앨범 상세 | -| canvas-confetti | 축하 효과 | 생일 페이지 | -| framer-motion | 애니메이션 | 전체 | -| dayjs | 날짜 처리 | 전체 | -| zustand | 상태 관리 | 전체 | - -## 특수 패턴 및 주의사항 - -### React Query 고급 사용 -- `useInfiniteQuery` - 일정 검색 무한 스크롤 (mobile/Schedule) -- `useQuery` - 일반 데이터 패칭 - -### Swiper 사용 시 -```jsx -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Virtual } from 'swiper/modules'; -import 'swiper/css'; -``` -- 사용 위치: mobile/Members, mobile/AlbumDetail, mobile/AlbumGallery - -### 가상화 리스트 -- `useVirtualizer` from `@tanstack/react-virtual` -- 사용 위치: mobile/Schedule, admin/AdminSchedule - -### 교차 관찰자 -- `useInView` from `react-intersection-observer` -- 사용 위치: mobile/Schedule (무한 스크롤 트리거) - -### 축하 효과 -- `canvas-confetti` - 생일 페이지 폭죽 효과 -- 사용 위치: pc/Birthday, mobile/Schedule (생일 일정) - -### 색상 선택기 -- `HexColorPicker` from `react-colorful` -- 사용 위치: admin/AdminScheduleCategory - -### 앨범 갤러리 -- `RowsPhotoAlbum` from `react-photo-album` -- 사용 위치: pc/AlbumGallery, mobile/AlbumGallery - -### Kakao API (미구현) -- 콘서트 장소 검색: `/api/admin/kakao/places` -- 환경변수: `VITE_KAKAO_JS_KEY` -- 사용 예정: 콘서트 일정 추가 시 장소 검색 - -## 마이그레이션 진행 상황 - -### 완료된 작업 -- [x] Phase 1: 프로젝트 셋업 -- [x] Phase 2: 유틸리티 및 상수 -- [x] Phase 3: Zustand 스토어 -- [x] Phase 4: API 계층 (공개 API) -- [x] Phase 5: 커스텀 훅 -- [x] Phase 6: 공통 컴포넌트 -- [x] Phase 7: PC/Mobile 레이아웃 컴포넌트 -- [x] Phase 8: App.jsx (BrowserView/MobileView 라우팅) -- [x] Phase 9: 공개 페이지 전체 - - Home, Members, Album, AlbumDetail, AlbumGallery, TrackDetail - - Schedule, ScheduleDetail, Birthday - -### 미완료 작업 - -#### 공개 영역 -- ✅ 모두 완료 - -#### 관리자 영역 -- [x] 관리자 API 전체 (api/pc/admin/) -- [x] 관리자 컴포넌트 전체 (components/pc/admin/) -- [x] 관리자 페이지 전체 (pages/pc/admin/) -- [x] useToast 훅 (hooks/common/) -- [x] useAdminAuth 훅 (hooks/pc/admin/) - -### 최종 검증 -- [ ] 모든 라우트 동작 확인 -- [ ] PC/Mobile 전환 테스트 -- [ ] 관리자 기능 테스트 -- [ ] frontend-temp → frontend 교체 diff --git a/frontend-temp/src/constants/index.js b/frontend-temp/src/constants/index.js index f821698..ac714f0 100644 --- a/frontend-temp/src/constants/index.js +++ b/frontend-temp/src/constants/index.js @@ -9,29 +9,11 @@ export const CATEGORY_ID = { X: 3, }; -/** 카테고리 이름 매핑 */ -export const CATEGORY_NAMES = { - [CATEGORY_ID.DEFAULT]: '기본', - [CATEGORY_ID.YOUTUBE]: 'YouTube', - [CATEGORY_ID.X]: 'X (Twitter)', -}; - /** 공식 SNS 링크 */ export const SOCIAL_LINKS = { youtube: 'https://www.youtube.com/@fromis9_official', instagram: 'https://www.instagram.com/officialfromis_9', - twitter: 'https://twitter.com/realfromis_9', - tiktok: 'https://www.tiktok.com/@officialfromis_9', - fancafe: 'https://cafe.daum.net/officialfromis9', -}; - -/** 앨범 타입 */ -export const ALBUM_TYPES = { - FULL: '정규', - MINI: '미니', - SINGLE: '싱글', - DIGITAL: '디지털', - OST: 'OST', + x: 'https://twitter.com/realfromis_9', }; /** 타임존 */ diff --git a/frontend-temp/src/utils/schedule.js b/frontend-temp/src/utils/schedule.js index 978121f..1936d42 100644 --- a/frontend-temp/src/utils/schedule.js +++ b/frontend-temp/src/utils/schedule.js @@ -50,16 +50,10 @@ export function getScheduleTime(schedule) { /** * 스케줄에서 멤버 이름 목록 추출 - * 다양한 형식 처리 (member_names 문자열, 문자열 배열, 객체 배열) * @param {object} schedule - 스케줄 객체 * @returns {string[]} 멤버 이름 배열 */ export function getMemberList(schedule) { - // member_names 문자열이 있으면 사용 (쉼표 구분) - if (schedule.member_names) { - return schedule.member_names.split(',').map((n) => n.trim()).filter(Boolean); - } - const members = schedule.members || []; if (members.length === 0) return [];