refactor: frontend-temp를 frontend로 대체 및 문서 업데이트
- frontend 폴더를 새로 리팩토링된 frontend-temp로 교체 - docs/architecture.md: 현재 프로젝트 구조 반영 - docs/development.md: API 클라이언트 구조 업데이트 - docs/frontend-improvement.md 삭제 (완료된 개선 계획) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
218b825878
commit
980ae3fe1d
238 changed files with 3437 additions and 26795 deletions
|
|
@ -12,19 +12,6 @@ services:
|
||||||
- app
|
- app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
fromis9-frontend-dev:
|
|
||||||
build: ./frontend-temp
|
|
||||||
container_name: fromis9-frontend-dev
|
|
||||||
labels:
|
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
|
||||||
volumes:
|
|
||||||
- ./frontend-temp:/app
|
|
||||||
depends_on:
|
|
||||||
- fromis9-backend
|
|
||||||
networks:
|
|
||||||
- app
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
fromis9-backend:
|
fromis9-backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: fromis9-backend
|
container_name: fromis9-backend
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
fromis_9/
|
fromis_9/
|
||||||
├── backend/ # Fastify 백엔드 (현재 사용)
|
├── backend/ # Fastify 백엔드
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── config/
|
│ │ ├── config/
|
||||||
│ │ │ ├── index.js # 환경변수 통합 관리
|
│ │ │ ├── index.js # 환경변수 통합 관리
|
||||||
|
|
@ -43,28 +43,15 @@ fromis_9/
|
||||||
│ ├── Dockerfile # 백엔드 컨테이너
|
│ ├── Dockerfile # 백엔드 컨테이너
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
├── frontend/ # React 프론트엔드
|
||||||
│
|
|
||||||
├── frontend/ # React 프론트엔드 (레거시, frontend-temp로 대체 예정)
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/
|
│ │ ├── api/ # API 클라이언트
|
||||||
│ │ │ ├── public/ # 공개 API
|
|
||||||
│ │ │ └── admin/ # 어드민 API
|
|
||||||
│ │ ├── pages/
|
|
||||||
│ │ │ ├── pc/ # PC 페이지
|
|
||||||
│ │ │ └── mobile/ # 모바일 페이지
|
|
||||||
│ │ └── ...
|
|
||||||
│ └── package.json
|
|
||||||
│
|
|
||||||
├── frontend-temp/ # React 프론트엔드 (신규, Strangler Fig 마이그레이션)
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # API 클라이언트 (공유)
|
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── client.js # fetchApi, fetchAuthApi
|
│ │ │ ├── client.js # fetchApi, fetchAuthApi
|
||||||
│ │ │ ├── albums.js
|
│ │ │ ├── public/ # 공개 API
|
||||||
│ │ │ ├── members.js
|
│ │ │ │ ├── albums.js
|
||||||
│ │ │ ├── schedules.js
|
│ │ │ │ ├── members.js
|
||||||
│ │ │ ├── auth.js
|
│ │ │ │ └── schedules.js
|
||||||
│ │ │ └── admin/ # 관리자 API
|
│ │ │ └── admin/ # 관리자 API
|
||||||
│ │ │ ├── albums.js
|
│ │ │ ├── albums.js
|
||||||
│ │ │ ├── members.js
|
│ │ │ ├── members.js
|
||||||
|
|
@ -72,131 +59,164 @@ fromis_9/
|
||||||
│ │ │ ├── categories.js
|
│ │ │ ├── categories.js
|
||||||
│ │ │ ├── stats.js
|
│ │ │ ├── stats.js
|
||||||
│ │ │ ├── bots.js
|
│ │ │ ├── bots.js
|
||||||
|
│ │ │ ├── auth.js
|
||||||
│ │ │ └── suggestions.js
|
│ │ │ └── suggestions.js
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── hooks/ # 커스텀 훅 (공유)
|
│ │ ├── hooks/ # 커스텀 훅
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── useAlbumData.js
|
│ │ │ ├── common/ # 공통 훅
|
||||||
│ │ │ ├── useMemberData.js
|
│ │ │ │ └── useToast.js
|
||||||
│ │ │ ├── useScheduleData.js
|
│ │ │ └── pc/
|
||||||
│ │ │ ├── useScheduleSearch.js
|
│ │ │ └── admin/ # 관리자 훅
|
||||||
│ │ │ ├── useCalendar.js
|
│ │ │ ├── useAdminAuth.js
|
||||||
│ │ │ ├── useToast.js
|
│ │ │ └── useScheduleSearch.js
|
||||||
│ │ │ └── useAdminAuth.js
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── stores/ # Zustand 스토어 (공유)
|
│ │ ├── stores/ # Zustand 스토어
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── useScheduleStore.js
|
│ │ │ ├── useScheduleStore.js
|
||||||
│ │ │ └── useAuthStore.js
|
│ │ │ └── useAuthStore.js
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── utils/ # 유틸리티 (공유)
|
│ │ ├── utils/ # 유틸리티
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── date.js
|
│ │ │ ├── cn.js # className 병합
|
||||||
│ │ │ └── format.js
|
│ │ │ ├── color.js # 색상 상수/유틸
|
||||||
|
│ │ │ ├── confetti.js # 생일 축하 효과
|
||||||
|
│ │ │ ├── date.js # 날짜 포맷
|
||||||
|
│ │ │ ├── format.js # 문자열 포맷
|
||||||
|
│ │ │ ├── schedule.js # 일정 관련 유틸
|
||||||
|
│ │ │ └── youtube.js # YouTube URL 파싱
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── constants/
|
│ │ ├── constants/
|
||||||
│ │ │ └── index.js
|
│ │ │ └── index.js # 상수 정의
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── components/
|
│ │ ├── components/
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── index.js
|
||||||
│ │ │ ├── common/ # 디바이스 무관 공통 컴포넌트
|
│ │ │ ├── common/ # 공통 컴포넌트
|
||||||
│ │ │ │ ├── Loading.jsx
|
│ │ │ │ ├── Loading.jsx
|
||||||
│ │ │ │ ├── ErrorBoundary.jsx
|
│ │ │ │ ├── ErrorBoundary.jsx
|
||||||
|
│ │ │ │ ├── ErrorMessage.jsx
|
||||||
│ │ │ │ ├── Toast.jsx
|
│ │ │ │ ├── Toast.jsx
|
||||||
│ │ │ │ ├── Lightbox.jsx
|
|
||||||
│ │ │ │ ├── LightboxIndicator.jsx
|
|
||||||
│ │ │ │ ├── Tooltip.jsx
|
│ │ │ │ ├── Tooltip.jsx
|
||||||
|
│ │ │ │ ├── Lightbox.jsx
|
||||||
|
│ │ │ │ ├── MobileLightbox.jsx
|
||||||
|
│ │ │ │ ├── LightboxIndicator.jsx
|
||||||
|
│ │ │ │ ├── AnimatedNumber.jsx
|
||||||
│ │ │ │ └── ScrollToTop.jsx
|
│ │ │ │ └── ScrollToTop.jsx
|
||||||
│ │ │ ├── pc/ # PC 레이아웃 컴포넌트
|
│ │ │ │
|
||||||
│ │ │ │ ├── Layout.jsx
|
│ │ │ ├── pc/
|
||||||
│ │ │ │ ├── Header.jsx
|
│ │ │ │ ├── public/ # PC 공개 컴포넌트
|
||||||
│ │ │ │ └── Footer.jsx
|
│ │ │ │ │ ├── layout/
|
||||||
│ │ │ ├── mobile/ # Mobile 레이아웃 컴포넌트
|
│ │ │ │ │ │ ├── Layout.jsx
|
||||||
│ │ │ │ ├── Layout.jsx
|
│ │ │ │ │ │ ├── Header.jsx
|
||||||
│ │ │ │ └── MobileNav.jsx
|
│ │ │ │ │ │ └── Footer.jsx
|
||||||
│ │ │ └── admin/ # 관리자 컴포넌트
|
│ │ │ │ │ └── schedule/
|
||||||
│ │ │ ├── AdminLayout.jsx
|
│ │ │ │ │ ├── Calendar.jsx
|
||||||
│ │ │ ├── AdminHeader.jsx
|
│ │ │ │ │ ├── ScheduleCard.jsx
|
||||||
│ │ │ ├── ConfirmDialog.jsx
|
│ │ │ │ │ ├── BirthdayCard.jsx
|
||||||
│ │ │ ├── CustomDatePicker.jsx
|
│ │ │ │ │ └── CategoryFilter.jsx
|
||||||
│ │ │ ├── CustomTimePicker.jsx
|
│ │ │ │ │
|
||||||
│ │ │ └── NumberPicker.jsx
|
│ │ │ │ └── admin/ # PC 관리자 컴포넌트
|
||||||
|
│ │ │ │ ├── layout/
|
||||||
|
│ │ │ │ │ ├── Layout.jsx
|
||||||
|
│ │ │ │ │ └── Header.jsx
|
||||||
|
│ │ │ │ ├── common/
|
||||||
|
│ │ │ │ │ ├── ConfirmDialog.jsx
|
||||||
|
│ │ │ │ │ ├── DatePicker.jsx
|
||||||
|
│ │ │ │ │ ├── TimePicker.jsx
|
||||||
|
│ │ │ │ │ ├── NumberPicker.jsx
|
||||||
|
│ │ │ │ │ └── CustomSelect.jsx
|
||||||
|
│ │ │ │ ├── schedule/
|
||||||
|
│ │ │ │ │ ├── AdminScheduleCard.jsx
|
||||||
|
│ │ │ │ │ ├── ScheduleItem.jsx
|
||||||
|
│ │ │ │ │ ├── CategorySelector.jsx
|
||||||
|
│ │ │ │ │ ├── CategoryFormModal.jsx
|
||||||
|
│ │ │ │ │ ├── MemberSelector.jsx
|
||||||
|
│ │ │ │ │ ├── ImageUploader.jsx
|
||||||
|
│ │ │ │ │ ├── LocationSearchDialog.jsx
|
||||||
|
│ │ │ │ │ └── WordItem.jsx
|
||||||
|
│ │ │ │ ├── album/
|
||||||
|
│ │ │ │ │ ├── TrackItem.jsx
|
||||||
|
│ │ │ │ │ ├── PhotoGrid.jsx
|
||||||
|
│ │ │ │ │ ├── PhotoPreviewModal.jsx
|
||||||
|
│ │ │ │ │ ├── PendingFileItem.jsx
|
||||||
|
│ │ │ │ │ └── BulkEditPanel.jsx
|
||||||
|
│ │ │ │ └── bot/
|
||||||
|
│ │ │ │ └── BotCard.jsx
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ └── mobile/ # 모바일 컴포넌트
|
||||||
|
│ │ │ ├── layout/
|
||||||
|
│ │ │ │ ├── Layout.jsx
|
||||||
|
│ │ │ │ ├── Header.jsx
|
||||||
|
│ │ │ │ └── BottomNav.jsx
|
||||||
|
│ │ │ └── schedule/
|
||||||
|
│ │ │ ├── Calendar.jsx
|
||||||
|
│ │ │ ├── ScheduleCard.jsx
|
||||||
|
│ │ │ ├── ScheduleListCard.jsx
|
||||||
|
│ │ │ ├── ScheduleSearchCard.jsx
|
||||||
|
│ │ │ └── BirthdayCard.jsx
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── pages/
|
│ │ ├── pages/
|
||||||
│ │ │ ├── index.js
|
│ │ │ ├── pc/
|
||||||
|
│ │ │ │ ├── public/ # PC 공개 페이지
|
||||||
|
│ │ │ │ │ ├── home/
|
||||||
|
│ │ │ │ │ │ └── Home.jsx
|
||||||
|
│ │ │ │ │ ├── members/
|
||||||
|
│ │ │ │ │ │ └── Members.jsx
|
||||||
|
│ │ │ │ │ ├── album/
|
||||||
|
│ │ │ │ │ │ ├── Album.jsx
|
||||||
|
│ │ │ │ │ │ ├── AlbumDetail.jsx
|
||||||
|
│ │ │ │ │ │ ├── AlbumGallery.jsx
|
||||||
|
│ │ │ │ │ │ └── TrackDetail.jsx
|
||||||
|
│ │ │ │ │ ├── schedule/
|
||||||
|
│ │ │ │ │ │ ├── Schedule.jsx
|
||||||
|
│ │ │ │ │ │ ├── ScheduleDetail.jsx
|
||||||
|
│ │ │ │ │ │ ├── Birthday.jsx
|
||||||
|
│ │ │ │ │ │ └── sections/
|
||||||
|
│ │ │ │ │ │ ├── DefaultSection.jsx
|
||||||
|
│ │ │ │ │ │ ├── YoutubeSection.jsx
|
||||||
|
│ │ │ │ │ │ └── XSection.jsx
|
||||||
|
│ │ │ │ │ └── common/
|
||||||
|
│ │ │ │ │ └── NotFound.jsx
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ └── admin/ # PC 관리자 페이지
|
||||||
|
│ │ │ │ ├── Login.jsx
|
||||||
|
│ │ │ │ ├── Dashboard.jsx
|
||||||
|
│ │ │ │ ├── members/
|
||||||
|
│ │ │ │ │ ├── Members.jsx
|
||||||
|
│ │ │ │ │ └── MemberEdit.jsx
|
||||||
|
│ │ │ │ ├── albums/
|
||||||
|
│ │ │ │ │ ├── Albums.jsx
|
||||||
|
│ │ │ │ │ ├── AlbumForm.jsx
|
||||||
|
│ │ │ │ │ └── AlbumPhotos.jsx
|
||||||
|
│ │ │ │ └── schedules/
|
||||||
|
│ │ │ │ ├── Schedules.jsx
|
||||||
|
│ │ │ │ ├── ScheduleForm.jsx
|
||||||
|
│ │ │ │ ├── ScheduleDict.jsx
|
||||||
|
│ │ │ │ ├── ScheduleBots.jsx
|
||||||
|
│ │ │ │ ├── ScheduleCategory.jsx
|
||||||
|
│ │ │ │ ├── form/
|
||||||
|
│ │ │ │ │ ├── YouTubeForm.jsx
|
||||||
|
│ │ │ │ │ └── XForm.jsx
|
||||||
|
│ │ │ │ └── edit/
|
||||||
|
│ │ │ │ └── YouTubeEdit.jsx
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ ├── home/
|
│ │ │ └── mobile/ # 모바일 페이지
|
||||||
│ │ │ │ ├── index.js # export { PCHome, MobileHome }
|
│ │ │ ├── home/
|
||||||
│ │ │ │ ├── pc/
|
│ │ │ │ └── Home.jsx
|
||||||
│ │ │ │ │ └── Home.jsx
|
|
||||||
│ │ │ │ └── mobile/
|
|
||||||
│ │ │ │ └── Home.jsx
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── members/
|
|
||||||
│ │ │ │ ├── index.js
|
|
||||||
│ │ │ │ ├── pc/
|
|
||||||
│ │ │ │ │ └── Members.jsx
|
|
||||||
│ │ │ │ └── mobile/
|
|
||||||
│ │ │ │ └── Members.jsx
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── album/
|
|
||||||
│ │ │ │ ├── index.js
|
|
||||||
│ │ │ │ ├── pc/
|
|
||||||
│ │ │ │ │ ├── Album.jsx
|
|
||||||
│ │ │ │ │ ├── AlbumDetail.jsx
|
|
||||||
│ │ │ │ │ ├── AlbumGallery.jsx
|
|
||||||
│ │ │ │ │ └── TrackDetail.jsx
|
|
||||||
│ │ │ │ └── mobile/
|
|
||||||
│ │ │ │ ├── Album.jsx
|
|
||||||
│ │ │ │ ├── AlbumDetail.jsx
|
|
||||||
│ │ │ │ ├── AlbumGallery.jsx
|
|
||||||
│ │ │ │ └── TrackDetail.jsx
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── schedule/
|
|
||||||
│ │ │ │ ├── index.js
|
|
||||||
│ │ │ │ ├── sections/ # 일정 상세 섹션 (PC 전용)
|
|
||||||
│ │ │ │ │ ├── DefaultSection.jsx
|
|
||||||
│ │ │ │ │ ├── XSection.jsx
|
|
||||||
│ │ │ │ │ └── YoutubeSection.jsx
|
|
||||||
│ │ │ │ ├── pc/
|
|
||||||
│ │ │ │ │ ├── Schedule.jsx
|
|
||||||
│ │ │ │ │ ├── ScheduleDetail.jsx
|
|
||||||
│ │ │ │ │ └── Birthday.jsx
|
|
||||||
│ │ │ │ └── mobile/
|
|
||||||
│ │ │ │ ├── Schedule.jsx
|
|
||||||
│ │ │ │ └── ScheduleDetail.jsx
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── common/
|
|
||||||
│ │ │ │ ├── pc/
|
|
||||||
│ │ │ │ │ └── NotFound.jsx
|
|
||||||
│ │ │ │ └── mobile/
|
|
||||||
│ │ │ │ └── NotFound.jsx
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── admin/ # 관리자 페이지 (PC 전용)
|
|
||||||
│ │ │ ├── index.js
|
|
||||||
│ │ │ ├── Login.jsx
|
|
||||||
│ │ │ ├── Dashboard.jsx
|
|
||||||
│ │ │ ├── members/
|
│ │ │ ├── members/
|
||||||
│ │ │ │ ├── List.jsx
|
│ │ │ │ └── Members.jsx
|
||||||
│ │ │ │ └── Edit.jsx
|
│ │ │ ├── album/
|
||||||
│ │ │ ├── albums/
|
│ │ │ │ ├── Album.jsx
|
||||||
│ │ │ │ ├── List.jsx
|
│ │ │ │ ├── AlbumDetail.jsx
|
||||||
│ │ │ │ ├── Form.jsx
|
│ │ │ │ ├── AlbumGallery.jsx
|
||||||
│ │ │ │ └── Photos.jsx
|
│ │ │ │ └── TrackDetail.jsx
|
||||||
│ │ │ ├── schedules/
|
│ │ │ ├── schedule/
|
||||||
│ │ │ │ ├── List.jsx
|
│ │ │ │ ├── Schedule.jsx
|
||||||
│ │ │ │ ├── Form.jsx
|
│ │ │ │ └── ScheduleDetail.jsx
|
||||||
│ │ │ │ ├── YouTubeForm.jsx
|
│ │ │ └── common/
|
||||||
│ │ │ │ ├── XForm.jsx
|
│ │ │ └── NotFound.jsx
|
||||||
│ │ │ │ └── YouTubeEditForm.jsx
|
|
||||||
│ │ │ ├── categories/
|
|
||||||
│ │ │ │ └── List.jsx
|
|
||||||
│ │ │ ├── bots/
|
|
||||||
│ │ │ │ └── Manager.jsx
|
|
||||||
│ │ │ └── dict/
|
|
||||||
│ │ │ └── Manager.jsx
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── App.jsx # BrowserView/MobileView 라우팅
|
│ │ ├── App.jsx # 라우팅 (PC/모바일 분기)
|
||||||
│ │ └── main.jsx
|
│ │ └── main.jsx
|
||||||
│ │
|
│ │
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,8 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
```
|
```
|
||||||
src/api/
|
src/api/
|
||||||
├── index.js # fetchApi 유틸 (에러 처리, 토큰 주입)
|
├── index.js # 전체 export
|
||||||
|
├── client.js # fetchApi, fetchAuthApi (에러 처리, 토큰 주입)
|
||||||
├── public/ # 공개 API (인증 불필요)
|
├── public/ # 공개 API (인증 불필요)
|
||||||
│ ├── albums.js # getAlbums, getAlbumByName, getTrack
|
│ ├── albums.js # getAlbums, getAlbumByName, getTrack
|
||||||
│ ├── members.js # getMembers
|
│ ├── members.js # getMembers
|
||||||
|
|
@ -179,7 +180,7 @@ src/api/
|
||||||
├── auth.js # login, verifyToken
|
├── auth.js # login, verifyToken
|
||||||
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
|
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
|
||||||
├── bots.js # getBots, startBot, stopBot, syncBot
|
├── bots.js # getBots, startBot, stopBot, syncBot
|
||||||
├── categories.js # getCategories
|
├── categories.js # getCategories, createCategory, updateCategory, ...
|
||||||
├── members.js # updateMember
|
├── members.js # updateMember
|
||||||
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
|
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
|
||||||
├── stats.js # getStats
|
├── stats.js # getStats
|
||||||
|
|
@ -192,7 +193,7 @@ src/api/
|
||||||
import { getSchedules, getSchedule } from '@/api/public/schedules';
|
import { getSchedules, getSchedule } from '@/api/public/schedules';
|
||||||
|
|
||||||
// 관리자 API
|
// 관리자 API
|
||||||
import { getBots, startBot } from '@/api/admin/bots';
|
import * as botsApi from '@/api/admin/bots';
|
||||||
```
|
```
|
||||||
|
|
||||||
### React Query 사용 (데이터 페칭)
|
### React Query 사용 (데이터 페칭)
|
||||||
|
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
# 일정 관리 페이지 개선 계획
|
|
||||||
|
|
||||||
## 대상 파일
|
|
||||||
|
|
||||||
| 파일 | 라인 수 | 역할 |
|
|
||||||
|------|---------|------|
|
|
||||||
| Schedules.jsx | 1159 | 일정 목록/검색 |
|
|
||||||
| ScheduleForm.jsx | 765 | 일정 추가/수정 폼 |
|
|
||||||
| ScheduleDict.jsx | 572 | 사전 관리 |
|
|
||||||
| ScheduleBots.jsx | 570 | 봇 관리 |
|
|
||||||
| ScheduleCategory.jsx | 466 | 카테고리 관리 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 공통 코드 중복 문제
|
|
||||||
|
|
||||||
### 1.1 colorMap / getColorStyle 중복
|
|
||||||
|
|
||||||
**현황:** 3개 파일에서 동일한 코드 반복
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Schedules.jsx:206-224
|
|
||||||
// ScheduleForm.jsx:97-117
|
|
||||||
// ScheduleCategory.jsx:24-36
|
|
||||||
const colorMap = {
|
|
||||||
blue: 'bg-blue-500',
|
|
||||||
green: 'bg-green-500',
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColorStyle = (color) => {
|
|
||||||
if (!color) return { className: 'bg-gray-500' };
|
|
||||||
if (color.startsWith('#')) {
|
|
||||||
return { style: { backgroundColor: color } };
|
|
||||||
}
|
|
||||||
return { className: colorMap[color] || 'bg-gray-500' };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:**
|
|
||||||
```
|
|
||||||
utils/color.js 생성
|
|
||||||
├── COLOR_MAP (상수)
|
|
||||||
├── COLOR_OPTIONS (ScheduleCategory에서 사용하는 색상 옵션)
|
|
||||||
└── getColorStyle(color) (함수)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 colorOptions 상수
|
|
||||||
|
|
||||||
**현황:** ScheduleCategory.jsx에만 있지만 확장성 고려
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ScheduleCategory.jsx:13-22
|
|
||||||
const colorOptions = [
|
|
||||||
{ id: 'blue', name: '파란색', bg: 'bg-blue-500', hex: '#3b82f6' },
|
|
||||||
// ...
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:** `constants/colors.js` 또는 `utils/color.js`에 통합
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 파일별 개선 사항
|
|
||||||
|
|
||||||
### 2.1 Schedules.jsx (1159줄)
|
|
||||||
|
|
||||||
#### 검색 관련 상태/로직 복잡
|
|
||||||
|
|
||||||
**현황:** 검색 관련 상태가 10개 이상, useEffect 5개
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 검색 관련 상태 (55-65줄)
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
|
||||||
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
|
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
|
||||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:** `useScheduleSearch` 커스텀 훅 분리
|
|
||||||
```javascript
|
|
||||||
// hooks/pc/admin/useScheduleSearch.js
|
|
||||||
export function useScheduleSearch() {
|
|
||||||
// 검색 상태 및 로직 캡슐화
|
|
||||||
return {
|
|
||||||
searchInput, setSearchInput,
|
|
||||||
suggestions, isLoadingSuggestions,
|
|
||||||
handleSearch, handleSuggestionSelect,
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 달력 로직 분리 가능
|
|
||||||
|
|
||||||
**현황:** 달력 관련 계산이 컴포넌트 내부에 산재
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 161-181줄
|
|
||||||
const year = currentDate.getFullYear();
|
|
||||||
const month = currentDate.getMonth();
|
|
||||||
const firstDay = new Date(year, month, 1).getDay();
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:** 기존 `utils/date.js`에 달력 헬퍼 함수 추가 또는 `useCalendar` 훅 생성
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.2 ScheduleForm.jsx (765줄)
|
|
||||||
|
|
||||||
#### fetchSchedule 함수 중복 설정
|
|
||||||
|
|
||||||
**현황:** formData를 두 번 설정 (비효율)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 140-158줄: 첫 번째 setFormData
|
|
||||||
setFormData({
|
|
||||||
title: data.title || '',
|
|
||||||
startDate: data.date ? formatDate(data.date) : '',
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
|
|
||||||
// 163-184줄: 두 번째 setFormData (기존 이미지 처리 시)
|
|
||||||
if (data.images && data.images.length > 0) {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
title: data.title || '', // 중복!
|
|
||||||
// ...
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:** 하나의 setFormData로 통합
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const initialFormData = {
|
|
||||||
title: data.title || '',
|
|
||||||
startDate: data.date ? formatDate(data.date) : '',
|
|
||||||
// ...
|
|
||||||
images: data.images?.map((img) => ({ id: img.id, url: img.image_url })) || [],
|
|
||||||
};
|
|
||||||
setFormData(initialFormData);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.3 ScheduleDict.jsx (572줄)
|
|
||||||
|
|
||||||
#### generateId 일관성
|
|
||||||
|
|
||||||
**현황:** `generateId`를 useCallback으로 정의했지만, `parseDict` 내부에서는 인라인으로 같은 로직 사용
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 113-116줄
|
|
||||||
const generateId = useCallback(
|
|
||||||
() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 128줄 (parseDict 내부)
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:** `generateId`를 외부 유틸 함수로 분리하거나, parseDict에서 generateId 참조
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.4 ScheduleBots.jsx (570줄)
|
|
||||||
|
|
||||||
#### 인라인 컴포넌트 분리
|
|
||||||
|
|
||||||
**현황:** AnimatedNumber, XIcon, MeilisearchIcon이 파일 내부에 정의
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 42-65줄
|
|
||||||
function AnimatedNumber({ value, className = '' }) { ... }
|
|
||||||
|
|
||||||
// 68-72줄
|
|
||||||
const XIcon = ({ size = 20, fill = 'currentColor' }) => ( ... );
|
|
||||||
|
|
||||||
// 75-128줄
|
|
||||||
const MeilisearchIcon = ({ size = 20 }) => ( ... );
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선안:**
|
|
||||||
```
|
|
||||||
components/common/
|
|
||||||
├── AnimatedNumber.jsx (재사용 가능한 애니메이션 숫자)
|
|
||||||
└── icons/
|
|
||||||
├── XIcon.jsx
|
|
||||||
└── MeilisearchIcon.jsx
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 봇 카드 컴포넌트 분리
|
|
||||||
|
|
||||||
**현황:** 봇 카드 렌더링이 414-559줄로 약 145줄
|
|
||||||
|
|
||||||
**개선안:**
|
|
||||||
```javascript
|
|
||||||
// components/pc/admin/bot/BotCard.jsx
|
|
||||||
function BotCard({ bot, onToggle, onSync, syncing }) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2.5 ScheduleCategory.jsx (466줄)
|
|
||||||
|
|
||||||
#### 모달 컴포넌트 인라인
|
|
||||||
|
|
||||||
**현황:** 카테고리 추가/수정 모달이 284-445줄로 약 160줄
|
|
||||||
|
|
||||||
**개선안:**
|
|
||||||
```javascript
|
|
||||||
// components/pc/admin/schedule/CategoryFormModal.jsx
|
|
||||||
function CategoryFormModal({ isOpen, onClose, category, onSave }) {
|
|
||||||
// 색상 선택, 폼 로직 포함
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 개선 우선순위
|
|
||||||
|
|
||||||
### Phase 1: 중복 코드 제거 (빠른 효과) ✅ 완료
|
|
||||||
1. [x] `utils/color.js` 생성 - COLOR_MAP, COLOR_OPTIONS, getColorStyle 통합
|
|
||||||
2. [x] 3개 파일에서 import로 교체
|
|
||||||
- Schedules.jsx: 1159줄 → 1139줄 (20줄 감소)
|
|
||||||
- ScheduleForm.jsx: 765줄 → 743줄 (22줄 감소)
|
|
||||||
- ScheduleCategory.jsx: 466줄 → 441줄 (25줄 감소)
|
|
||||||
|
|
||||||
### Phase 2: 커스텀 훅 분리 (복잡도 감소) ✅ 완료
|
|
||||||
1. [x] `useScheduleSearch` 훅 생성 - Schedules.jsx 검색 로직 분리
|
|
||||||
- 검색어 자동완성, 무한 스크롤, 키보드 네비게이션 캡슐화
|
|
||||||
- Schedules.jsx: 1139줄 → 1009줄 (130줄 감소)
|
|
||||||
2. [ ] 달력 관련 로직 정리 (선택사항, 현재 규모 적절)
|
|
||||||
|
|
||||||
### Phase 3: 컴포넌트 분리 (재사용성) ✅ 완료
|
|
||||||
1. [x] `AnimatedNumber` 공통 컴포넌트화 → components/common/AnimatedNumber.jsx (32줄)
|
|
||||||
2. [x] `BotCard` 컴포넌트 분리 → components/pc/admin/bot/BotCard.jsx (233줄)
|
|
||||||
3. [x] `CategoryFormModal` 컴포넌트 분리 → components/pc/admin/schedule/CategoryFormModal.jsx (195줄)
|
|
||||||
4. [x] SVG 아이콘 분리 (XIcon, MeilisearchIcon) → BotCard.jsx에 포함
|
|
||||||
- ScheduleBots.jsx: 570줄 → 339줄 (231줄 감소)
|
|
||||||
- ScheduleCategory.jsx: 441줄 → 289줄 (152줄 감소)
|
|
||||||
|
|
||||||
### Phase 4: 코드 정리
|
|
||||||
1. [ ] ScheduleForm.jsx - fetchSchedule 중복 제거
|
|
||||||
2. [ ] ScheduleDict.jsx - generateId 일관성
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 개선 결과
|
|
||||||
|
|
||||||
| 파일 | 개선 전 | 개선 후 | 감소 |
|
|
||||||
|------|---------|---------|------|
|
|
||||||
| Schedules.jsx | 1159줄 | 1009줄 | 150줄 |
|
|
||||||
| ScheduleForm.jsx | 765줄 | 743줄 | 22줄 |
|
|
||||||
| ScheduleDict.jsx | 572줄 | 572줄 | - |
|
|
||||||
| ScheduleBots.jsx | 570줄 | 339줄 | 231줄 |
|
|
||||||
| ScheduleCategory.jsx | 466줄 | 289줄 | 177줄 |
|
|
||||||
| **합계** | **3532줄** | **2952줄** | **580줄** |
|
|
||||||
|
|
||||||
### 새로 생성된 파일
|
|
||||||
| 파일 | 라인 수 | 역할 |
|
|
||||||
|------|---------|------|
|
|
||||||
| utils/color.js | 35줄 | 색상 상수/유틸 |
|
|
||||||
| hooks/pc/admin/useScheduleSearch.js | 217줄 | 검색 로직 훅 |
|
|
||||||
| components/common/AnimatedNumber.jsx | 32줄 | 숫자 애니메이션 |
|
|
||||||
| components/pc/admin/bot/BotCard.jsx | 233줄 | 봇 카드 |
|
|
||||||
| components/pc/admin/schedule/CategoryFormModal.jsx | 195줄 | 카테고리 폼 모달 |
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
# 개발 모드
|
|
||||||
FROM node:20-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<title>fromis_9 - 프로미스나인</title>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
as="style"
|
|
||||||
crossorigin
|
|
||||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
2106
frontend-temp/package-lock.json
generated
2106
frontend-temp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,44 +0,0 @@
|
||||||
{
|
|
||||||
"name": "fromis9-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "2.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.28.6",
|
|
||||||
"@tanstack/react-query": "^5.90.16",
|
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
|
||||||
"canvas-confetti": "^1.9.4",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dayjs": "^1.11.19",
|
|
||||||
"framer-motion": "^11.0.8",
|
|
||||||
"lucide-react": "^0.344.0",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-calendar": "^6.0.0",
|
|
||||||
"react-colorful": "^5.6.1",
|
|
||||||
"react-device-detect": "^2.2.3",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-infinite-scroll-component": "^6.1.1",
|
|
||||||
"react-intersection-observer": "^10.0.0",
|
|
||||||
"react-ios-time-picker": "^0.2.2",
|
|
||||||
"react-linkify": "^1.0.0-alpha",
|
|
||||||
"react-photo-album": "^3.4.0",
|
|
||||||
"react-router-dom": "^6.22.3",
|
|
||||||
"react-window": "^2.2.3",
|
|
||||||
"swiper": "^12.0.3",
|
|
||||||
"zustand": "^5.0.9"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.3.3",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
|
||||||
"autoprefixer": "^10.4.22",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^3.4.18",
|
|
||||||
"vite": "^5.4.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 234 KiB |
|
|
@ -1,193 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
||||||
import { BrowserView, MobileView } from 'react-device-detect';
|
|
||||||
|
|
||||||
// 공통 컴포넌트
|
|
||||||
import { ScrollToTop } from '@/components/common';
|
|
||||||
|
|
||||||
// PC 레이아웃
|
|
||||||
import { Layout as PCLayout } from '@/components/pc/public';
|
|
||||||
|
|
||||||
// Mobile 레이아웃
|
|
||||||
import { Layout as MobileLayout } from '@/components/mobile';
|
|
||||||
|
|
||||||
// PC 공개 페이지
|
|
||||||
import PCHome from '@/pages/pc/public/home/Home';
|
|
||||||
import PCMembers from '@/pages/pc/public/members/Members';
|
|
||||||
import PCSchedule from '@/pages/pc/public/schedule/Schedule';
|
|
||||||
import PCScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
|
||||||
import PCBirthday from '@/pages/pc/public/schedule/Birthday';
|
|
||||||
import PCAlbum from '@/pages/pc/public/album/Album';
|
|
||||||
import PCAlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
|
||||||
import PCTrackDetail from '@/pages/pc/public/album/TrackDetail';
|
|
||||||
import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
|
||||||
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
|
||||||
|
|
||||||
// PC 관리자 페이지
|
|
||||||
import AdminLogin from '@/pages/pc/admin/login/Login';
|
|
||||||
import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard';
|
|
||||||
import AdminMembers from '@/pages/pc/admin/members/Members';
|
|
||||||
import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit';
|
|
||||||
import AdminAlbums from '@/pages/pc/admin/albums/Albums';
|
|
||||||
import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm';
|
|
||||||
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
|
||||||
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
|
||||||
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
|
||||||
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
|
||||||
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
|
||||||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
|
||||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
|
||||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
|
||||||
import AdminNotFound from '@/pages/pc/admin/common/NotFound';
|
|
||||||
|
|
||||||
// Mobile 페이지
|
|
||||||
import MobileHome from '@/pages/mobile/home/Home';
|
|
||||||
import MobileMembers from '@/pages/mobile/members/Members';
|
|
||||||
import MobileSchedule from '@/pages/mobile/schedule/Schedule';
|
|
||||||
import MobileScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
|
||||||
import MobileBirthday from '@/pages/mobile/schedule/Birthday';
|
|
||||||
import MobileAlbum from '@/pages/mobile/album/Album';
|
|
||||||
import MobileAlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
|
||||||
import MobileTrackDetail from '@/pages/mobile/album/TrackDetail';
|
|
||||||
import MobileAlbumGallery from '@/pages/mobile/album/AlbumGallery';
|
|
||||||
import MobileNotFound from '@/pages/mobile/common/NotFound';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PC 환경에서 body에 클래스 추가하는 래퍼
|
|
||||||
*/
|
|
||||||
function PCWrapper({ children }) {
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.classList.add('is-pc');
|
|
||||||
return () => document.body.classList.remove('is-pc');
|
|
||||||
}, []);
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 프로미스나인 팬사이트 메인 앱
|
|
||||||
* react-device-detect를 사용한 PC/Mobile 분리
|
|
||||||
*/
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
||||||
<ScrollToTop />
|
|
||||||
|
|
||||||
{/* PC 뷰 */}
|
|
||||||
<BrowserView>
|
|
||||||
<PCWrapper>
|
|
||||||
<Routes>
|
|
||||||
{/* 관리자 페이지 (자체 레이아웃 사용) */}
|
|
||||||
<Route path="/admin" element={<AdminLogin />} />
|
|
||||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
|
||||||
<Route path="/admin/members" element={<AdminMembers />} />
|
|
||||||
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
|
||||||
<Route path="/admin/albums" element={<AdminAlbums />} />
|
|
||||||
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
|
||||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
|
||||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
|
||||||
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
|
||||||
<Route path="/admin/schedule/new" element={<AdminScheduleFormPage />} />
|
|
||||||
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
|
|
||||||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
|
||||||
<Route path="/admin/schedule/:id/edit/youtube" element={<AdminYouTubeEditForm />} />
|
|
||||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
|
||||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
|
||||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
|
||||||
{/* 관리자 404 페이지 */}
|
|
||||||
<Route path="/admin/*" element={<AdminNotFound />} />
|
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
|
||||||
<Route
|
|
||||||
path="/*"
|
|
||||||
element={
|
|
||||||
<PCLayout>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<PCHome />} />
|
|
||||||
<Route path="/members" element={<PCMembers />} />
|
|
||||||
<Route path="/schedule" element={<PCSchedule />} />
|
|
||||||
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
|
||||||
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
|
||||||
<Route path="/album" element={<PCAlbum />} />
|
|
||||||
<Route path="/album/:name" element={<PCAlbumDetail />} />
|
|
||||||
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
|
|
||||||
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
|
||||||
{/* 404 페이지 */}
|
|
||||||
<Route path="*" element={<PCNotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</PCLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</PCWrapper>
|
|
||||||
</BrowserView>
|
|
||||||
|
|
||||||
{/* Mobile 뷰 */}
|
|
||||||
<MobileView>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<MobileLayout>
|
|
||||||
<MobileHome />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/members"
|
|
||||||
element={
|
|
||||||
<MobileLayout pageTitle="멤버" noShadow>
|
|
||||||
<MobileMembers />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/schedule"
|
|
||||||
element={
|
|
||||||
<MobileLayout pageTitle="일정" useCustomLayout>
|
|
||||||
<MobileSchedule />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
|
||||||
<Route path="/birthday/:memberName/:year" element={<MobileBirthday />} />
|
|
||||||
<Route
|
|
||||||
path="/album"
|
|
||||||
element={
|
|
||||||
<MobileLayout pageTitle="앨범">
|
|
||||||
<MobileAlbum />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/album/:name"
|
|
||||||
element={
|
|
||||||
<MobileLayout pageTitle="앨범">
|
|
||||||
<MobileAlbumDetail />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/album/:name/track/:trackTitle"
|
|
||||||
element={
|
|
||||||
<MobileLayout pageTitle="곡 상세">
|
|
||||||
<MobileTrackDetail />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/album/:name/gallery"
|
|
||||||
element={
|
|
||||||
<MobileLayout pageTitle="앨범">
|
|
||||||
<MobileAlbumGallery />
|
|
||||||
</MobileLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* 404 페이지 */}
|
|
||||||
<Route path="*" element={<MobileNotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</MobileView>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 앨범 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 목록 조회
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getAlbums() {
|
|
||||||
return fetchAuthApi('/albums');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 상세 조회
|
|
||||||
* @param {number} id - 앨범 ID
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function getAlbum(id) {
|
|
||||||
return fetchAuthApi(`/albums/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 생성
|
|
||||||
* @param {FormData} formData - 앨범 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function createAlbum(formData) {
|
|
||||||
return fetchFormData('/albums', formData, 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 수정
|
|
||||||
* @param {number} id - 앨범 ID
|
|
||||||
* @param {FormData} formData - 앨범 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function updateAlbum(id, formData) {
|
|
||||||
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 삭제
|
|
||||||
* @param {number} id - 앨범 ID
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function deleteAlbum(id) {
|
|
||||||
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 사진 목록 조회
|
|
||||||
* @param {number} albumId - 앨범 ID
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getAlbumPhotos(albumId) {
|
|
||||||
return fetchAuthApi(`/albums/${albumId}/photos`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 사진 업로드
|
|
||||||
* @param {number} albumId - 앨범 ID
|
|
||||||
* @param {FormData} formData - 사진 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function uploadAlbumPhotos(albumId, formData) {
|
|
||||||
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 사진 삭제
|
|
||||||
* @param {number} albumId - 앨범 ID
|
|
||||||
* @param {number} photoId - 사진 ID
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function deleteAlbumPhoto(albumId, photoId) {
|
|
||||||
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 티저 목록 조회
|
|
||||||
* @param {number} albumId - 앨범 ID
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getAlbumTeasers(albumId) {
|
|
||||||
return fetchAuthApi(`/albums/${albumId}/teasers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앨범 티저 삭제
|
|
||||||
* @param {number} albumId - 앨범 ID
|
|
||||||
* @param {number} teaserId - 티저 ID
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function deleteAlbumTeaser(albumId, teaserId) {
|
|
||||||
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 인증 API
|
|
||||||
*/
|
|
||||||
import { fetchApi, fetchAuthApi } from '@/api/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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 봇 관리 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 봇 목록 조회
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getBots() {
|
|
||||||
return fetchAuthApi('/admin/bots');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 봇 시작
|
|
||||||
* @param {string} id - 봇 ID
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function startBot(id) {
|
|
||||||
return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 봇 정지
|
|
||||||
* @param {string} id - 봇 ID
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function stopBot(id) {
|
|
||||||
return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 봇 전체 동기화
|
|
||||||
* @param {string} id - 봇 ID
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function syncAllVideos(id) {
|
|
||||||
return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 할당량 경고 조회
|
|
||||||
* @returns {Promise<{warning: boolean, message: string}>}
|
|
||||||
*/
|
|
||||||
export async function getQuotaWarning() {
|
|
||||||
return fetchAuthApi('/admin/bots/quota-warning');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 할당량 경고 해제
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function dismissQuotaWarning() {
|
|
||||||
return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 카테고리 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 목록 조회
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getCategories() {
|
|
||||||
return fetchAuthApi('/schedules/categories');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 생성
|
|
||||||
* @param {object} data - 카테고리 데이터
|
|
||||||
* @param {string} data.name - 카테고리 이름
|
|
||||||
* @param {string} data.color - 색상 코드
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function createCategory(data) {
|
|
||||||
return fetchAuthApi('/admin/schedule-categories', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 수정
|
|
||||||
* @param {number} id - 카테고리 ID
|
|
||||||
* @param {object} data - 카테고리 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function updateCategory(id, data) {
|
|
||||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 삭제
|
|
||||||
* @param {number} id - 카테고리 ID
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function deleteCategory(id) {
|
|
||||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 순서 변경
|
|
||||||
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function reorderCategories(orders) {
|
|
||||||
return fetchAuthApi('/admin/schedule-categories-order', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ orders }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 멤버 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 멤버 목록 조회
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getMembers() {
|
|
||||||
return fetchAuthApi('/members');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 멤버 상세 조회
|
|
||||||
* @param {number} id - 멤버 ID
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function getMember(id) {
|
|
||||||
return fetchAuthApi(`/members/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 멤버 수정
|
|
||||||
* @param {number} id - 멤버 ID
|
|
||||||
* @param {FormData} formData - 멤버 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function updateMember(id, formData) {
|
|
||||||
return fetchFormData(`/members/${id}`, formData, 'PUT');
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 일정 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API 응답을 프론트엔드 형식으로 변환
|
|
||||||
* - datetime → date, time 분리
|
|
||||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
|
||||||
* - members 배열 → member_names 문자열
|
|
||||||
*/
|
|
||||||
function transformSchedule(schedule) {
|
|
||||||
const category = schedule.category || {};
|
|
||||||
|
|
||||||
// datetime에서 date와 time 분리
|
|
||||||
let date = '';
|
|
||||||
let time = null;
|
|
||||||
if (schedule.datetime) {
|
|
||||||
const parts = schedule.datetime.split('T');
|
|
||||||
date = parts[0];
|
|
||||||
time = parts[1] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// members 배열을 문자열로 (기존 코드 호환성)
|
|
||||||
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
...schedule,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
category_id: category.id,
|
|
||||||
category_name: category.name,
|
|
||||||
category_color: category.color,
|
|
||||||
member_names: memberNames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일정 목록 조회 (월별)
|
|
||||||
* @param {number} year - 년도
|
|
||||||
* @param {number} month - 월
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
export async function getSchedules(year, month) {
|
|
||||||
const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`);
|
|
||||||
return (data.schedules || []).map(transformSchedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일정 검색 (Meilisearch)
|
|
||||||
* @param {string} query - 검색어
|
|
||||||
* @param {object} options - 페이지네이션 옵션
|
|
||||||
* @param {number} options.offset - 시작 위치
|
|
||||||
* @param {number} options.limit - 조회 개수
|
|
||||||
* @returns {Promise<{schedules: Array, total: number}>}
|
|
||||||
*/
|
|
||||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
|
||||||
const data = await fetchAuthApi(
|
|
||||||
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
schedules: (data.schedules || []).map(transformSchedule),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일정 상세 조회
|
|
||||||
* @param {number} id - 일정 ID
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function getSchedule(id) {
|
|
||||||
return fetchAuthApi(`/schedules/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일정 생성
|
|
||||||
* @param {FormData} formData - 일정 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function createSchedule(formData) {
|
|
||||||
return fetchFormData('/admin/schedules', formData, 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일정 수정
|
|
||||||
* @param {number} id - 일정 ID
|
|
||||||
* @param {FormData} formData - 일정 데이터
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function updateSchedule(id, formData) {
|
|
||||||
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일정 삭제
|
|
||||||
* @param {number} id - 일정 ID
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function deleteSchedule(id) {
|
|
||||||
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 통계 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 통계 조회
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
export async function getStats() {
|
|
||||||
return fetchAuthApi('/stats');
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
/**
|
|
||||||
* 관리자 추천 검색어 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사전 내용 조회
|
|
||||||
* @returns {Promise<{content: string}>}
|
|
||||||
*/
|
|
||||||
export async function getDict() {
|
|
||||||
return fetchAuthApi('/schedules/suggestions/dict');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사전 저장
|
|
||||||
* @param {string} content - 사전 내용
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function saveDict(content) {
|
|
||||||
return fetchAuthApi('/schedules/suggestions/dict', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* API 통합 export
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 공통 유틸리티
|
|
||||||
export * from './client';
|
|
||||||
|
|
||||||
// 공개 API
|
|
||||||
export * from './public';
|
|
||||||
export * as scheduleApi from './public/schedules';
|
|
||||||
export * as albumApi from './public/albums';
|
|
||||||
export * as memberApi from './public/members';
|
|
||||||
|
|
||||||
// 관리자 API
|
|
||||||
export * from './admin';
|
|
||||||
export * as authApi from './admin/auth';
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
/**
|
|
||||||
* 앨범 API
|
|
||||||
*/
|
|
||||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/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' });
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* 멤버 API
|
|
||||||
*/
|
|
||||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/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' });
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
/**
|
|
||||||
* 스케줄 API
|
|
||||||
*/
|
|
||||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
|
||||||
import { getTodayKST, dayjs } from '@/utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API 응답을 프론트엔드 형식으로 변환
|
|
||||||
* - datetime → date, time 분리
|
|
||||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
|
||||||
* - members 배열 → member_names 문자열
|
|
||||||
*/
|
|
||||||
function transformSchedule(schedule) {
|
|
||||||
const category = schedule.category || {};
|
|
||||||
|
|
||||||
// datetime에서 date와 time 분리
|
|
||||||
let date = '';
|
|
||||||
let time = null;
|
|
||||||
if (schedule.datetime) {
|
|
||||||
const dt = dayjs(schedule.datetime);
|
|
||||||
date = dt.format('YYYY-MM-DD');
|
|
||||||
// datetime에 T가 포함되어 있으면 시간이 있는 것
|
|
||||||
time = schedule.datetime.includes('T') ? dt.format('HH:mm:ss') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// members 배열을 문자열로 (기존 코드 호환성)
|
|
||||||
const memberNames = Array.isArray(schedule.members)
|
|
||||||
? schedule.members.join(',')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
...schedule,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
category_id: category.id,
|
|
||||||
category_name: category.name,
|
|
||||||
category_color: category.color,
|
|
||||||
member_names: memberNames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 공개 API ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스케줄 목록 조회 (월별)
|
|
||||||
*/
|
|
||||||
export async function getSchedules(year, month) {
|
|
||||||
const data = await fetchApi(`/schedules?year=${year}&month=${month}`);
|
|
||||||
return (data.schedules || []).map(transformSchedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 다가오는 스케줄 조회
|
|
||||||
*/
|
|
||||||
export async function getUpcomingSchedules(limit = 3) {
|
|
||||||
const today = getTodayKST();
|
|
||||||
const data = await fetchApi(`/schedules?startDate=${today}&limit=${limit}`);
|
|
||||||
return (data.schedules || []).map(transformSchedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스케줄 검색 (Meilisearch)
|
|
||||||
*/
|
|
||||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
|
||||||
const data = await fetchApi(
|
|
||||||
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
schedules: (data.schedules || []).map(transformSchedule),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스케줄 상세 조회
|
|
||||||
*/
|
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
import { useState, useEffect, useCallback, memo } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
|
||||||
import LightboxIndicator from './LightboxIndicator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이트박스 공통 컴포넌트
|
|
||||||
* 이미지/비디오 갤러리를 전체 화면으로 표시
|
|
||||||
*
|
|
||||||
* @param {string[]} images - 이미지/비디오 URL 배열
|
|
||||||
* @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적)
|
|
||||||
* @param {string} photos[].title - 컨셉 이름
|
|
||||||
* @param {string} photos[].members - 멤버 이름 (쉼표 구분)
|
|
||||||
* @param {Object[]} teasers - 티저 정보 배열 (비디오 여부 확인용)
|
|
||||||
* @param {string} teasers[].media_type - 'video' 또는 'image'
|
|
||||||
* @param {number} currentIndex - 현재 인덱스
|
|
||||||
* @param {boolean} isOpen - 열림 상태
|
|
||||||
* @param {function} onClose - 닫기 콜백
|
|
||||||
* @param {function} onIndexChange - 인덱스 변경 콜백
|
|
||||||
* @param {boolean} showCounter - 카운터 표시 여부 (기본: true)
|
|
||||||
* @param {boolean} showDownload - 다운로드 버튼 표시 여부 (기본: true)
|
|
||||||
*/
|
|
||||||
function Lightbox({
|
|
||||||
images,
|
|
||||||
photos,
|
|
||||||
teasers,
|
|
||||||
currentIndex,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onIndexChange,
|
|
||||||
showCounter = true,
|
|
||||||
showDownload = true,
|
|
||||||
}) {
|
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
|
||||||
const [slideDirection, setSlideDirection] = useState(0);
|
|
||||||
|
|
||||||
// 이전/다음 네비게이션
|
|
||||||
const goToPrev = useCallback(() => {
|
|
||||||
if (images.length <= 1) return;
|
|
||||||
setImageLoaded(false);
|
|
||||||
setSlideDirection(-1);
|
|
||||||
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
|
||||||
}, [images.length, currentIndex, onIndexChange]);
|
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
|
||||||
if (images.length <= 1) return;
|
|
||||||
setImageLoaded(false);
|
|
||||||
setSlideDirection(1);
|
|
||||||
onIndexChange((currentIndex + 1) % images.length);
|
|
||||||
}, [images.length, currentIndex, onIndexChange]);
|
|
||||||
|
|
||||||
const goToIndex = useCallback(
|
|
||||||
(index) => {
|
|
||||||
if (index === currentIndex) return;
|
|
||||||
setImageLoaded(false);
|
|
||||||
setSlideDirection(index > currentIndex ? 1 : -1);
|
|
||||||
onIndexChange(index);
|
|
||||||
},
|
|
||||||
[currentIndex, onIndexChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 이미지 다운로드
|
|
||||||
const downloadImage = useCallback(async () => {
|
|
||||||
const imageUrl = images[currentIndex];
|
|
||||||
if (!imageUrl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(imageUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `image_${currentIndex + 1}.jpg`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('이미지 다운로드 실패:', error);
|
|
||||||
}
|
|
||||||
}, [images, currentIndex]);
|
|
||||||
|
|
||||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.documentElement.style.overflow = 'hidden';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// 키보드 이벤트 핸들러
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
goToPrev();
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
goToNext();
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
onClose();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [isOpen, goToPrev, goToNext, onClose]);
|
|
||||||
|
|
||||||
// 이미지가 바뀔 때 로딩 상태 리셋
|
|
||||||
useEffect(() => {
|
|
||||||
setImageLoaded(false);
|
|
||||||
}, [currentIndex]);
|
|
||||||
|
|
||||||
// 현재 사진의 메타데이터
|
|
||||||
const currentPhoto = photos?.[currentIndex];
|
|
||||||
const photoTitle = currentPhoto?.title;
|
|
||||||
const hasValidTitle = photoTitle && photoTitle.trim() && photoTitle !== 'Default';
|
|
||||||
const photoMembers = currentPhoto?.members;
|
|
||||||
const hasMembers = photoMembers && String(photoMembers).trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && images.length > 0 && (
|
|
||||||
<motion.div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="이미지 뷰어"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{/* 내부 컨테이너 */}
|
|
||||||
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
|
||||||
{/* 카운터 */}
|
|
||||||
{showCounter && images.length > 1 && (
|
|
||||||
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
|
||||||
{currentIndex + 1} / {images.length}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 상단 버튼들 */}
|
|
||||||
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
|
||||||
{showDownload && (
|
|
||||||
<button
|
|
||||||
aria-label="다운로드"
|
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadImage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download size={28} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
aria-label="닫기"
|
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={32} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 이전 버튼 */}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<button
|
|
||||||
aria-label="이전 이미지"
|
|
||||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToPrev();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={48} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 로딩 스피너 */}
|
|
||||||
{!imageLoaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이미지/비디오 + 메타데이터 */}
|
|
||||||
<div className="flex flex-col items-center mx-24">
|
|
||||||
{teasers?.[currentIndex]?.media_type === 'video' ? (
|
|
||||||
<motion.video
|
|
||||||
key={currentIndex}
|
|
||||||
src={images[currentIndex]}
|
|
||||||
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
|
||||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onCanPlay={() => setImageLoaded(true)}
|
|
||||||
initial={{ x: slideDirection * 100 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<motion.img
|
|
||||||
key={currentIndex}
|
|
||||||
src={images[currentIndex]}
|
|
||||||
alt={`이미지 ${currentIndex + 1}`}
|
|
||||||
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
|
||||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
|
||||||
initial={{ x: slideDirection * 100 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 컨셉/멤버 정보 */}
|
|
||||||
{imageLoaded && (hasValidTitle || hasMembers) && (
|
|
||||||
<div className="mt-6 flex flex-col items-center gap-2">
|
|
||||||
{hasValidTitle && (
|
|
||||||
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
|
||||||
{photoTitle}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{hasMembers && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{String(photoMembers)
|
|
||||||
.split(',')
|
|
||||||
.map((member, idx) => (
|
|
||||||
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
|
|
||||||
{member.trim()}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 다음 버튼 */}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<button
|
|
||||||
aria-label="다음 이미지"
|
|
||||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToNext();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRight size={48} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 인디케이터 */}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<LightboxIndicator
|
|
||||||
count={images.length}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
goToIndex={goToIndex}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Lightbox;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 라이트박스 인디케이터 컴포넌트
|
|
||||||
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
|
||||||
* CSS transition 사용으로 GPU 가속
|
|
||||||
*/
|
|
||||||
const LightboxIndicator = memo(function LightboxIndicator({
|
|
||||||
count,
|
|
||||||
currentIndex,
|
|
||||||
goToIndex,
|
|
||||||
width = 200,
|
|
||||||
}) {
|
|
||||||
const halfWidth = width / 2;
|
|
||||||
const translateX = -(currentIndex * 18) + halfWidth - 6;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
|
||||||
style={{ width: `${width}px` }}
|
|
||||||
>
|
|
||||||
{/* 양옆 페이드 그라데이션 */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 pointer-events-none z-10"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 justify-center"
|
|
||||||
style={{
|
|
||||||
width: `${count * 18}px`,
|
|
||||||
transform: `translateX(${translateX}px)`,
|
|
||||||
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
aria-label={`이미지 ${i + 1}/${count}`}
|
|
||||||
aria-current={i === currentIndex ? 'true' : undefined}
|
|
||||||
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
|
||||||
i === currentIndex
|
|
||||||
? 'w-3 h-3 bg-white'
|
|
||||||
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
|
||||||
}`}
|
|
||||||
onClick={() => goToIndex(i)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default LightboxIndicator;
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* 기본 스타일 */
|
|
||||||
body {
|
|
||||||
background-color: #fafafa;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 스크롤바 스타일 */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #548360;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #456e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* View Transitions API - 앨범 커버 이미지 부드러운 전환 */
|
|
||||||
::view-transition-old(root),
|
|
||||||
::view-transition-new(root) {
|
|
||||||
animation-duration: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 앨범 커버 트랜지션 */
|
|
||||||
::view-transition-group(*) {
|
|
||||||
animation-duration: 0.4s;
|
|
||||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-old(*) {
|
|
||||||
animation: fade-out 0.3s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-new(*) {
|
|
||||||
animation: fade-in 0.3s ease-in both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 라이트박스 스크롤바 숨기기 */
|
|
||||||
.lightbox-no-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 스크롤바 숨기기 유틸리티 */
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Swiper autoHeight 지원 */
|
|
||||||
.swiper-slide {
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import App from "./App";
|
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 1000 * 60 * 5, // 5분
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
/* 모바일 전용 스타일 */
|
|
||||||
|
|
||||||
/* 모바일 html,body 스크롤 방지 */
|
|
||||||
html.mobile-layout,
|
|
||||||
html.mobile-layout body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 레이아웃 컨테이너 */
|
|
||||||
.mobile-layout-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 툴바 (기본 56px) */
|
|
||||||
.mobile-toolbar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 일정 페이지 툴바 (헤더 + 날짜 선택기) */
|
|
||||||
.mobile-toolbar-schedule {
|
|
||||||
flex-shrink: 0;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 하단 네비게이션 */
|
|
||||||
.mobile-bottom-nav {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 컨텐츠 영역 - 스크롤 가능, 스크롤바 숨김 */
|
|
||||||
.mobile-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
-ms-overflow-style: none; /* IE/Edge */
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-content::-webkit-scrollbar {
|
|
||||||
display: none; /* Chrome, Safari, Opera */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 safe-area 지원 (노치, 홈 인디케이터) */
|
|
||||||
.safe-area-bottom {
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.safe-area-top {
|
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 달력 스타일 */
|
|
||||||
.mobile-calendar-wrapper .react-calendar__navigation button:hover {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__navigation__label {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__month-view__weekdays {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday abbr {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 일요일 (빨간색) */
|
|
||||||
.mobile-calendar-wrapper
|
|
||||||
.react-calendar__month-view__weekdays__weekday:first-child {
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 토요일 (파란색) */
|
|
||||||
.mobile-calendar-wrapper
|
|
||||||
.react-calendar__month-view__weekdays__weekday:last-child {
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile:hover {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile abbr {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 이웃 달 날짜 (흐리게) */
|
|
||||||
.mobile-calendar-wrapper
|
|
||||||
.react-calendar__month-view__days__day--neighboringMonth {
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 일요일 */
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile.sunday abbr {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 토요일 */
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile.saturday abbr {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 오늘 */
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile--now abbr {
|
|
||||||
background-color: #548360;
|
|
||||||
color: white;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 선택된 날짜 */
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile--active abbr {
|
|
||||||
background-color: #548360;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile--active:enabled:hover abbr,
|
|
||||||
.mobile-calendar-wrapper .react-calendar__tile--active:enabled:focus abbr {
|
|
||||||
background-color: #456e50;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/* PC 전용 스타일 - body.is-pc 클래스가 있을 때만 적용 */
|
|
||||||
|
|
||||||
/* PC에서는 body 스크롤 숨기고 내부 영역에서만 스크롤 */
|
|
||||||
html.is-pc,
|
|
||||||
body.is-pc {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* PC 최소 너비 설정 */
|
|
||||||
body.is-pc #root {
|
|
||||||
min-width: 1440px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스케줄 페이지 상태 스토어
|
|
||||||
* 메모리 기반 - SPA 내 페이지 이동 시 유지, 새로고침 시 초기화
|
|
||||||
*/
|
|
||||||
const useScheduleStore = create((set, get) => ({
|
|
||||||
// ===== 검색 관련 =====
|
|
||||||
searchInput: '',
|
|
||||||
searchTerm: '',
|
|
||||||
isSearchMode: false,
|
|
||||||
|
|
||||||
// ===== 필터 관련 =====
|
|
||||||
selectedCategories: [],
|
|
||||||
selectedMembers: [],
|
|
||||||
|
|
||||||
// ===== 날짜 관련 =====
|
|
||||||
selectedDate: undefined, // undefined: 오늘, null: 전체, Date: 특정 날짜
|
|
||||||
currentDate: new Date(),
|
|
||||||
|
|
||||||
// ===== 뷰 관련 =====
|
|
||||||
viewMode: 'list', // 'list' | 'calendar'
|
|
||||||
scrollPosition: 0,
|
|
||||||
|
|
||||||
// ===== 검색 액션 =====
|
|
||||||
setSearchInput: (value) => set({ searchInput: value }),
|
|
||||||
setSearchTerm: (value) => set({ searchTerm: value }),
|
|
||||||
setIsSearchMode: (value) => set({ isSearchMode: value }),
|
|
||||||
|
|
||||||
startSearch: (term) => {
|
|
||||||
set({
|
|
||||||
searchTerm: term,
|
|
||||||
isSearchMode: true,
|
|
||||||
selectedDate: null, // 검색 시 날짜 필터 해제
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearSearch: () => {
|
|
||||||
set({
|
|
||||||
searchInput: '',
|
|
||||||
searchTerm: '',
|
|
||||||
isSearchMode: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 필터 액션 =====
|
|
||||||
setSelectedCategories: (value) => set({ selectedCategories: value }),
|
|
||||||
setSelectedMembers: (value) => set({ selectedMembers: value }),
|
|
||||||
|
|
||||||
toggleCategory: (categoryId) => {
|
|
||||||
const { selectedCategories } = get();
|
|
||||||
const isSelected = selectedCategories.includes(categoryId);
|
|
||||||
set({
|
|
||||||
selectedCategories: isSelected
|
|
||||||
? selectedCategories.filter((id) => id !== categoryId)
|
|
||||||
: [...selectedCategories, categoryId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleMember: (memberId) => {
|
|
||||||
const { selectedMembers } = get();
|
|
||||||
const isSelected = selectedMembers.includes(memberId);
|
|
||||||
set({
|
|
||||||
selectedMembers: isSelected
|
|
||||||
? selectedMembers.filter((id) => id !== memberId)
|
|
||||||
: [...selectedMembers, memberId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearFilters: () => {
|
|
||||||
set({
|
|
||||||
selectedCategories: [],
|
|
||||||
selectedMembers: [],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 날짜 액션 =====
|
|
||||||
setSelectedDate: (value) => set({ selectedDate: value }),
|
|
||||||
setCurrentDate: (value) => set({ currentDate: value }),
|
|
||||||
|
|
||||||
goToToday: () => {
|
|
||||||
set({
|
|
||||||
selectedDate: undefined,
|
|
||||||
currentDate: new Date(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 뷰 액션 =====
|
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
|
||||||
setScrollPosition: (value) => set({ scrollPosition: value }),
|
|
||||||
|
|
||||||
// ===== 전체 초기화 =====
|
|
||||||
reset: () =>
|
|
||||||
set({
|
|
||||||
searchInput: '',
|
|
||||||
searchTerm: '',
|
|
||||||
isSearchMode: false,
|
|
||||||
selectedCategories: [],
|
|
||||||
selectedMembers: [],
|
|
||||||
selectedDate: undefined,
|
|
||||||
currentDate: new Date(),
|
|
||||||
viewMode: 'list',
|
|
||||||
scrollPosition: 0,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default useScheduleStore;
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
/**
|
|
||||||
* 날짜 관련 유틸리티 함수
|
|
||||||
* dayjs를 사용하여 KST(한국 표준시) 기준으로 날짜 처리
|
|
||||||
*/
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import utc from 'dayjs/plugin/utc';
|
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
|
||||||
import { TIMEZONE, WEEKDAYS } from '@/constants';
|
|
||||||
|
|
||||||
// 플러그인 확장
|
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KST 기준 오늘 날짜 (YYYY-MM-DD)
|
|
||||||
* @returns {string} 오늘 날짜 문자열
|
|
||||||
*/
|
|
||||||
export const getTodayKST = () => {
|
|
||||||
return dayjs().tz(TIMEZONE).format('YYYY-MM-DD');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 문자열 포맷팅
|
|
||||||
* @param {string|Date} date - 날짜
|
|
||||||
* @param {string} format - 포맷 (기본: 'YYYY-MM-DD')
|
|
||||||
* @returns {string} 포맷된 날짜 문자열
|
|
||||||
*/
|
|
||||||
export const formatDate = (date, format = 'YYYY-MM-DD') => {
|
|
||||||
if (!date) return '';
|
|
||||||
return dayjs(date).tz(TIMEZONE).format(format);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 두 날짜 비교 (같은 날인지)
|
|
||||||
* @param {string|Date} date1
|
|
||||||
* @param {string|Date} date2
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export const isSameDay = (date1, date2) => {
|
|
||||||
return (
|
|
||||||
dayjs(date1).tz(TIMEZONE).format('YYYY-MM-DD') ===
|
|
||||||
dayjs(date2).tz(TIMEZONE).format('YYYY-MM-DD')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜가 오늘인지 확인
|
|
||||||
* @param {string|Date} date
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export const isToday = (date) => {
|
|
||||||
return isSameDay(date, dayjs());
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 날짜 포맷 (YYYY. M. D. (요일))
|
|
||||||
* @param {string|Date} date - 날짜
|
|
||||||
* @returns {string} 포맷된 문자열
|
|
||||||
*/
|
|
||||||
export const formatFullDate = (date) => {
|
|
||||||
if (!date) return '';
|
|
||||||
const d = dayjs(date).tz(TIMEZONE);
|
|
||||||
return `${d.year()}. ${d.month() + 1}. ${d.date()}. (${WEEKDAYS[d.day()]})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* X(트위터) 스타일 날짜/시간 포맷팅
|
|
||||||
* @param {string} datetime - datetime 문자열 (YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD)
|
|
||||||
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
|
|
||||||
*/
|
|
||||||
export const formatXDateTime = (datetime) => {
|
|
||||||
if (!datetime) return '';
|
|
||||||
|
|
||||||
const d = dayjs(datetime).tz(TIMEZONE);
|
|
||||||
const datePart = `${d.year()}년 ${d.month() + 1}월 ${d.date()}일`;
|
|
||||||
|
|
||||||
// datetime에 T가 포함되고 시간이 00:00:00이 아니면 시간 표시
|
|
||||||
if (datetime.includes('T') && !datetime.endsWith('T00:00:00')) {
|
|
||||||
const hours = d.hour();
|
|
||||||
const minutes = d.minute();
|
|
||||||
// 00:00인 경우 시간 표시 안함
|
|
||||||
if (hours !== 0 || minutes !== 0) {
|
|
||||||
const period = hours < 12 ? '오전' : '오후';
|
|
||||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
|
||||||
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return datePart;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* datetime 문자열에서 date 추출
|
|
||||||
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD"
|
|
||||||
* @returns {string} "YYYY-MM-DD"
|
|
||||||
*/
|
|
||||||
export const extractDate = (datetime) => {
|
|
||||||
if (!datetime) return '';
|
|
||||||
return datetime.split(' ')[0].split('T')[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* datetime 문자열에서 time 추출
|
|
||||||
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DDTHH:mm"
|
|
||||||
* @returns {string|null} "HH:mm" 또는 null
|
|
||||||
*/
|
|
||||||
export const extractTime = (datetime) => {
|
|
||||||
if (!datetime) return null;
|
|
||||||
if (datetime.includes(' ')) {
|
|
||||||
return datetime.split(' ')[1]?.slice(0, 5) || null;
|
|
||||||
}
|
|
||||||
if (datetime.includes('T')) {
|
|
||||||
return datetime.split('T')[1]?.slice(0, 5) || null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// dayjs 인스턴스도 export (고급 사용용)
|
|
||||||
export { dayjs };
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "#548360",
|
|
||||||
dark: "#456E50",
|
|
||||||
light: "#6A9A75",
|
|
||||||
},
|
|
||||||
secondary: "#F5F5F5",
|
|
||||||
accent: "#FFD700",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Pretendard", "Inter", "sans-serif"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: true,
|
|
||||||
port: 80,
|
|
||||||
allowedHosts: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://fromis9-backend:80",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
"/docs": {
|
|
||||||
target: "http://fromis9-backend:80",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da
|
|
||||||
|
|
@ -2,16 +2,3 @@
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
||||||
|
|
||||||
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
|
|
||||||
# FROM node:20-alpine AS builder
|
|
||||||
# WORKDIR /app
|
|
||||||
# COPY package*.json ./
|
|
||||||
# RUN npm install
|
|
||||||
# COPY . .
|
|
||||||
# RUN npm run build
|
|
||||||
#
|
|
||||||
# FROM nginx:alpine
|
|
||||||
# COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
# EXPOSE 80
|
|
||||||
# CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|
|
||||||
5312
frontend/package-lock.json
generated
5312
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,43 +1,44 @@
|
||||||
{
|
{
|
||||||
"name": "fromis9-frontend",
|
"name": "fromis9-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"dayjs": "^1.11.19",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.0.8",
|
"dayjs": "^1.11.19",
|
||||||
"lucide-react": "^0.344.0",
|
"framer-motion": "^11.0.8",
|
||||||
"react": "^18.2.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react-calendar": "^6.0.0",
|
"react": "^18.2.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-calendar": "^6.0.0",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-infinite-scroll-component": "^6.1.1",
|
"react-dom": "^18.2.0",
|
||||||
"react-intersection-observer": "^10.0.0",
|
"react-infinite-scroll-component": "^6.1.1",
|
||||||
"react-ios-time-picker": "^0.2.2",
|
"react-intersection-observer": "^10.0.0",
|
||||||
"react-linkify": "^1.0.0-alpha",
|
"react-ios-time-picker": "^0.2.2",
|
||||||
"react-photo-album": "^3.4.0",
|
"react-linkify": "^1.0.0-alpha",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-photo-album": "^3.4.0",
|
||||||
"react-window": "^2.2.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"swiper": "^12.0.3",
|
"react-window": "^2.2.3",
|
||||||
"zustand": "^5.0.9"
|
"swiper": "^12.0.3",
|
||||||
},
|
"zustand": "^5.0.9"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/react": "^18.3.3",
|
"devDependencies": {
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^18.3.3",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"postcss": "^8.5.6",
|
"autoprefixer": "^10.4.22",
|
||||||
"tailwindcss": "^3.4.18",
|
"postcss": "^8.5.6",
|
||||||
"vite": "^5.4.1"
|
"tailwindcss": "^3.4.18",
|
||||||
}
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,121 +3,191 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { BrowserView, MobileView } from 'react-device-detect';
|
import { BrowserView, MobileView } from 'react-device-detect';
|
||||||
|
|
||||||
// 공통 컴포넌트
|
// 공통 컴포넌트
|
||||||
import ScrollToTop from './components/ScrollToTop';
|
import { ScrollToTop } from '@/components/common';
|
||||||
|
|
||||||
// PC 페이지
|
// PC 레이아웃
|
||||||
import PCHome from './pages/pc/public/Home';
|
import { Layout as PCLayout } from '@/components/pc/public';
|
||||||
import PCMembers from './pages/pc/public/Members';
|
|
||||||
import PCAlbum from './pages/pc/public/Album';
|
|
||||||
import PCAlbumDetail from './pages/pc/public/AlbumDetail';
|
|
||||||
import PCAlbumGallery from './pages/pc/public/AlbumGallery';
|
|
||||||
import PCTrackDetail from './pages/pc/public/TrackDetail';
|
|
||||||
import PCSchedule from './pages/pc/public/Schedule';
|
|
||||||
import PCScheduleDetail from './pages/pc/public/ScheduleDetail';
|
|
||||||
import PCBirthday from './pages/pc/public/Birthday';
|
|
||||||
import PCNotFound from './pages/pc/public/NotFound';
|
|
||||||
|
|
||||||
// 모바일 페이지
|
// Mobile 레이아웃
|
||||||
import MobileHome from './pages/mobile/public/Home';
|
import { Layout as MobileLayout } from '@/components/mobile';
|
||||||
import MobileMembers from './pages/mobile/public/Members';
|
|
||||||
import MobileAlbum from './pages/mobile/public/Album';
|
|
||||||
import MobileAlbumDetail from './pages/mobile/public/AlbumDetail';
|
|
||||||
import MobileAlbumGallery from './pages/mobile/public/AlbumGallery';
|
|
||||||
import MobileTrackDetail from './pages/mobile/public/TrackDetail';
|
|
||||||
import MobileSchedule from './pages/mobile/public/Schedule';
|
|
||||||
import MobileScheduleDetail from './pages/mobile/public/ScheduleDetail';
|
|
||||||
import MobileNotFound from './pages/mobile/public/NotFound';
|
|
||||||
|
|
||||||
// 관리자 페이지
|
// PC 공개 페이지
|
||||||
import AdminLogin from './pages/pc/admin/AdminLogin';
|
import PCHome from '@/pages/pc/public/home/Home';
|
||||||
import AdminDashboard from './pages/pc/admin/AdminDashboard';
|
import PCMembers from '@/pages/pc/public/members/Members';
|
||||||
import AdminMembers from './pages/pc/admin/AdminMembers';
|
import PCSchedule from '@/pages/pc/public/schedule/Schedule';
|
||||||
import AdminMemberEdit from './pages/pc/admin/AdminMemberEdit';
|
import PCScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
||||||
import AdminAlbums from './pages/pc/admin/AdminAlbums';
|
import PCBirthday from '@/pages/pc/public/schedule/Birthday';
|
||||||
import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
import PCAlbum from '@/pages/pc/public/album/Album';
|
||||||
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
import PCAlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
||||||
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
import PCTrackDetail from '@/pages/pc/public/album/TrackDetail';
|
||||||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
||||||
import ScheduleFormPage from './pages/pc/admin/schedule/form';
|
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
||||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
|
||||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
|
||||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
|
||||||
import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm';
|
|
||||||
|
|
||||||
// 레이아웃
|
// PC 관리자 페이지
|
||||||
import PCLayout from './components/pc/Layout';
|
import AdminLogin from '@/pages/pc/admin/login/Login';
|
||||||
import MobileLayout from './components/mobile/Layout';
|
import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard';
|
||||||
|
import AdminMembers from '@/pages/pc/admin/members/Members';
|
||||||
|
import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit';
|
||||||
|
import AdminAlbums from '@/pages/pc/admin/albums/Albums';
|
||||||
|
import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm';
|
||||||
|
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
||||||
|
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
||||||
|
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||||
|
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||||
|
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||||
|
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||||
|
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||||
|
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||||
|
import AdminNotFound from '@/pages/pc/admin/common/NotFound';
|
||||||
|
|
||||||
// PC 환경에서 body에 클래스 추가하는 래퍼
|
// Mobile 페이지
|
||||||
|
import MobileHome from '@/pages/mobile/home/Home';
|
||||||
|
import MobileMembers from '@/pages/mobile/members/Members';
|
||||||
|
import MobileSchedule from '@/pages/mobile/schedule/Schedule';
|
||||||
|
import MobileScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
||||||
|
import MobileBirthday from '@/pages/mobile/schedule/Birthday';
|
||||||
|
import MobileAlbum from '@/pages/mobile/album/Album';
|
||||||
|
import MobileAlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
||||||
|
import MobileTrackDetail from '@/pages/mobile/album/TrackDetail';
|
||||||
|
import MobileAlbumGallery from '@/pages/mobile/album/AlbumGallery';
|
||||||
|
import MobileNotFound from '@/pages/mobile/common/NotFound';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||||
|
*/
|
||||||
function PCWrapper({ children }) {
|
function PCWrapper({ children }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.add('is-pc');
|
document.body.classList.add('is-pc');
|
||||||
return () => document.body.classList.remove('is-pc');
|
return () => document.body.classList.remove('is-pc');
|
||||||
}, []);
|
}, []);
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
|
* react-device-detect를 사용한 PC/Mobile 분리
|
||||||
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<BrowserView>
|
|
||||||
<PCWrapper>
|
|
||||||
<Routes>
|
|
||||||
{/* 관리자 페이지 (레이아웃 없음) */}
|
|
||||||
<Route path="/admin" element={<AdminLogin />} />
|
|
||||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
|
||||||
<Route path="/admin/members" element={<AdminMembers />} />
|
|
||||||
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
|
||||||
<Route path="/admin/albums" element={<AdminAlbums />} />
|
|
||||||
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
|
||||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
|
||||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
|
||||||
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
|
||||||
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
|
|
||||||
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
|
|
||||||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
|
||||||
<Route path="/admin/schedule/:id/edit/youtube" element={<YouTubeEditForm />} />
|
|
||||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
|
||||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
|
||||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
{/* PC 뷰 */}
|
||||||
<Route path="/*" element={
|
<BrowserView>
|
||||||
<PCLayout>
|
<PCWrapper>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PCHome />} />
|
{/* 관리자 페이지 (자체 레이아웃 사용) */}
|
||||||
<Route path="/members" element={<PCMembers />} />
|
<Route path="/admin" element={<AdminLogin />} />
|
||||||
<Route path="/album" element={<PCAlbum />} />
|
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||||
<Route path="/album/:name" element={<PCAlbumDetail />} />
|
<Route path="/admin/members" element={<AdminMembers />} />
|
||||||
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
||||||
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
|
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||||
<Route path="/schedule" element={<PCSchedule />} />
|
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||||
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||||
<Route path="*" element={<PCNotFound />} />
|
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
||||||
</Routes>
|
<Route path="/admin/schedule/new" element={<AdminScheduleFormPage />} />
|
||||||
</PCLayout>
|
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
|
||||||
} />
|
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||||
</Routes>
|
<Route path="/admin/schedule/:id/edit/youtube" element={<AdminYouTubeEditForm />} />
|
||||||
</PCWrapper>
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
</BrowserView>
|
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||||
<MobileView>
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
<Routes>
|
{/* 관리자 404 페이지 */}
|
||||||
<Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} />
|
<Route path="/admin/*" element={<AdminNotFound />} />
|
||||||
<Route path="/members" element={<MobileLayout pageTitle="멤버" noShadow><MobileMembers /></MobileLayout>} />
|
|
||||||
<Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} />
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
<Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} />
|
<Route
|
||||||
<Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} />
|
path="/*"
|
||||||
<Route path="/album/:name/track/:trackTitle" element={<MobileLayout pageTitle="앨범"><MobileTrackDetail /></MobileLayout>} />
|
element={
|
||||||
<Route path="/schedule" element={<MobileLayout useCustomLayout><MobileSchedule /></MobileLayout>} />
|
<PCLayout>
|
||||||
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
<Routes>
|
||||||
<Route path="*" element={<MobileNotFound />} />
|
<Route path="/" element={<PCHome />} />
|
||||||
</Routes>
|
<Route path="/members" element={<PCMembers />} />
|
||||||
</MobileView>
|
<Route path="/schedule" element={<PCSchedule />} />
|
||||||
</BrowserRouter>
|
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
||||||
);
|
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
||||||
|
<Route path="/album" element={<PCAlbum />} />
|
||||||
|
<Route path="/album/:name" element={<PCAlbumDetail />} />
|
||||||
|
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
|
||||||
|
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
||||||
|
{/* 404 페이지 */}
|
||||||
|
<Route path="*" element={<PCNotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</PCLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</PCWrapper>
|
||||||
|
</BrowserView>
|
||||||
|
|
||||||
|
{/* Mobile 뷰 */}
|
||||||
|
<MobileView>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<MobileLayout>
|
||||||
|
<MobileHome />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/members"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="멤버" noShadow>
|
||||||
|
<MobileMembers />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schedule"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="일정" useCustomLayout>
|
||||||
|
<MobileSchedule />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
||||||
|
<Route path="/birthday/:memberName/:year" element={<MobileBirthday />} />
|
||||||
|
<Route
|
||||||
|
path="/album"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="앨범">
|
||||||
|
<MobileAlbum />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album/:name"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="앨범">
|
||||||
|
<MobileAlbumDetail />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album/:name/track/:trackTitle"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="곡 상세">
|
||||||
|
<MobileTrackDetail />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album/:name/gallery"
|
||||||
|
element={
|
||||||
|
<MobileLayout pageTitle="앨범">
|
||||||
|
<MobileAlbumGallery />
|
||||||
|
</MobileLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* 404 페이지 */}
|
||||||
|
<Route path="*" element={<MobileNotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</MobileView>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,97 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 앨범 관리 API
|
* 관리자 앨범 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi, fetchAdminFormData } from "../index";
|
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
// 앨범 목록 조회
|
/**
|
||||||
|
* 앨범 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getAlbums() {
|
export async function getAlbums() {
|
||||||
return fetchAdminApi("/api/albums");
|
return fetchAuthApi('/albums');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 상세 조회
|
/**
|
||||||
|
* 앨범 상세 조회
|
||||||
|
* @param {number} id - 앨범 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function getAlbum(id) {
|
export async function getAlbum(id) {
|
||||||
return fetchAdminApi(`/api/albums/${id}`);
|
return fetchAuthApi(`/albums/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 생성
|
/**
|
||||||
|
* 앨범 생성
|
||||||
|
* @param {FormData} formData - 앨범 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function createAlbum(formData) {
|
export async function createAlbum(formData) {
|
||||||
return fetchAdminFormData("/api/albums", formData, "POST");
|
return fetchFormData('/albums', formData, 'POST');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 수정
|
/**
|
||||||
|
* 앨범 수정
|
||||||
|
* @param {number} id - 앨범 ID
|
||||||
|
* @param {FormData} formData - 앨범 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function updateAlbum(id, formData) {
|
export async function updateAlbum(id, formData) {
|
||||||
return fetchAdminFormData(`/api/albums/${id}`, formData, "PUT");
|
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 삭제
|
/**
|
||||||
|
* 앨범 삭제
|
||||||
|
* @param {number} id - 앨범 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function deleteAlbum(id) {
|
export async function deleteAlbum(id) {
|
||||||
return fetchAdminApi(`/api/albums/${id}`, { method: "DELETE" });
|
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 사진 목록 조회
|
/**
|
||||||
|
* 앨범 사진 목록 조회
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getAlbumPhotos(albumId) {
|
export async function getAlbumPhotos(albumId) {
|
||||||
return fetchAdminApi(`/api/albums/${albumId}/photos`);
|
return fetchAuthApi(`/albums/${albumId}/photos`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 사진 업로드
|
/**
|
||||||
|
* 앨범 사진 업로드
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @param {FormData} formData - 사진 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function uploadAlbumPhotos(albumId, formData) {
|
export async function uploadAlbumPhotos(albumId, formData) {
|
||||||
return fetchAdminFormData(
|
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
||||||
`/api/albums/${albumId}/photos`,
|
|
||||||
formData,
|
|
||||||
"POST"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 사진 삭제
|
/**
|
||||||
|
* 앨범 사진 삭제
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @param {number} photoId - 사진 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function deleteAlbumPhoto(albumId, photoId) {
|
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||||
return fetchAdminApi(`/api/albums/${albumId}/photos/${photoId}`, {
|
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 티저 목록 조회
|
/**
|
||||||
|
* 앨범 티저 목록 조회
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getAlbumTeasers(albumId) {
|
export async function getAlbumTeasers(albumId) {
|
||||||
return fetchAdminApi(`/api/albums/${albumId}/teasers`);
|
return fetchAuthApi(`/albums/${albumId}/teasers`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 티저 삭제
|
/**
|
||||||
|
* 앨범 티저 삭제
|
||||||
|
* @param {number} albumId - 앨범 ID
|
||||||
|
* @param {number} teaserId - 티저 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function deleteAlbumTeaser(albumId, teaserId) {
|
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||||
return fetchAdminApi(`/api/albums/${albumId}/teasers/${teaserId}`, {
|
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,37 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 인증 API
|
* 관리자 인증 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi } from "../index";
|
import { fetchApi, fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
// 토큰 검증
|
/**
|
||||||
export async function verifyToken() {
|
* 로그인
|
||||||
return fetchAdminApi("/api/auth/verify");
|
* @param {string} username - 사용자명
|
||||||
}
|
* @param {string} password - 비밀번호
|
||||||
|
* @returns {Promise<{token: string, user: object}>}
|
||||||
// 로그인
|
*/
|
||||||
export async function login(username, password) {
|
export async function login(username, password) {
|
||||||
const response = await fetch("/api/auth/login", {
|
return fetchApi('/auth/login', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || "로그인 실패");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그아웃 (로컬 스토리지 정리)
|
/**
|
||||||
export function logout() {
|
* 토큰 검증
|
||||||
localStorage.removeItem("adminToken");
|
* @returns {Promise<{valid: boolean, user: object}>}
|
||||||
localStorage.removeItem("adminUser");
|
*/
|
||||||
|
export async function verifyToken() {
|
||||||
|
return fetchAuthApi('/auth/verify');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 사용자 정보 가져오기
|
/**
|
||||||
export function getCurrentUser() {
|
* 비밀번호 변경
|
||||||
const userData = localStorage.getItem("adminUser");
|
* @param {string} currentPassword - 현재 비밀번호
|
||||||
return userData ? JSON.parse(userData) : null;
|
* @param {string} newPassword - 새 비밀번호
|
||||||
}
|
*/
|
||||||
|
export async function changePassword(currentPassword, newPassword) {
|
||||||
// 토큰 존재 여부 확인
|
return fetchAuthApi('/auth/change-password', {
|
||||||
export function hasToken() {
|
method: 'POST',
|
||||||
return !!localStorage.getItem("adminToken");
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,55 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 봇 관리 API
|
* 관리자 봇 관리 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi } from "../index";
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
// 봇 목록 조회
|
/**
|
||||||
|
* 봇 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getBots() {
|
export async function getBots() {
|
||||||
return fetchAdminApi("/api/admin/bots");
|
return fetchAuthApi('/admin/bots');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봇 시작
|
/**
|
||||||
|
* 봇 시작
|
||||||
|
* @param {string} id - 봇 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function startBot(id) {
|
export async function startBot(id) {
|
||||||
return fetchAdminApi(`/api/admin/bots/${id}/start`, { method: "POST" });
|
return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봇 정지
|
/**
|
||||||
|
* 봇 정지
|
||||||
|
* @param {string} id - 봇 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function stopBot(id) {
|
export async function stopBot(id) {
|
||||||
return fetchAdminApi(`/api/admin/bots/${id}/stop`, { method: "POST" });
|
return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봇 전체 동기화
|
/**
|
||||||
|
* 봇 전체 동기화
|
||||||
|
* @param {string} id - 봇 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function syncAllVideos(id) {
|
export async function syncAllVideos(id) {
|
||||||
return fetchAdminApi(`/api/admin/bots/${id}/sync-all`, { method: "POST" });
|
return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 할당량 경고 조회
|
/**
|
||||||
|
* 할당량 경고 조회
|
||||||
|
* @returns {Promise<{warning: boolean, message: string}>}
|
||||||
|
*/
|
||||||
export async function getQuotaWarning() {
|
export async function getQuotaWarning() {
|
||||||
return fetchAdminApi("/api/admin/bots/quota-warning");
|
return fetchAuthApi('/admin/bots/quota-warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 할당량 경고 해제
|
/**
|
||||||
|
* 할당량 경고 해제
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function dismissQuotaWarning() {
|
export async function dismissQuotaWarning() {
|
||||||
return fetchAdminApi("/api/admin/bots/quota-warning", { method: "DELETE" });
|
return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,60 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 카테고리 관리 API
|
* 관리자 카테고리 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi } from "../index";
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
// 카테고리 목록 조회
|
/**
|
||||||
|
* 카테고리 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getCategories() {
|
export async function getCategories() {
|
||||||
return fetchAdminApi("/api/schedules/categories");
|
return fetchAuthApi('/schedules/categories');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 생성
|
/**
|
||||||
|
* 카테고리 생성
|
||||||
|
* @param {object} data - 카테고리 데이터
|
||||||
|
* @param {string} data.name - 카테고리 이름
|
||||||
|
* @param {string} data.color - 색상 코드
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function createCategory(data) {
|
export async function createCategory(data) {
|
||||||
return fetchAdminApi("/api/admin/schedule-categories", {
|
return fetchAuthApi('/admin/schedule-categories', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 수정
|
/**
|
||||||
|
* 카테고리 수정
|
||||||
|
* @param {number} id - 카테고리 ID
|
||||||
|
* @param {object} data - 카테고리 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function updateCategory(id, data) {
|
export async function updateCategory(id, data) {
|
||||||
return fetchAdminApi(`/api/admin/schedule-categories/${id}`, {
|
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
||||||
method: "PUT",
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 삭제
|
/**
|
||||||
|
* 카테고리 삭제
|
||||||
|
* @param {number} id - 카테고리 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function deleteCategory(id) {
|
export async function deleteCategory(id) {
|
||||||
return fetchAdminApi(`/api/admin/schedule-categories/${id}`, {
|
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 순서 변경
|
/**
|
||||||
|
* 카테고리 순서 변경
|
||||||
|
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function reorderCategories(orders) {
|
export async function reorderCategories(orders) {
|
||||||
return fetchAdminApi("/api/admin/schedule-categories-order", {
|
return fetchAuthApi('/admin/schedule-categories-order', {
|
||||||
method: "PUT",
|
method: 'PUT',
|
||||||
body: JSON.stringify({ orders }),
|
body: JSON.stringify({ orders }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 멤버 관리 API
|
* 관리자 멤버 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi, fetchAdminFormData } from "../index";
|
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
// 멤버 목록 조회
|
/**
|
||||||
|
* 멤버 목록 조회
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getMembers() {
|
export async function getMembers() {
|
||||||
return fetchAdminApi("/api/members");
|
return fetchAuthApi('/members');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 멤버 상세 조회
|
/**
|
||||||
|
* 멤버 상세 조회
|
||||||
|
* @param {number} id - 멤버 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function getMember(id) {
|
export async function getMember(id) {
|
||||||
return fetchAdminApi(`/api/members/${id}`);
|
return fetchAuthApi(`/members/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 멤버 수정
|
/**
|
||||||
|
* 멤버 수정
|
||||||
|
* @param {number} id - 멤버 ID
|
||||||
|
* @param {FormData} formData - 멤버 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function updateMember(id, formData) {
|
export async function updateMember(id, formData) {
|
||||||
return fetchAdminFormData(`/api/members/${id}`, formData, "PUT");
|
return fetchFormData(`/members/${id}`, formData, 'PUT');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 일정 관리 API
|
* 관리자 일정 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi, fetchAdminFormData } from "../index";
|
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 응답을 프론트엔드 형식으로 변환
|
* API 응답을 프론트엔드 형식으로 변환
|
||||||
|
|
@ -22,9 +22,7 @@ function transformSchedule(schedule) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// members 배열을 문자열로 (기존 코드 호환성)
|
// members 배열을 문자열로 (기존 코드 호환성)
|
||||||
const memberNames = Array.isArray(schedule.members)
|
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
|
||||||
? schedule.members.join(',')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...schedule,
|
...schedule,
|
||||||
|
|
@ -37,16 +35,28 @@ function transformSchedule(schedule) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 목록 조회 (월별)
|
/**
|
||||||
|
* 일정 목록 조회 (월별)
|
||||||
|
* @param {number} year - 년도
|
||||||
|
* @param {number} month - 월
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
export async function getSchedules(year, month) {
|
export async function getSchedules(year, month) {
|
||||||
const data = await fetchAdminApi(`/api/schedules?year=${year}&month=${month}`);
|
const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`);
|
||||||
return (data.schedules || []).map(transformSchedule);
|
return (data.schedules || []).map(transformSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 검색 (Meilisearch)
|
/**
|
||||||
|
* 일정 검색 (Meilisearch)
|
||||||
|
* @param {string} query - 검색어
|
||||||
|
* @param {object} options - 페이지네이션 옵션
|
||||||
|
* @param {number} options.offset - 시작 위치
|
||||||
|
* @param {number} options.limit - 조회 개수
|
||||||
|
* @returns {Promise<{schedules: Array, total: number}>}
|
||||||
|
*/
|
||||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||||
const data = await fetchAdminApi(
|
const data = await fetchAuthApi(
|
||||||
`/api/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -54,22 +64,39 @@ export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 상세 조회
|
/**
|
||||||
|
* 일정 상세 조회
|
||||||
|
* @param {number} id - 일정 ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function getSchedule(id) {
|
export async function getSchedule(id) {
|
||||||
return fetchAdminApi(`/api/admin/schedules/${id}`);
|
return fetchAuthApi(`/schedules/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 생성
|
/**
|
||||||
|
* 일정 생성
|
||||||
|
* @param {FormData} formData - 일정 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function createSchedule(formData) {
|
export async function createSchedule(formData) {
|
||||||
return fetchAdminFormData("/api/admin/schedules", formData, "POST");
|
return fetchFormData('/admin/schedules', formData, 'POST');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 수정
|
/**
|
||||||
|
* 일정 수정
|
||||||
|
* @param {number} id - 일정 ID
|
||||||
|
* @param {FormData} formData - 일정 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function updateSchedule(id, formData) {
|
export async function updateSchedule(id, formData) {
|
||||||
return fetchAdminFormData(`/api/admin/schedules/${id}`, formData, "PUT");
|
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 삭제
|
/**
|
||||||
|
* 일정 삭제
|
||||||
|
* @param {number} id - 일정 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function deleteSchedule(id) {
|
export async function deleteSchedule(id) {
|
||||||
return fetchAdminApi(`/api/schedules/${id}`, { method: "DELETE" });
|
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 통계 API
|
* 관리자 통계 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi } from "../index";
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
// 대시보드 통계 조회
|
/**
|
||||||
|
* 대시보드 통계 조회
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
export async function getStats() {
|
export async function getStats() {
|
||||||
return fetchAdminApi("/api/stats");
|
return fetchAuthApi('/stats');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
/**
|
/**
|
||||||
* 어드민 추천 검색어 API
|
* 관리자 추천 검색어 API
|
||||||
*/
|
*/
|
||||||
import { fetchAdminApi } from "../index";
|
import { fetchAuthApi } from '@/api/client';
|
||||||
|
|
||||||
// 사전 내용 조회
|
/**
|
||||||
|
* 사전 내용 조회
|
||||||
|
* @returns {Promise<{content: string}>}
|
||||||
|
*/
|
||||||
export async function getDict() {
|
export async function getDict() {
|
||||||
return fetchAdminApi("/api/schedules/suggestions/dict");
|
return fetchAuthApi('/schedules/suggestions/dict');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사전 저장
|
/**
|
||||||
|
* 사전 저장
|
||||||
|
* @param {string} content - 사전 내용
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
export async function saveDict(content) {
|
export async function saveDict(content) {
|
||||||
return fetchAdminApi("/api/schedules/suggestions/dict", {
|
return fetchAuthApi('/schedules/suggestions/dict', {
|
||||||
method: "PUT",
|
method: 'PUT',
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* 공통 API 유틸리티
|
* API 통합 export
|
||||||
* 모든 API 호출에서 사용되는 기본 fetch 래퍼
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 기본 fetch 래퍼
|
// 공통 유틸리티
|
||||||
export async function fetchApi(url, options = {}) {
|
export * from './client';
|
||||||
const headers = { ...options.headers };
|
|
||||||
|
|
||||||
// body가 있을 때만 Content-Type 설정 (DELETE 등 body 없는 요청 대응)
|
// 공개 API
|
||||||
if (options.body) {
|
export * from './public';
|
||||||
headers["Content-Type"] = "application/json";
|
export * as scheduleApi from './public/schedules';
|
||||||
}
|
export * as albumApi from './public/albums';
|
||||||
|
export * as memberApi from './public/members';
|
||||||
|
|
||||||
const response = await fetch(url, {
|
// 관리자 API
|
||||||
...options,
|
export * from './admin';
|
||||||
headers,
|
export * as authApi from './admin/auth';
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({ error: "요청 실패" }));
|
|
||||||
throw new Error(error.error || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 어드민 토큰 가져오기
|
|
||||||
export function getAdminToken() {
|
|
||||||
return localStorage.getItem("adminToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 어드민 API용 fetch 래퍼 (토큰 자동 추가)
|
|
||||||
export async function fetchAdminApi(url, options = {}) {
|
|
||||||
const token = getAdminToken();
|
|
||||||
|
|
||||||
return fetchApi(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
...options.headers,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormData 전송용 (이미지 업로드 등)
|
|
||||||
export async function fetchAdminFormData(url, formData, method = "POST") {
|
|
||||||
const token = getAdminToken();
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({ error: "요청 실패" }));
|
|
||||||
throw new Error(error.error || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,101 @@
|
||||||
/**
|
/**
|
||||||
* 앨범 관련 공개 API
|
* 앨범 API
|
||||||
*/
|
*/
|
||||||
import { fetchApi } from "../index";
|
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
// 앨범 목록 조회
|
// ==================== 공개 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앨범 목록 조회
|
||||||
|
*/
|
||||||
export async function getAlbums() {
|
export async function getAlbums() {
|
||||||
return fetchApi("/api/albums");
|
return fetchApi('/albums');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 상세 조회 (ID)
|
/**
|
||||||
|
* 앨범 상세 조회 (ID)
|
||||||
|
*/
|
||||||
export async function getAlbum(id) {
|
export async function getAlbum(id) {
|
||||||
return fetchApi(`/api/albums/${id}`);
|
return fetchApi(`/albums/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 상세 조회 (이름)
|
/**
|
||||||
|
* 앨범 상세 조회 (이름)
|
||||||
|
*/
|
||||||
export async function getAlbumByName(name) {
|
export async function getAlbumByName(name) {
|
||||||
return fetchApi(`/api/albums/by-name/${name}`);
|
return fetchApi(`/albums/by-name/${encodeURIComponent(name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 사진 조회
|
/**
|
||||||
|
* 앨범 사진 조회
|
||||||
|
*/
|
||||||
export async function getAlbumPhotos(albumId) {
|
export async function getAlbumPhotos(albumId) {
|
||||||
return fetchApi(`/api/albums/${albumId}/photos`);
|
return fetchApi(`/albums/${albumId}/photos`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 앨범 트랙 조회
|
/**
|
||||||
|
* 앨범 트랙 조회
|
||||||
|
*/
|
||||||
export async function getAlbumTracks(albumId) {
|
export async function getAlbumTracks(albumId) {
|
||||||
return fetchApi(`/api/albums/${albumId}/tracks`);
|
return fetchApi(`/albums/${albumId}/tracks`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트랙 상세 조회 (앨범명, 트랙명으로)
|
/**
|
||||||
|
* 트랙 상세 조회 (앨범명, 트랙명으로)
|
||||||
|
*/
|
||||||
export async function getTrack(albumName, trackTitle) {
|
export async function getTrack(albumName, trackTitle) {
|
||||||
return fetchApi(
|
return fetchApi(
|
||||||
`/api/albums/by-name/${encodeURIComponent(
|
`/albums/by-name/${encodeURIComponent(albumName)}/track/${encodeURIComponent(trackTitle)}`
|
||||||
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' });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,43 @@
|
||||||
/**
|
/**
|
||||||
* 멤버 관련 공개 API
|
* 멤버 API
|
||||||
*/
|
*/
|
||||||
import { fetchApi } from "../index";
|
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
// 멤버 목록 조회
|
// ==================== 공개 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 목록 조회
|
||||||
|
*/
|
||||||
export async function getMembers() {
|
export async function getMembers() {
|
||||||
return fetchApi("/api/members");
|
return fetchApi('/members');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 멤버 상세 조회
|
/**
|
||||||
|
* 멤버 상세 조회
|
||||||
|
*/
|
||||||
export async function getMember(id) {
|
export async function getMember(id) {
|
||||||
return fetchApi(`/api/members/${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' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* 일정 관련 공개 API
|
* 스케줄 API
|
||||||
*/
|
*/
|
||||||
import { fetchApi } from "../index";
|
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||||
import { getTodayKST } from "../../utils/date";
|
import { getTodayKST, dayjs } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 응답을 프론트엔드 형식으로 변환
|
* API 응답을 프론트엔드 형식으로 변환
|
||||||
|
|
@ -17,9 +17,10 @@ function transformSchedule(schedule) {
|
||||||
let date = '';
|
let date = '';
|
||||||
let time = null;
|
let time = null;
|
||||||
if (schedule.datetime) {
|
if (schedule.datetime) {
|
||||||
const parts = schedule.datetime.split('T');
|
const dt = dayjs(schedule.datetime);
|
||||||
date = parts[0];
|
date = dt.format('YYYY-MM-DD');
|
||||||
time = parts[1] || null;
|
// datetime에 T가 포함되어 있으면 시간이 있는 것
|
||||||
|
time = schedule.datetime.includes('T') ? dt.format('HH:mm:ss') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// members 배열을 문자열로 (기존 코드 호환성)
|
// members 배열을 문자열로 (기존 코드 호환성)
|
||||||
|
|
@ -38,23 +39,31 @@ function transformSchedule(schedule) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 목록 조회 (월별)
|
// ==================== 공개 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 목록 조회 (월별)
|
||||||
|
*/
|
||||||
export async function getSchedules(year, month) {
|
export async function getSchedules(year, month) {
|
||||||
const data = await fetchApi(`/api/schedules?year=${year}&month=${month}`);
|
const data = await fetchApi(`/schedules?year=${year}&month=${month}`);
|
||||||
return (data.schedules || []).map(transformSchedule);
|
return (data.schedules || []).map(transformSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다가오는 일정 조회 (오늘 이후)
|
/**
|
||||||
|
* 다가오는 스케줄 조회
|
||||||
|
*/
|
||||||
export async function getUpcomingSchedules(limit = 3) {
|
export async function getUpcomingSchedules(limit = 3) {
|
||||||
const todayStr = getTodayKST();
|
const today = getTodayKST();
|
||||||
const data = await fetchApi(`/api/schedules?startDate=${todayStr}&limit=${limit}`);
|
const data = await fetchApi(`/schedules?startDate=${today}&limit=${limit}`);
|
||||||
return (data.schedules || []).map(transformSchedule);
|
return (data.schedules || []).map(transformSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 검색 (Meilisearch)
|
/**
|
||||||
|
* 스케줄 검색 (Meilisearch)
|
||||||
|
*/
|
||||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||||
const data = await fetchApi(
|
const data = await fetchApi(
|
||||||
`/api/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -62,12 +71,99 @@ export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일정 상세 조회
|
/**
|
||||||
|
* 스케줄 상세 조회
|
||||||
|
*/
|
||||||
export async function getSchedule(id) {
|
export async function getSchedule(id) {
|
||||||
return fetchApi(`/api/schedules/${id}`);
|
return fetchApi(`/schedules/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// X 프로필 정보 조회
|
/**
|
||||||
|
* X 프로필 정보 조회
|
||||||
|
*/
|
||||||
export async function getXProfile(username) {
|
export async function getXProfile(username) {
|
||||||
return fetchApi(`/api/schedules/x-profile/${encodeURIComponent(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 }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
// 페이지 이동 시 스크롤을 맨 위로 이동시키는 컴포넌트
|
|
||||||
function ScrollToTop() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// window 스크롤 초기화
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
|
|
||||||
// 모바일 레이아웃 스크롤 컨테이너 초기화
|
|
||||||
const mobileContent = document.querySelector('.mobile-content');
|
|
||||||
if (mobileContent) {
|
|
||||||
mobileContent.scrollTop = 0;
|
|
||||||
}
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ScrollToTop;
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toast 컴포넌트 (Minecraft Web 스타일)
|
|
||||||
* - 하단 중앙에 표시
|
|
||||||
* - type: 'success' | 'error' | 'warning'
|
|
||||||
*/
|
|
||||||
function Toast({ toast, onClose }) {
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{toast && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 50 }}
|
|
||||||
onClick={onClose}
|
|
||||||
className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[9999] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg cursor-pointer ${
|
|
||||||
toast.type === 'error'
|
|
||||||
? 'bg-red-500/90'
|
|
||||||
: toast.type === 'warning'
|
|
||||||
? 'bg-amber-500/90'
|
|
||||||
: 'bg-emerald-500/90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{toast.message}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Toast;
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import { useState, useRef } from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 커스텀 툴팁 컴포넌트
|
|
||||||
* 마우스 커서를 따라다니는 방식
|
|
||||||
* @param {React.ReactNode} children - 툴팁을 표시할 요소
|
|
||||||
* @param {string|React.ReactNode} text - 툴팁에 표시할 내용 (content prop과 호환)
|
|
||||||
* @param {string|React.ReactNode} content - 툴팁에 표시할 내용 (text prop과 호환)
|
|
||||||
*/
|
|
||||||
const Tooltip = ({ children, text, content, className = "" }) => {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [position, setPosition] = useState({ bottom: 0, left: 0 });
|
|
||||||
const triggerRef = useRef(null);
|
|
||||||
|
|
||||||
// text 또는 content prop 사용 (문자열 또는 React 노드)
|
|
||||||
const tooltipContent = text || content;
|
|
||||||
|
|
||||||
const handleMouseEnter = (e) => {
|
|
||||||
// 마우스 커서 위치를 기준으로 툴팁 위치 설정 (커서 위로)
|
|
||||||
setPosition({
|
|
||||||
bottom: window.innerHeight - e.clientY + 10,
|
|
||||||
left: e.clientX
|
|
||||||
});
|
|
||||||
setIsVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e) => {
|
|
||||||
// 마우스 이동 시 툴팁 위치 업데이트
|
|
||||||
setPosition({
|
|
||||||
bottom: window.innerHeight - e.clientY + 10,
|
|
||||||
left: e.clientX
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={triggerRef}
|
|
||||||
className={`inline-flex items-center ${className}`}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={() => setIsVisible(false)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{isVisible && tooltipContent && ReactDOM.createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 5, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 5, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
style={{
|
|
||||||
bottom: position.bottom,
|
|
||||||
left: position.left,
|
|
||||||
}}
|
|
||||||
className="fixed z-[9999] -translate-x-1/2 px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none"
|
|
||||||
>
|
|
||||||
{tooltipContent}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Tooltip;
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/**
|
|
||||||
* AdminHeader 컴포넌트
|
|
||||||
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
|
|
||||||
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
|
|
||||||
*/
|
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
|
||||||
import { LogOut } from 'lucide-react';
|
|
||||||
|
|
||||||
function AdminHeader({ user }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem('adminToken');
|
|
||||||
localStorage.removeItem('adminUser');
|
|
||||||
navigate('/admin');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="bg-white shadow-sm border-b border-gray-100">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
|
||||||
fromis_9
|
|
||||||
</Link>
|
|
||||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-gray-500 text-sm">
|
|
||||||
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut size={18} />
|
|
||||||
<span>로그아웃</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminHeader;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/**
|
|
||||||
* AdminLayout 컴포넌트
|
|
||||||
* 모든 Admin 페이지에서 공통으로 사용하는 레이아웃
|
|
||||||
* 헤더 고정 + 본문 스크롤 구조
|
|
||||||
*/
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import AdminHeader from './AdminHeader';
|
|
||||||
|
|
||||||
function AdminLayout({ user, children }) {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// 일정 관리 페이지는 내부 스크롤 처리
|
|
||||||
const isSchedulePage = location.pathname.includes('/admin/schedules');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
|
||||||
<AdminHeader user={user} />
|
|
||||||
<main className={`flex-1 min-h-0 ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminLayout;
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* ConfirmDialog 컴포넌트
|
|
||||||
* 삭제 등 위험한 작업의 확인을 위한 공통 다이얼로그
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* - isOpen: 다이얼로그 표시 여부
|
|
||||||
* - onClose: 닫기 콜백
|
|
||||||
* - onConfirm: 확인 콜백
|
|
||||||
* - title: 제목 (예: "앨범 삭제")
|
|
||||||
* - message: 메시지 내용 (ReactNode 가능)
|
|
||||||
* - confirmText: 확인 버튼 텍스트 (기본: "삭제")
|
|
||||||
* - cancelText: 취소 버튼 텍스트 (기본: "취소")
|
|
||||||
* - loading: 로딩 상태
|
|
||||||
* - loadingText: 로딩 중 텍스트 (기본: "삭제 중...")
|
|
||||||
* - variant: 버튼 색상 (기본: "danger", "primary" 가능)
|
|
||||||
*/
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
function ConfirmDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmText = '삭제',
|
|
||||||
cancelText = '취소',
|
|
||||||
loading = false,
|
|
||||||
loadingText = '삭제 중...',
|
|
||||||
variant = 'danger',
|
|
||||||
icon: Icon = AlertTriangle
|
|
||||||
}) {
|
|
||||||
// 버튼 색상 설정
|
|
||||||
const buttonColors = {
|
|
||||||
danger: 'bg-red-500 hover:bg-red-600',
|
|
||||||
primary: 'bg-primary hover:bg-primary-dark'
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconBgColors = {
|
|
||||||
danger: 'bg-red-100',
|
|
||||||
primary: 'bg-primary/10'
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconColors = {
|
|
||||||
danger: 'text-red-500',
|
|
||||||
primary: 'text-primary'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onClick={() => !loading && onClose()}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
|
||||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className={`w-10 h-10 rounded-full ${iconBgColors[variant]} flex items-center justify-center`}>
|
|
||||||
<Icon className={iconColors[variant]} size={20} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메시지 */}
|
|
||||||
<div className="text-gray-600 mb-6">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 버튼 */}
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{cancelText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
className={`px-4 py-2 ${buttonColors[variant]} text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
{loadingText}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
{confirmText}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConfirmDialog;
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
/**
|
|
||||||
* 커스텀 데이트픽커 컴포넌트
|
|
||||||
* 연/월/일 선택이 가능한 드롭다운 형태의 날짜 선택기
|
|
||||||
*/
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
|
||||||
|
|
||||||
function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfWeek = false }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [viewMode, setViewMode] = useState('days');
|
|
||||||
const [viewDate, setViewDate] = useState(() => {
|
|
||||||
if (value) return new Date(value);
|
|
||||||
return new Date();
|
|
||||||
});
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
setViewMode('days');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const year = viewDate.getFullYear();
|
|
||||||
const month = viewDate.getMonth();
|
|
||||||
|
|
||||||
const firstDay = new Date(year, month, 1).getDay();
|
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
||||||
|
|
||||||
const days = [];
|
|
||||||
for (let i = 0; i < firstDay; i++) {
|
|
||||||
days.push(null);
|
|
||||||
}
|
|
||||||
for (let i = 1; i <= daysInMonth; i++) {
|
|
||||||
days.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_YEAR = 2025;
|
|
||||||
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
|
|
||||||
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
|
||||||
const canGoPrevYearRange = startYear > MIN_YEAR;
|
|
||||||
|
|
||||||
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
|
|
||||||
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
|
|
||||||
const prevYearRange = () => canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
|
||||||
const nextYearRange = () => setViewDate(new Date(year + 12, month, 1));
|
|
||||||
|
|
||||||
const selectDate = (day) => {
|
|
||||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
||||||
onChange(dateStr);
|
|
||||||
setIsOpen(false);
|
|
||||||
setViewMode('days');
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectYear = (y) => {
|
|
||||||
setViewDate(new Date(y, month, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectMonth = (m) => {
|
|
||||||
setViewDate(new Date(year, m, 1));
|
|
||||||
setViewMode('days');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 날짜 표시 포맷 (요일 포함 옵션)
|
|
||||||
const formatDisplayDate = (dateStr) => {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const [y, m, d] = dateStr.split('-');
|
|
||||||
if (showDayOfWeek) {
|
|
||||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
||||||
const date = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
|
|
||||||
const dayOfWeek = dayNames[date.getDay()];
|
|
||||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일 (${dayOfWeek})`;
|
|
||||||
}
|
|
||||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSelected = (day) => {
|
|
||||||
if (!value || !day) return false;
|
|
||||||
const [y, m, d] = value.split('-');
|
|
||||||
return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isToday = (day) => {
|
|
||||||
if (!day) return false;
|
|
||||||
const today = new Date();
|
|
||||||
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCurrentYear = (y) => new Date().getFullYear() === y;
|
|
||||||
const isCurrentMonth = (m) => {
|
|
||||||
const today = new Date();
|
|
||||||
return today.getFullYear() === year && today.getMonth() === m;
|
|
||||||
};
|
|
||||||
|
|
||||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
||||||
>
|
|
||||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
|
||||||
{value ? formatDisplayDate(value) : placeholder}
|
|
||||||
</span>
|
|
||||||
<Calendar size={18} className="text-gray-400" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute z-50 mt-2 bg-white border border-gray-200 rounded-xl shadow-lg p-4 w-80"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
|
|
||||||
disabled={viewMode === 'years' && !canGoPrevYearRange}
|
|
||||||
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={20} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode(viewMode === 'days' ? 'years' : 'days')}
|
|
||||||
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}년 ${month + 1}월`}
|
|
||||||
<ChevronDown size={16} className={`transition-transform ${viewMode !== 'days' ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={viewMode === 'years' ? nextYearRange : nextMonth}
|
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight size={20} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{viewMode === 'years' && (
|
|
||||||
<motion.div
|
|
||||||
key="years"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<div className="text-center text-sm text-gray-500 mb-3">년도</div>
|
|
||||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
|
||||||
{years.map((y) => (
|
|
||||||
<button
|
|
||||||
key={y}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectYear(y)}
|
|
||||||
className={`py-2 rounded-lg text-sm transition-colors ${year === y ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentYear(y) && year !== y ? 'text-primary font-medium' : ''}`}
|
|
||||||
>
|
|
||||||
{y}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-sm text-gray-500 mb-3">월</div>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{monthNames.map((m, i) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectMonth(i)}
|
|
||||||
className={`py-2 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'months' && (
|
|
||||||
<motion.div
|
|
||||||
key="months"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<div className="text-center text-sm text-gray-500 mb-3">월 선택</div>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{monthNames.map((m, i) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectMonth(i)}
|
|
||||||
className={`py-2.5 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'days' && (
|
|
||||||
<motion.div
|
|
||||||
key="days"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
||||||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
|
||||||
<div
|
|
||||||
key={d}
|
|
||||||
className={`text-center text-xs font-medium py-1 ${i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'}`}
|
|
||||||
>
|
|
||||||
{d}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
|
||||||
{days.map((day, i) => {
|
|
||||||
const dayOfWeek = i % 7;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
disabled={!day}
|
|
||||||
onClick={() => day && selectDate(day)}
|
|
||||||
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
|
||||||
${!day ? '' : 'hover:bg-gray-100'}
|
|
||||||
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
|
||||||
${isToday(day) && !isSelected(day) ? 'text-primary font-bold' : ''}
|
|
||||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
|
|
||||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
|
||||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomDatePicker;
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
/**
|
|
||||||
* CustomTimePicker 컴포넌트
|
|
||||||
* 오전/오후, 시간, 분을 선택할 수 있는 시간 피커
|
|
||||||
* NumberPicker를 사용하여 스크롤 방식 선택 제공
|
|
||||||
*/
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Clock } from 'lucide-react';
|
|
||||||
import NumberPicker from './NumberPicker';
|
|
||||||
|
|
||||||
function CustomTimePicker({ value, onChange, placeholder = "시간 선택" }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
// 현재 값 파싱
|
|
||||||
const parseValue = () => {
|
|
||||||
if (!value) return { hour: "12", minute: "00", period: "오후" };
|
|
||||||
const [h, m] = value.split(":");
|
|
||||||
const hour = parseInt(h);
|
|
||||||
const isPM = hour >= 12;
|
|
||||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
|
||||||
return {
|
|
||||||
hour: String(hour12).padStart(2, "0"),
|
|
||||||
minute: m,
|
|
||||||
period: isPM ? "오후" : "오전",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsed = parseValue();
|
|
||||||
const [selectedHour, setSelectedHour] = useState(parsed.hour);
|
|
||||||
const [selectedMinute, setSelectedMinute] = useState(parsed.minute);
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState(parsed.period);
|
|
||||||
|
|
||||||
// 외부 클릭 시 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 피커 열릴 때 현재 값으로 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
const parsed = parseValue();
|
|
||||||
setSelectedHour(parsed.hour);
|
|
||||||
setSelectedMinute(parsed.minute);
|
|
||||||
setSelectedPeriod(parsed.period);
|
|
||||||
}
|
|
||||||
}, [isOpen, value]);
|
|
||||||
|
|
||||||
// 시간 확정
|
|
||||||
const handleSave = () => {
|
|
||||||
let hour = parseInt(selectedHour);
|
|
||||||
if (selectedPeriod === "오후" && hour !== 12) hour += 12;
|
|
||||||
if (selectedPeriod === "오전" && hour === 12) hour = 0;
|
|
||||||
const timeStr = `${String(hour).padStart(2, "0")}:${selectedMinute}`;
|
|
||||||
onChange(timeStr);
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 취소
|
|
||||||
const handleCancel = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 초기화
|
|
||||||
const handleClear = () => {
|
|
||||||
onChange("");
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 표시용 포맷
|
|
||||||
const displayValue = () => {
|
|
||||||
if (!value) return placeholder;
|
|
||||||
const [h, m] = value.split(":");
|
|
||||||
const hour = parseInt(h);
|
|
||||||
const isPM = hour >= 12;
|
|
||||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
|
||||||
return `${isPM ? "오후" : "오전"} ${hour12}:${m}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 피커 아이템 데이터
|
|
||||||
const periods = ["오전", "오후"];
|
|
||||||
const hours = [
|
|
||||||
"01", "02", "03", "04", "05", "06",
|
|
||||||
"07", "08", "09", "10", "11", "12",
|
|
||||||
];
|
|
||||||
const minutes = Array.from({ length: 60 }, (_, i) =>
|
|
||||||
String(i).padStart(2, "0")
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
||||||
>
|
|
||||||
<span className={value ? "text-gray-900" : "text-gray-400"}>
|
|
||||||
{displayValue()}
|
|
||||||
</span>
|
|
||||||
<Clock size={18} className="text-gray-400" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
className="absolute top-full left-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-200 z-50 overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* 피커 영역 */}
|
|
||||||
<div className="flex items-center justify-center px-4 py-4">
|
|
||||||
{/* 오전/오후 (맨 앞) */}
|
|
||||||
<NumberPicker
|
|
||||||
items={periods}
|
|
||||||
value={selectedPeriod}
|
|
||||||
onChange={setSelectedPeriod}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 시간 */}
|
|
||||||
<NumberPicker
|
|
||||||
items={hours}
|
|
||||||
value={selectedHour}
|
|
||||||
onChange={setSelectedHour}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="text-xl text-gray-300 font-medium mx-0.5">
|
|
||||||
:
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 분 */}
|
|
||||||
<NumberPicker
|
|
||||||
items={minutes}
|
|
||||||
value={selectedMinute}
|
|
||||||
onChange={setSelectedMinute}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 버튼 */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
className="px-4 py-1.5 text-sm bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors"
|
|
||||||
>
|
|
||||||
저장
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomTimePicker;
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
/**
|
|
||||||
* NumberPicker 컴포넌트
|
|
||||||
* 스크롤 가능한 숫자/값 선택 피커
|
|
||||||
* AdminScheduleForm의 시간 선택에서 사용
|
|
||||||
*/
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
function NumberPicker({ items, value, onChange }) {
|
|
||||||
const ITEM_HEIGHT = 40;
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const offsetRef = useRef(0); // 드래그용 ref
|
|
||||||
const touchStartY = useRef(0);
|
|
||||||
const startOffset = useRef(0);
|
|
||||||
const isScrolling = useRef(false);
|
|
||||||
|
|
||||||
// offset 변경시 ref도 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
offsetRef.current = offset;
|
|
||||||
}, [offset]);
|
|
||||||
|
|
||||||
// 초기 위치 설정
|
|
||||||
useEffect(() => {
|
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
const index = items.indexOf(value);
|
|
||||||
if (index !== -1) {
|
|
||||||
const newOffset = -index * ITEM_HEIGHT;
|
|
||||||
setOffset(newOffset);
|
|
||||||
offsetRef.current = newOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 값 변경시 위치 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
const index = items.indexOf(value);
|
|
||||||
if (index !== -1) {
|
|
||||||
const targetOffset = -index * ITEM_HEIGHT;
|
|
||||||
if (Math.abs(offset - targetOffset) > 1) {
|
|
||||||
setOffset(targetOffset);
|
|
||||||
offsetRef.current = targetOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [value, items]);
|
|
||||||
|
|
||||||
const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋
|
|
||||||
|
|
||||||
// 아이템이 중앙에 있는지 확인
|
|
||||||
const isItemInCenter = (item) => {
|
|
||||||
const itemIndex = items.indexOf(item);
|
|
||||||
const itemPosition = -itemIndex * ITEM_HEIGHT;
|
|
||||||
const tolerance = ITEM_HEIGHT / 2;
|
|
||||||
return Math.abs(offset - itemPosition) < tolerance;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 오프셋 업데이트 (경계 제한)
|
|
||||||
const updateOffset = (newOffset) => {
|
|
||||||
const maxOffset = 0;
|
|
||||||
const minOffset = -(items.length - 1) * ITEM_HEIGHT;
|
|
||||||
return Math.min(maxOffset, Math.max(minOffset, newOffset));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 중앙 아이템 업데이트
|
|
||||||
const updateCenterItem = (currentOffset) => {
|
|
||||||
const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT);
|
|
||||||
if (centerIndex >= 0 && centerIndex < items.length) {
|
|
||||||
const centerItem = items[centerIndex];
|
|
||||||
if (value !== centerItem) {
|
|
||||||
onChange(centerItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 가장 가까운 아이템에 스냅
|
|
||||||
const snapToClosestItem = (currentOffset) => {
|
|
||||||
const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT;
|
|
||||||
setOffset(targetOffset);
|
|
||||||
offsetRef.current = targetOffset;
|
|
||||||
updateCenterItem(targetOffset);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 터치 시작
|
|
||||||
const handleTouchStart = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
touchStartY.current = e.touches[0].clientY;
|
|
||||||
startOffset.current = offsetRef.current;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 터치 이동
|
|
||||||
const handleTouchMove = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const touchY = e.touches[0].clientY;
|
|
||||||
const deltaY = touchY - touchStartY.current;
|
|
||||||
const newOffset = updateOffset(startOffset.current + deltaY);
|
|
||||||
setOffset(newOffset);
|
|
||||||
offsetRef.current = newOffset;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 터치 종료
|
|
||||||
const handleTouchEnd = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
snapToClosestItem(offsetRef.current);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 마우스 휠 - 바깥 스크롤 방지
|
|
||||||
const handleWheel = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isScrolling.current) return;
|
|
||||||
isScrolling.current = true;
|
|
||||||
|
|
||||||
const newOffset = updateOffset(
|
|
||||||
offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT
|
|
||||||
);
|
|
||||||
setOffset(newOffset);
|
|
||||||
offsetRef.current = newOffset;
|
|
||||||
snapToClosestItem(newOffset);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
isScrolling.current = false;
|
|
||||||
}, 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 마우스 드래그
|
|
||||||
const handleMouseDown = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
touchStartY.current = e.clientY;
|
|
||||||
startOffset.current = offsetRef.current;
|
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent) => {
|
|
||||||
moveEvent.preventDefault();
|
|
||||||
const deltaY = moveEvent.clientY - touchStartY.current;
|
|
||||||
const newOffset = updateOffset(startOffset.current + deltaY);
|
|
||||||
setOffset(newOffset);
|
|
||||||
offsetRef.current = newOffset;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
snapToClosestItem(offsetRef.current);
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
// wheel 이벤트 passive false로 등록
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (container) {
|
|
||||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
|
||||||
return () => container.removeEventListener("wheel", handleWheel);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative w-16 h-[120px] overflow-hidden touch-none select-none cursor-grab active:cursor-grabbing"
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
>
|
|
||||||
{/* 중앙 선택 영역 */}
|
|
||||||
<div className="absolute top-1/2 left-1 right-1 h-10 -translate-y-1/2 bg-primary/10 rounded-lg z-0" />
|
|
||||||
|
|
||||||
{/* 피커 내부 */}
|
|
||||||
<div
|
|
||||||
className="relative transition-transform duration-150 ease-out"
|
|
||||||
style={{ transform: `translateY(${offset + centerOffset}px)` }}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
className={`h-10 leading-10 text-center select-none transition-all duration-150 ${
|
|
||||||
isItemInCenter(item)
|
|
||||||
? "text-primary text-lg font-bold"
|
|
||||||
: "text-gray-300 text-base"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NumberPicker;
|
|
||||||
|
|
@ -1,196 +1,290 @@
|
||||||
import { useState, useEffect, useCallback, memo } from 'react';
|
import { useState, useEffect, useCallback, memo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
|
import LightboxIndicator from './LightboxIndicator';
|
||||||
|
|
||||||
// 인디케이터 컴포넌트 - CSS transition 사용으로 GPU 가속
|
/**
|
||||||
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex }) {
|
* 라이트박스 공통 컴포넌트
|
||||||
const translateX = -(currentIndex * 18) + 100 - 6;
|
* 이미지/비디오 갤러리를 전체 화면으로 표시
|
||||||
|
*
|
||||||
return (
|
* @param {string[]} images - 이미지/비디오 URL 배열
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: '200px' }}>
|
* @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적)
|
||||||
{/* 양옆 페이드 그라데이션 */}
|
* @param {string} photos[].title - 컨셉 이름
|
||||||
<div className="absolute inset-0 pointer-events-none z-10" style={{
|
* @param {string} photos[].members - 멤버 이름 (쉼표 구분)
|
||||||
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'
|
* @param {Object[]} teasers - 티저 정보 배열 (비디오 여부 확인용)
|
||||||
}} />
|
* @param {string} teasers[].media_type - 'video' 또는 'image'
|
||||||
{/* 슬라이딩 컨테이너 */}
|
* @param {number} currentIndex - 현재 인덱스
|
||||||
<div
|
* @param {boolean} isOpen - 열림 상태
|
||||||
className="flex items-center gap-2 justify-center"
|
* @param {function} onClose - 닫기 콜백
|
||||||
style={{
|
* @param {function} onIndexChange - 인덱스 변경 콜백
|
||||||
width: `${count * 18}px`,
|
* @param {boolean} showCounter - 카운터 표시 여부 (기본: true)
|
||||||
transform: `translateX(${translateX}px)`,
|
* @param {boolean} showDownload - 다운로드 버튼 표시 여부 (기본: true)
|
||||||
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
*/
|
||||||
}}
|
function Lightbox({
|
||||||
>
|
images,
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
photos,
|
||||||
<button
|
teasers,
|
||||||
key={i}
|
currentIndex,
|
||||||
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
isOpen,
|
||||||
i === currentIndex
|
onClose,
|
||||||
? 'w-3 h-3 bg-white'
|
onIndexChange,
|
||||||
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
showCounter = true,
|
||||||
}`}
|
showDownload = true,
|
||||||
onClick={() => goToIndex(i)}
|
}) {
|
||||||
/>
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
))}
|
const [slideDirection, setSlideDirection] = useState(0);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 라이트박스 공통 컴포넌트
|
// 이전/다음 네비게이션
|
||||||
function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
const goToPrev = useCallback(() => {
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
if (images.length <= 1) return;
|
||||||
const [slideDirection, setSlideDirection] = useState(0);
|
setImageLoaded(false);
|
||||||
|
setSlideDirection(-1);
|
||||||
// 이전/다음 네비게이션
|
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
||||||
const goToPrev = useCallback(() => {
|
}, [images.length, currentIndex, onIndexChange]);
|
||||||
if (images.length <= 1) return;
|
|
||||||
setImageLoaded(false);
|
|
||||||
setSlideDirection(-1);
|
|
||||||
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
|
||||||
}, [images.length, currentIndex, onIndexChange]);
|
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
const goToNext = useCallback(() => {
|
||||||
if (images.length <= 1) return;
|
if (images.length <= 1) return;
|
||||||
setImageLoaded(false);
|
setImageLoaded(false);
|
||||||
setSlideDirection(1);
|
setSlideDirection(1);
|
||||||
onIndexChange((currentIndex + 1) % images.length);
|
onIndexChange((currentIndex + 1) % images.length);
|
||||||
}, [images.length, currentIndex, onIndexChange]);
|
}, [images.length, currentIndex, onIndexChange]);
|
||||||
|
|
||||||
const goToIndex = useCallback((index) => {
|
const goToIndex = useCallback(
|
||||||
if (index === currentIndex) return;
|
(index) => {
|
||||||
setImageLoaded(false);
|
if (index === currentIndex) return;
|
||||||
setSlideDirection(index > currentIndex ? 1 : -1);
|
setImageLoaded(false);
|
||||||
onIndexChange(index);
|
setSlideDirection(index > currentIndex ? 1 : -1);
|
||||||
}, [currentIndex, onIndexChange]);
|
onIndexChange(index);
|
||||||
|
},
|
||||||
|
[currentIndex, onIndexChange]
|
||||||
|
);
|
||||||
|
|
||||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
// 이미지 다운로드
|
||||||
useEffect(() => {
|
const downloadImage = useCallback(async () => {
|
||||||
if (isOpen) {
|
const imageUrl = images[currentIndex];
|
||||||
document.documentElement.style.overflow = 'hidden';
|
if (!imageUrl) return;
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// 키보드 이벤트 핸들러
|
try {
|
||||||
useEffect(() => {
|
const response = await fetch(imageUrl);
|
||||||
if (!isOpen) return;
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `image_${currentIndex + 1}.jpg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 다운로드 실패:', error);
|
||||||
|
}
|
||||||
|
}, [images, currentIndex]);
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
// 라이트박스 열릴 때 body 스크롤 숨기기
|
||||||
switch (e.key) {
|
useEffect(() => {
|
||||||
case 'ArrowLeft':
|
if (isOpen) {
|
||||||
goToPrev();
|
document.documentElement.style.overflow = 'hidden';
|
||||||
break;
|
document.body.style.overflow = 'hidden';
|
||||||
case 'ArrowRight':
|
} else {
|
||||||
goToNext();
|
document.documentElement.style.overflow = '';
|
||||||
break;
|
document.body.style.overflow = '';
|
||||||
case 'Escape':
|
}
|
||||||
onClose();
|
return () => {
|
||||||
break;
|
document.documentElement.style.overflow = '';
|
||||||
default:
|
document.body.style.overflow = '';
|
||||||
break;
|
};
|
||||||
}
|
}, [isOpen]);
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
// 키보드 이벤트 핸들러
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
useEffect(() => {
|
||||||
}, [isOpen, goToPrev, goToNext, onClose]);
|
if (!isOpen) return;
|
||||||
|
|
||||||
// 이미지가 바뀔 때 로딩 상태 리셋
|
const handleKeyDown = (e) => {
|
||||||
useEffect(() => {
|
switch (e.key) {
|
||||||
setImageLoaded(false);
|
case 'ArrowLeft':
|
||||||
}, [currentIndex]);
|
goToPrev();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
goToNext();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
<AnimatePresence>
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
{isOpen && images.length > 0 && (
|
}, [isOpen, goToPrev, goToNext, onClose]);
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{/* 내부 컨테이너 */}
|
|
||||||
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
|
||||||
{/* 닫기 버튼 */}
|
|
||||||
<button
|
|
||||||
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X size={32} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 이전 버튼 */}
|
// 이미지가 바뀔 때 로딩 상태 리셋
|
||||||
{images.length > 1 && (
|
useEffect(() => {
|
||||||
<button
|
setImageLoaded(false);
|
||||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
}, [currentIndex]);
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToPrev();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 로딩 스피너 */}
|
// 현재 사진의 메타데이터
|
||||||
{!imageLoaded && (
|
const currentPhoto = photos?.[currentIndex];
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
const photoTitle = currentPhoto?.title;
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
const hasValidTitle = photoTitle && photoTitle.trim() && photoTitle !== 'Default';
|
||||||
</div>
|
const photoMembers = currentPhoto?.members;
|
||||||
)}
|
const hasMembers = photoMembers && String(photoMembers).trim();
|
||||||
|
|
||||||
{/* 이미지 */}
|
return (
|
||||||
<div className="flex flex-col items-center mx-24">
|
<AnimatePresence>
|
||||||
<motion.img
|
{isOpen && images.length > 0 && (
|
||||||
key={currentIndex}
|
<motion.div
|
||||||
src={images[currentIndex]}
|
role="dialog"
|
||||||
alt={`이미지 ${currentIndex + 1}`}
|
aria-modal="true"
|
||||||
className={`max-w-[90vw] max-h-[85vh] object-contain transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
aria-label="이미지 뷰어"
|
||||||
onClick={(e) => e.stopPropagation()}
|
initial={{ opacity: 0 }}
|
||||||
onLoad={() => setImageLoaded(true)}
|
animate={{ opacity: 1 }}
|
||||||
initial={{ x: slideDirection * 100 }}
|
exit={{ opacity: 0 }}
|
||||||
animate={{ x: 0 }}
|
transition={{ duration: 0.2 }}
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
||||||
/>
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
</div>
|
onClick={onClose}
|
||||||
|
>
|
||||||
{/* 다음 버튼 */}
|
{/* 내부 컨테이너 */}
|
||||||
{images.length > 1 && (
|
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
||||||
<button
|
{/* 카운터 */}
|
||||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
{showCounter && images.length > 1 && (
|
||||||
onClick={(e) => {
|
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
||||||
e.stopPropagation();
|
{currentIndex + 1} / {images.length}
|
||||||
goToNext();
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRight size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 인디케이터 */}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<LightboxIndicator
|
|
||||||
count={images.length}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
goToIndex={goToIndex}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
);
|
{/* 상단 버튼들 */}
|
||||||
|
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
||||||
|
{showDownload && (
|
||||||
|
<button
|
||||||
|
aria-label="다운로드"
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadImage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={28} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="닫기"
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={32} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이전 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
aria-label="이전 이미지"
|
||||||
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrev();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={48} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 스피너 */}
|
||||||
|
{!imageLoaded && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지/비디오 + 메타데이터 */}
|
||||||
|
<div className="flex flex-col items-center mx-24">
|
||||||
|
{teasers?.[currentIndex]?.media_type === 'video' ? (
|
||||||
|
<motion.video
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onCanPlay={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.img
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`이미지 ${currentIndex + 1}`}
|
||||||
|
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컨셉/멤버 정보 */}
|
||||||
|
{imageLoaded && (hasValidTitle || hasMembers) && (
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{hasValidTitle && (
|
||||||
|
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
||||||
|
{photoTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasMembers && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{String(photoMembers)
|
||||||
|
.split(',')
|
||||||
|
.map((member, idx) => (
|
||||||
|
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
|
||||||
|
{member.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
aria-label="다음 이미지"
|
||||||
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={48} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인디케이터 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<LightboxIndicator
|
||||||
|
count={images.length}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
goToIndex={goToIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Lightbox;
|
export default Lightbox;
|
||||||
|
|
|
||||||
|
|
@ -5,39 +5,53 @@ import { memo } from 'react';
|
||||||
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
||||||
* CSS transition 사용으로 GPU 가속
|
* CSS transition 사용으로 GPU 가속
|
||||||
*/
|
*/
|
||||||
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex, width = 200 }) {
|
const LightboxIndicator = memo(function LightboxIndicator({
|
||||||
const halfWidth = width / 2;
|
count,
|
||||||
const translateX = -(currentIndex * 18) + halfWidth - 6;
|
currentIndex,
|
||||||
|
goToIndex,
|
||||||
return (
|
width = 200,
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: `${width}px` }}>
|
}) {
|
||||||
{/* 양옆 페이드 그라데이션 */}
|
const halfWidth = width / 2;
|
||||||
<div className="absolute inset-0 pointer-events-none z-10" style={{
|
const translateX = -(currentIndex * 18) + halfWidth - 6;
|
||||||
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'
|
|
||||||
}} />
|
return (
|
||||||
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
|
<div
|
||||||
<div
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
||||||
className="flex items-center gap-2 justify-center"
|
style={{ width: `${width}px` }}
|
||||||
style={{
|
>
|
||||||
width: `${count * 18}px`,
|
{/* 양옆 페이드 그라데이션 */}
|
||||||
transform: `translateX(${translateX}px)`,
|
<div
|
||||||
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
className="absolute inset-0 pointer-events-none z-10"
|
||||||
}}
|
style={{
|
||||||
>
|
background:
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
|
||||||
<button
|
}}
|
||||||
key={i}
|
/>
|
||||||
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
|
||||||
i === currentIndex
|
<div
|
||||||
? 'w-3 h-3 bg-white'
|
className="flex items-center gap-2 justify-center"
|
||||||
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
style={{
|
||||||
}`}
|
width: `${count * 18}px`,
|
||||||
onClick={() => goToIndex(i)}
|
transform: `translateX(${translateX}px)`,
|
||||||
/>
|
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
))}
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
);
|
<button
|
||||||
|
key={i}
|
||||||
|
aria-label={`이미지 ${i + 1}/${count}`}
|
||||||
|
aria-current={i === currentIndex ? 'true' : undefined}
|
||||||
|
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
||||||
|
i === currentIndex
|
||||||
|
? 'w-3 h-3 bg-white'
|
||||||
|
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
||||||
|
}`}
|
||||||
|
onClick={() => goToIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LightboxIndicator;
|
export default LightboxIndicator;
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
|
||||||
import { Home, Users, Disc3, Calendar } from 'lucide-react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import '../../mobile.css';
|
|
||||||
|
|
||||||
// 모바일 헤더 컴포넌트
|
|
||||||
function MobileHeader({ title, noShadow = false }) {
|
|
||||||
return (
|
|
||||||
<header className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}>
|
|
||||||
<div className="flex items-center justify-center h-14 px-4">
|
|
||||||
{title ? (
|
|
||||||
<span className="text-xl font-bold text-primary">{title}</span>
|
|
||||||
) : (
|
|
||||||
<NavLink to="/" className="text-xl font-bold text-primary">
|
|
||||||
fromis_9
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모바일 하단 네비게이션
|
|
||||||
function MobileBottomNav() {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ path: '/', label: '홈', icon: Home },
|
|
||||||
{ path: '/members', label: '멤버', icon: Users },
|
|
||||||
{ path: '/album', label: '앨범', icon: Disc3 },
|
|
||||||
{ path: '/schedule', label: '일정', icon: Calendar },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex-shrink-0 bg-white border-t border-gray-200 z-50 safe-area-bottom">
|
|
||||||
<div className="flex items-center justify-around h-16">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = location.pathname === item.path ||
|
|
||||||
(item.path !== '/' && location.pathname.startsWith(item.path));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
onClick={() => window.scrollTo(0, 0)}
|
|
||||||
className={`flex flex-col items-center justify-center gap-1 w-full h-full transition-colors ${
|
|
||||||
isActive ? 'text-primary' : 'text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
|
||||||
<span className="text-xs font-medium">{item.label}</span>
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모바일 레이아웃 컴포넌트
|
|
||||||
// pageTitle: 헤더에 표시할 제목 (없으면 fromis_9)
|
|
||||||
// hideHeader: true면 헤더 숨김 (일정 페이지처럼 자체 헤더가 있는 경우)
|
|
||||||
// useCustomLayout: true면 자체 레이아웃 사용 (mobile-layout-container를 페이지에서 관리)
|
|
||||||
function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout = false, noShadow = false }) {
|
|
||||||
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.classList.add('mobile-layout');
|
|
||||||
return () => {
|
|
||||||
document.documentElement.classList.remove('mobile-layout');
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 자체 레이아웃 사용 시 (Schedule 페이지 등)
|
|
||||||
if (useCustomLayout) {
|
|
||||||
return (
|
|
||||||
<div className="mobile-layout-container bg-white">
|
|
||||||
{children}
|
|
||||||
<MobileBottomNav />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-layout-container bg-white">
|
|
||||||
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
|
||||||
<main className="mobile-content">{children}</main>
|
|
||||||
<MobileBottomNav />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MobileLayout;
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="bg-gray-900 text-white py-8">
|
|
||||||
<div className="max-w-7xl mx-auto px-6">
|
|
||||||
{/* 저작권 */}
|
|
||||||
<div className="text-center text-sm text-gray-500">
|
|
||||||
<p>© {currentYear} fromis_9 Fan Site. This is an unofficial fan-made website.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
import { Instagram, Youtube } from 'lucide-react';
|
|
||||||
import { socialLinks } from '../../data/dummy';
|
|
||||||
|
|
||||||
// X (Twitter) 아이콘 컴포넌트
|
|
||||||
const XIcon = ({ size = 20 }) => (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
function Header() {
|
|
||||||
const navItems = [
|
|
||||||
{ path: '/', label: '홈' },
|
|
||||||
{ path: '/members', label: '멤버' },
|
|
||||||
{ path: '/album', label: '앨범' },
|
|
||||||
{ path: '/schedule', label: '일정' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="bg-white shadow-sm sticky top-0 z-50">
|
|
||||||
<div className="px-24">
|
|
||||||
<div className="flex items-center justify-between h-16">
|
|
||||||
{/* 로고 */}
|
|
||||||
<NavLink to="/" className="flex items-center gap-2">
|
|
||||||
<span className="text-2xl font-bold text-primary">fromis_9</span>
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
{/* 네비게이션 */}
|
|
||||||
<nav className="flex items-center gap-8">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`text-sm font-medium transition-colors hover:text-primary ${
|
|
||||||
isActive ? 'text-primary' : 'text-gray-600'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* SNS 링크 */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<a
|
|
||||||
href={socialLinks.youtube}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-500 hover:text-red-600 transition-colors"
|
|
||||||
>
|
|
||||||
<Youtube size={20} />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={socialLinks.instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-500 hover:text-pink-600 transition-colors"
|
|
||||||
>
|
|
||||||
<Instagram size={20} />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={socialLinks.twitter}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-500 hover:text-black transition-colors"
|
|
||||||
>
|
|
||||||
<XIcon size={20} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import Header from './Header';
|
|
||||||
import Footer from './Footer';
|
|
||||||
import '../../pc.css';
|
|
||||||
|
|
||||||
function Layout({ children }) {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Footer 숨김 페이지 (화면 고정 레이아웃)
|
|
||||||
const hideFooterPages = ['/schedule', '/members', '/album'];
|
|
||||||
const hideFooter = hideFooterPages.some(path =>
|
|
||||||
location.pathname === path || location.pathname.startsWith(path + '/')
|
|
||||||
);
|
|
||||||
|
|
||||||
// 일정 페이지에서는 스크롤바도 숨김 (내부에서 자체 스크롤 처리)
|
|
||||||
const isSchedulePage = location.pathname === '/schedule';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen overflow-hidden flex flex-col">
|
|
||||||
<Header />
|
|
||||||
<main className={`flex-1 min-h-0 flex flex-col ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{!hideFooter && <Footer />}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue