- PC 컴포넌트를 components/pc/로 이동 (Calendar, ScheduleCard, BirthdayCard, CategoryFilter) - Mobile 컴포넌트를 components/mobile/로 이동 (Mobile 접두사 제거) - components/schedule/에는 공용 코드만 유지 (confetti.js, AdminScheduleCard) - Schedule, Home 페이지의 import 경로 업데이트 - 관련 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1626 lines
50 KiB
Markdown
1626 lines
50 KiB
Markdown
# 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 (
|
|
<div className="flex flex-col items-center justify-center p-8 text-center min-h-[200px]">
|
|
<AlertTriangle size={48} className="text-red-500 mb-4" />
|
|
<h2 className="text-xl font-bold text-gray-900 mb-2">
|
|
문제가 발생했습니다
|
|
</h2>
|
|
<p className="text-gray-500 mb-4">
|
|
{this.state.error?.message || '알 수 없는 오류'}
|
|
</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
|
>
|
|
<RefreshCw size={16} />
|
|
새로고침
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={`flex justify-center items-center ${className}`}>
|
|
<div className={`animate-spin rounded-full border-primary border-t-transparent ${sizeClasses[size]}`} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 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 (
|
|
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
|
프로미스나인
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return memberList.map((name, i) => (
|
|
<span
|
|
key={i}
|
|
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
|
>
|
|
{name}
|
|
</span>
|
|
));
|
|
};
|
|
|
|
if (variant === 'list') {
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={cn(
|
|
"p-5 hover:bg-gray-50 transition-colors group cursor-pointer",
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-20 text-center flex-shrink-0">
|
|
{showYear && (
|
|
<div className="text-xs text-gray-400 mb-0.5">
|
|
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
|
|
</div>
|
|
)}
|
|
<div className="text-2xl font-bold text-gray-900">
|
|
{scheduleDate.getDate()}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{WEEKDAYS[scheduleDate.getDay()]}요일
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
|
style={{ backgroundColor: categoryInfo.color }}
|
|
/>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-gray-900 line-clamp-1">{title}</h3>
|
|
<div className="flex items-center flex-wrap gap-3 mt-1 text-sm text-gray-500">
|
|
{timeStr && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={14} />
|
|
{timeStr}
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1">
|
|
<Tag size={14} />
|
|
{categoryInfo.name}
|
|
</span>
|
|
{schedule.source?.name && (
|
|
<span className="flex items-center gap-1">
|
|
<Link2 size={14} />
|
|
{schedule.source.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{memberList.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
<MemberBadges />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{actions && (
|
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{actions}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Card variant (default)
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer",
|
|
className
|
|
)}
|
|
>
|
|
<div
|
|
className="w-24 flex flex-col items-center justify-center text-white py-4"
|
|
style={{ backgroundColor: categoryInfo.color }}
|
|
>
|
|
{showYear && (
|
|
<span className="text-xs font-medium opacity-60">
|
|
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
|
|
</span>
|
|
)}
|
|
<span className="text-3xl font-bold">{scheduleDate.getDate()}</span>
|
|
<span className="text-sm font-medium opacity-80">
|
|
{WEEKDAYS[scheduleDate.getDay()]}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 p-4 flex flex-col justify-center">
|
|
<h3 className="font-bold text-lg mb-1 line-clamp-1">{title}</h3>
|
|
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
|
{timeStr && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={14} />
|
|
{timeStr}
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1">
|
|
<Tag size={14} />
|
|
{categoryInfo.name}
|
|
</span>
|
|
</div>
|
|
{memberList.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{memberList.slice(0, 3).map((name, i) => (
|
|
<span
|
|
key={i}
|
|
className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full"
|
|
>
|
|
{name}
|
|
</span>
|
|
))}
|
|
{memberList.length > 3 && (
|
|
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">
|
|
+{memberList.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
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 (
|
|
<div
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex items-center gap-3 p-4 bg-gradient-to-r from-pink-50 to-purple-50 rounded-xl cursor-pointer hover:shadow-md transition-shadow",
|
|
className
|
|
)}
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-400 to-purple-400 flex items-center justify-center">
|
|
<Cake size={20} className="text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-gray-900">{memberName}</p>
|
|
<p className="text-sm text-pink-500">생일 축하해요!</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={cn(
|
|
"relative overflow-hidden rounded-2xl bg-gradient-to-br from-pink-100 via-purple-50 to-blue-100 p-6 cursor-pointer hover:shadow-lg transition-shadow",
|
|
className
|
|
)}
|
|
>
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-pink-200/50 to-purple-200/50 rounded-full -translate-y-1/2 translate-x-1/2" />
|
|
<div className="relative">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-pink-400 to-purple-400 flex items-center justify-center shadow-lg">
|
|
<Cake size={24} className="text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-purple-600 font-medium">오늘은</p>
|
|
<h3 className="text-xl font-bold text-gray-900">{memberName} 생일!</h3>
|
|
</div>
|
|
</div>
|
|
<p className="text-pink-600 font-medium">생일 축하해요!</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
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 (
|
|
<Layout>
|
|
<div className="h-full flex">
|
|
{/* 왼쪽: 달력 + 카테고리 */}
|
|
<aside className="w-80 flex-shrink-0 p-6 space-y-6">
|
|
<CalendarPicker
|
|
{...calendar}
|
|
schedules={schedules}
|
|
disabled={search.isSearchMode}
|
|
/>
|
|
<CategoryFilter
|
|
categories={categories}
|
|
disabled={search.isSearchMode && !search.searchTerm}
|
|
/>
|
|
</aside>
|
|
|
|
{/* 오른쪽: 일정 목록 */}
|
|
<main className="flex-1 flex flex-col min-h-0">
|
|
<SearchBar {...search} />
|
|
|
|
{isLoading ? (
|
|
<Loading className="flex-1" />
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
<AnimatePresence mode="popLayout">
|
|
{filteredSchedules.map((schedule, index) => (
|
|
<motion.div
|
|
key={schedule.id}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
transition={{ delay: index * 0.02 }}
|
|
>
|
|
{isBirthdaySchedule(schedule) ? (
|
|
<BirthdayCard
|
|
schedule={schedule}
|
|
onClick={() => handleScheduleClick(schedule)}
|
|
/>
|
|
) : (
|
|
<ScheduleCard
|
|
schedule={schedule}
|
|
categories={categories}
|
|
onClick={() => handleScheduleClick(schedule)}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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 페이지 | ⬜ 대기 |
|
|
| 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
|
|
|
|
**스토어**
|
|
- ✅ 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 이미지 태그로 이전 버전 배포 가능
|