diff --git a/docs/frontend-refactoring.md b/docs/frontend-refactoring.md new file mode 100644 index 0000000..8e3c9e0 --- /dev/null +++ b/docs/frontend-refactoring.md @@ -0,0 +1,1590 @@ +# 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 계층 | ⬜ 대기 | +| 5 | 커스텀 훅 | ⬜ 대기 | +| 6 | 공통 컴포넌트 | ⬜ 대기 | +| 7 | Schedule 페이지 | ⬜ 대기 | +| 8 | Album 페이지 | ⬜ 대기 | +| 9 | 기타 Public 페이지 | ⬜ 대기 | +| 10 | Admin 페이지 | ⬜ 대기 | +| 11 | 최종 검증 및 교체 | ⬜ 대기 | + +--- + +## 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 이미지 태그로 이전 버전 배포 가능 diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index daf2c96..e7e70d3 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -1,30 +1,48 @@ +import { useState } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { isMobile } from "react-device-detect"; +import { useQuery } from "@tanstack/react-query"; import { cn, getTodayKST, formatFullDate } from "@/utils"; -import { useAuthStore, useScheduleStore, useUIStore } from "@/stores"; +import { useAuthStore, useUIStore } from "@/stores"; +import { memberApi, scheduleApi } from "@/api"; /** * 프로미스나인 팬사이트 메인 앱 * - * Phase 3: 스토어 완료 - * - useAuthStore: 인증 상태 (localStorage 지속) - * - useScheduleStore: 스케줄 페이지 상태 - * - useUIStore: UI 상태 (모달, 토스트, 라이트박스 등) + * Phase 4: API 계층 완료 + * - api/client.js: 기본 fetch 클라이언트 (공개/인증) + * - api/schedules.js: 스케줄 API + * - api/albums.js: 앨범 API + * - api/members.js: 멤버 API + * - api/auth.js: 인증 API */ function App() { const today = getTodayKST(); - const { isAuthenticated, login, logout } = useAuthStore(); - const { viewMode, setViewMode, selectedCategories, toggleCategory } = useScheduleStore(); + const { isAuthenticated } = useAuthStore(); const { showSuccess, showError, toasts } = useUIStore(); + const [apiTest, setApiTest] = useState(null); - const handleTestLogin = () => { - login("test-token", { name: "테스트 유저" }); - showSuccess("로그인 성공!"); - }; + // React Query로 멤버 데이터 조회 + const { data: members, isLoading: membersLoading } = useQuery({ + queryKey: ["members"], + queryFn: memberApi.getMembers, + }); - const handleTestLogout = () => { - logout(); - showError("로그아웃됨"); + // React Query로 카테고리 데이터 조회 + const { data: categories, isLoading: categoriesLoading } = useQuery({ + queryKey: ["categories"], + queryFn: scheduleApi.getCategories, + }); + + const handleTestApi = async () => { + try { + const data = await memberApi.getMembers(); + setApiTest(`멤버 ${data.length}명 조회 성공!`); + showSuccess("API 호출 성공!"); + } catch (error) { + setApiTest(`에러: ${error.message}`); + showError("API 호출 실패"); + } }; return ( @@ -33,48 +51,69 @@ function App() { -
+
+

fromis_9 Frontend Refactoring

-

Phase 3 완료 - 스토어

+

Phase 4 완료 - API 계층

디바이스: {isMobile ? "모바일" : "PC"}

오늘: {formatFullDate(today)}

+

인증: {isAuthenticated ? "✅" : "❌"}

-

useAuthStore 테스트

-

인증 상태: {isAuthenticated ? "✅ 로그인됨" : "❌ 로그아웃"}

-
- - +

API 테스트 (React Query)

+ +
+

+ 멤버:{" "} + {membersLoading ? "로딩 중..." : `${members?.length || 0}명`} +

+ {members && ( +
+ {members.map((m) => ( + + {m.name} + + ))} +
+ )} +
+ +
+

+ 카테고리:{" "} + {categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개`} +

+ {categories && ( +
+ {categories.map((c) => ( + + {c.name} + + ))} +
+ )}
-

useScheduleStore 테스트

-

뷰 모드: {viewMode}

-
- - -
-

선택된 카테고리: {selectedCategories.length > 0 ? selectedCategories.join(", ") : "없음"}

-
- {[1, 2, 3].map((id) => ( - - ))} -
-
- -
-

useUIStore 토스트

-

활성 토스트: {toasts.length}개

+

직접 API 호출 테스트

+ + {apiTest &&

{apiTest}

}
diff --git a/frontend-temp/src/api/.gitkeep b/frontend-temp/src/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend-temp/src/api/albums.js b/frontend-temp/src/api/albums.js new file mode 100644 index 0000000..2265c43 --- /dev/null +++ b/frontend-temp/src/api/albums.js @@ -0,0 +1,101 @@ +/** + * 앨범 API + */ +import { fetchApi, fetchAuthApi, fetchFormData } from './client'; + +// ==================== 공개 API ==================== + +/** + * 앨범 목록 조회 + */ +export async function getAlbums() { + return fetchApi('/albums'); +} + +/** + * 앨범 상세 조회 (ID) + */ +export async function getAlbum(id) { + return fetchApi(`/albums/${id}`); +} + +/** + * 앨범 상세 조회 (이름) + */ +export async function getAlbumByName(name) { + return fetchApi(`/albums/by-name/${encodeURIComponent(name)}`); +} + +/** + * 앨범 사진 조회 + */ +export async function getAlbumPhotos(albumId) { + return fetchApi(`/albums/${albumId}/photos`); +} + +/** + * 앨범 트랙 조회 + */ +export async function getAlbumTracks(albumId) { + return fetchApi(`/albums/${albumId}/tracks`); +} + +/** + * 트랙 상세 조회 (앨범명, 트랙명으로) + */ +export async function getTrack(albumName, trackTitle) { + return fetchApi( + `/albums/by-name/${encodeURIComponent(albumName)}/track/${encodeURIComponent(trackTitle)}` + ); +} + +/** + * 앨범 티저 조회 + */ +export async function getAlbumTeasers(albumId) { + return fetchApi(`/albums/${albumId}/teasers`); +} + +// ==================== 어드민 API ==================== + +/** + * [Admin] 앨범 생성 + */ +export async function createAlbum(formData) { + return fetchFormData('/albums', formData, 'POST'); +} + +/** + * [Admin] 앨범 수정 + */ +export async function updateAlbum(id, formData) { + return fetchFormData(`/albums/${id}`, formData, 'PUT'); +} + +/** + * [Admin] 앨범 삭제 + */ +export async function deleteAlbum(id) { + return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' }); +} + +/** + * [Admin] 앨범 사진 업로드 + */ +export async function uploadAlbumPhotos(albumId, formData) { + return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST'); +} + +/** + * [Admin] 앨범 사진 삭제 + */ +export async function deleteAlbumPhoto(albumId, photoId) { + return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' }); +} + +/** + * [Admin] 앨범 티저 삭제 + */ +export async function deleteAlbumTeaser(albumId, teaserId) { + return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' }); +} diff --git a/frontend-temp/src/api/auth.js b/frontend-temp/src/api/auth.js new file mode 100644 index 0000000..d45e805 --- /dev/null +++ b/frontend-temp/src/api/auth.js @@ -0,0 +1,37 @@ +/** + * 인증 API + */ +import { fetchApi, fetchAuthApi } from './client'; + +/** + * 로그인 + * @param {string} username - 사용자명 + * @param {string} password - 비밀번호 + * @returns {Promise<{token: string, user: object}>} + */ +export async function login(username, password) { + return fetchApi('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); +} + +/** + * 토큰 검증 + * @returns {Promise<{valid: boolean, user: object}>} + */ +export async function verifyToken() { + return fetchAuthApi('/auth/verify'); +} + +/** + * 비밀번호 변경 + * @param {string} currentPassword - 현재 비밀번호 + * @param {string} newPassword - 새 비밀번호 + */ +export async function changePassword(currentPassword, newPassword) { + return fetchAuthApi('/auth/change-password', { + method: 'POST', + body: JSON.stringify({ currentPassword, newPassword }), + }); +} diff --git a/frontend-temp/src/api/client.js b/frontend-temp/src/api/client.js new file mode 100644 index 0000000..39e7d8d --- /dev/null +++ b/frontend-temp/src/api/client.js @@ -0,0 +1,152 @@ +/** + * API 클라이언트 + * 모든 API 호출에서 사용되는 기본 fetch 래퍼 + */ +import { useAuthStore } from '@/stores'; + +const API_BASE = '/api'; + +/** + * API 에러 클래스 + */ +export class ApiError extends Error { + constructor(message, status, data = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } +} + +/** + * 응답 처리 헬퍼 + */ +async function handleResponse(response) { + if (!response.ok) { + const error = await response.json().catch(() => ({ error: '요청 실패' })); + throw new ApiError( + error.error || error.message || `HTTP ${response.status}`, + response.status, + error + ); + } + + // 204 No Content 처리 + if (response.status === 204) { + return null; + } + + return response.json(); +} + +/** + * 공개 API fetch + * @param {string} endpoint - API 엔드포인트 (/api 제외) + * @param {RequestInit} options - fetch 옵션 + */ +export async function fetchApi(endpoint, options = {}) { + const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`; + const headers = { ...options.headers }; + + // body가 있고 FormData가 아닐 때만 Content-Type 설정 + if (options.body && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(url, { + ...options, + headers, + }); + + return handleResponse(response); +} + +/** + * 인증된 API fetch (토큰 자동 추가) + * @param {string} endpoint - API 엔드포인트 + * @param {RequestInit} options - fetch 옵션 + */ +export async function fetchAuthApi(endpoint, options = {}) { + const token = useAuthStore.getState().token; + + if (!token) { + throw new ApiError('인증이 필요합니다.', 401); + } + + return fetchApi(endpoint, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + }, + }); +} + +/** + * FormData 전송용 (이미지 업로드 등) + * @param {string} endpoint - API 엔드포인트 + * @param {FormData} formData - 전송할 FormData + * @param {string} method - HTTP 메서드 (기본: POST) + */ +export async function fetchFormData(endpoint, formData, method = 'POST') { + const token = useAuthStore.getState().token; + const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`; + + const headers = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { + method, + headers, + body: formData, + }); + + return handleResponse(response); +} + +/** + * GET 요청 헬퍼 + */ +export const get = (endpoint) => fetchApi(endpoint); +export const authGet = (endpoint) => fetchAuthApi(endpoint); + +/** + * POST 요청 헬퍼 + */ +export const post = (endpoint, data) => + fetchApi(endpoint, { + method: 'POST', + body: JSON.stringify(data), + }); + +export const authPost = (endpoint, data) => + fetchAuthApi(endpoint, { + method: 'POST', + body: JSON.stringify(data), + }); + +/** + * PUT 요청 헬퍼 + */ +export const put = (endpoint, data) => + fetchApi(endpoint, { + method: 'PUT', + body: JSON.stringify(data), + }); + +export const authPut = (endpoint, data) => + fetchAuthApi(endpoint, { + method: 'PUT', + body: JSON.stringify(data), + }); + +/** + * DELETE 요청 헬퍼 + */ +export const del = (endpoint) => + fetchApi(endpoint, { method: 'DELETE' }); + +export const authDel = (endpoint) => + fetchAuthApi(endpoint, { method: 'DELETE' }); diff --git a/frontend-temp/src/api/index.js b/frontend-temp/src/api/index.js new file mode 100644 index 0000000..2ad80fc --- /dev/null +++ b/frontend-temp/src/api/index.js @@ -0,0 +1,31 @@ +/** + * API 통합 export + */ + +// 클라이언트 +export { + fetchApi, + fetchAuthApi, + fetchFormData, + ApiError, + get, + authGet, + post, + authPost, + put, + authPut, + del, + authDel, +} from './client'; + +// 인증 +export * as authApi from './auth'; + +// 스케줄 +export * as scheduleApi from './schedules'; + +// 앨범 +export * as albumApi from './albums'; + +// 멤버 +export * as memberApi from './members'; diff --git a/frontend-temp/src/api/members.js b/frontend-temp/src/api/members.js new file mode 100644 index 0000000..d93814e --- /dev/null +++ b/frontend-temp/src/api/members.js @@ -0,0 +1,43 @@ +/** + * 멤버 API + */ +import { fetchApi, fetchAuthApi, fetchFormData } from './client'; + +// ==================== 공개 API ==================== + +/** + * 멤버 목록 조회 + */ +export async function getMembers() { + return fetchApi('/members'); +} + +/** + * 멤버 상세 조회 + */ +export async function getMember(id) { + return fetchApi(`/members/${id}`); +} + +// ==================== 어드민 API ==================== + +/** + * [Admin] 멤버 생성 + */ +export async function createMember(formData) { + return fetchFormData('/admin/members', formData, 'POST'); +} + +/** + * [Admin] 멤버 수정 + */ +export async function updateMember(id, formData) { + return fetchFormData(`/admin/members/${id}`, formData, 'PUT'); +} + +/** + * [Admin] 멤버 삭제 + */ +export async function deleteMember(id) { + return fetchAuthApi(`/admin/members/${id}`, { method: 'DELETE' }); +} diff --git a/frontend-temp/src/api/schedules.js b/frontend-temp/src/api/schedules.js new file mode 100644 index 0000000..144c82d --- /dev/null +++ b/frontend-temp/src/api/schedules.js @@ -0,0 +1,150 @@ +/** + * 스케줄 API + */ +import { fetchApi, fetchAuthApi, fetchFormData } from './client'; +import { getTodayKST } from '@/utils'; + +/** + * API 응답을 플랫 배열로 변환 + * 백엔드가 날짜별 그룹화된 객체를 반환하므로 변환 필요 + */ +function flattenScheduleResponse(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, + }); + } + } + return schedules; +} + +// ==================== 공개 API ==================== + +/** + * 스케줄 목록 조회 (월별) + */ +export async function getSchedules(year, month) { + const data = await fetchApi(`/schedules?year=${year}&month=${month}`); + return flattenScheduleResponse(data); +} + +/** + * 다가오는 스케줄 조회 + */ +export async function getUpcomingSchedules(limit = 3) { + const today = getTodayKST(); + return fetchApi(`/schedules?startDate=${today}&limit=${limit}`); +} + +/** + * 스케줄 검색 (Meilisearch) + */ +export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) { + return fetchApi( + `/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}` + ); +} + +/** + * 스케줄 상세 조회 + */ +export async function getSchedule(id) { + return fetchApi(`/schedules/${id}`); +} + +/** + * X 프로필 정보 조회 + */ +export async function getXProfile(username) { + return fetchApi(`/schedules/x-profile/${encodeURIComponent(username)}`); +} + +/** + * 카테고리 목록 조회 + */ +export async function getCategories() { + return fetchApi('/schedules/categories'); +} + +// ==================== 어드민 API ==================== + +/** + * [Admin] 스케줄 검색 + */ +export async function adminSearchSchedules(query) { + return fetchAuthApi(`/admin/schedules/search?q=${encodeURIComponent(query)}`); +} + +/** + * [Admin] 스케줄 상세 조회 + */ +export async function adminGetSchedule(id) { + return fetchAuthApi(`/admin/schedules/${id}`); +} + +/** + * [Admin] 스케줄 생성 + */ +export async function createSchedule(formData) { + return fetchFormData('/admin/schedules', formData, 'POST'); +} + +/** + * [Admin] 스케줄 수정 + */ +export async function updateSchedule(id, formData) { + return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT'); +} + +/** + * [Admin] 스케줄 삭제 + */ +export async function deleteSchedule(id) { + return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' }); +} + +// ==================== 카테고리 어드민 API ==================== + +/** + * [Admin] 카테고리 생성 + */ +export async function createCategory(data) { + return fetchAuthApi('/admin/schedule-categories', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * [Admin] 카테고리 수정 + */ +export async function updateCategory(id, data) { + return fetchAuthApi(`/admin/schedule-categories/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +/** + * [Admin] 카테고리 삭제 + */ +export async function deleteCategory(id) { + return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' }); +} + +/** + * [Admin] 카테고리 순서 변경 + */ +export async function reorderCategories(orders) { + return fetchAuthApi('/admin/schedule-categories-order', { + method: 'PUT', + body: JSON.stringify({ orders }), + }); +}