Compare commits
No commits in common. "cd70fb18c9a859424c259b2c8e78c01f2fc89336" and "02fe9314e42b604bf58849916652e81ffefd5781" have entirely different histories.
cd70fb18c9
...
02fe9314e4
114 changed files with 10930 additions and 15494 deletions
2
.env
2
.env
|
|
@ -7,7 +7,7 @@ DB_NAME=fromis9
|
|||
|
||||
# Server
|
||||
PORT=80
|
||||
NODE_ENV=development
|
||||
NODE_ENV=production
|
||||
JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w
|
||||
|
||||
# RustFS (S3 Compatible)
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -25,9 +25,3 @@ redis_data/
|
|||
backend/scrape_*.cjs
|
||||
backend/scrape_*.js
|
||||
backend/scrape_*.txt
|
||||
|
||||
# Backup
|
||||
backend-backup/
|
||||
|
||||
# Kiwi 모델 파일 (용량 큼, 별도 다운로드 필요)
|
||||
backend/models/kiwi/models/
|
||||
|
|
|
|||
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -1,31 +0,0 @@
|
|||
# fromis_9 프로젝트
|
||||
|
||||
K-pop 그룹 프로미스나인 팬사이트
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Frontend**: React 18, Vite, Tailwind CSS, React Query, Zustand
|
||||
- **Backend**: Fastify, MySQL2, Meilisearch, Redis, AWS S3
|
||||
- **Infrastructure**: Docker, Caddy
|
||||
|
||||
## 개발 환경
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose logs -f fromis9-frontend
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
|
||||
- DB_HOST, DB_USER, DB_PASSWORD, DB_NAME (MariaDB)
|
||||
- RUSTFS_* (S3 호환 스토리지)
|
||||
- YOUTUBE_API_KEY
|
||||
- MEILI_MASTER_KEY (Meilisearch)
|
||||
|
||||
## 문서
|
||||
|
||||
- [docs/migration.md](docs/migration.md) - 마이그레이션 현황 및 남은 작업
|
||||
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
|
||||
- [docs/api.md](docs/api.md) - API 명세
|
||||
- [docs/development.md](docs/development.md) - 개발/배포 가이드
|
||||
48
Dockerfile
48
Dockerfile
|
|
@ -1,27 +1,27 @@
|
|||
# ============================================
|
||||
# 개발 모드
|
||||
# ============================================
|
||||
# 빌드 스테이지 - 프론트엔드 빌드
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 프로덕션 스테이지
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ffmpeg
|
||||
CMD ["sh", "-c", "cd /app/backend && npm install && cd /app/frontend && npm install --include=dev && (cd /app/backend && PORT=3000 npm run dev &) && sleep 3 && cd /app/frontend && npm run dev -- --host 0.0.0.0"]
|
||||
|
||||
# ============================================
|
||||
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
|
||||
# ============================================
|
||||
# FROM node:20-alpine AS frontend-builder
|
||||
# WORKDIR /frontend
|
||||
# COPY frontend/package*.json ./
|
||||
# RUN npm install
|
||||
# COPY frontend/ ./
|
||||
# RUN npm run build
|
||||
#
|
||||
# FROM node:20-alpine
|
||||
# WORKDIR /app
|
||||
# RUN apk add --no-cache ffmpeg
|
||||
# COPY backend/package*.json ./
|
||||
# RUN npm install --production
|
||||
# COPY backend/ ./
|
||||
# COPY --from=frontend-builder /frontend/dist ./dist
|
||||
# EXPOSE 80
|
||||
# CMD ["npm", "start"]
|
||||
# ffmpeg 설치 (비디오 썸네일 추출용)
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
# 백엔드 의존성 설치
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# 백엔드 파일 복사
|
||||
COPY backend/ ./
|
||||
|
||||
# 프론트엔드 빌드 결과물 복사
|
||||
COPY --from=frontend-builder /frontend/dist ./dist
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ library;
|
|||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/schedule.dart';
|
||||
import '../services/schedules_service.dart';
|
||||
|
||||
|
|
@ -364,89 +363,3 @@ class SuggestionController extends Notifier<SuggestionState> {
|
|||
final suggestionProvider = NotifierProvider<SuggestionController, SuggestionState>(
|
||||
SuggestionController.new,
|
||||
);
|
||||
|
||||
/// 최근 검색기록 상태
|
||||
class RecentSearchState {
|
||||
final List<String> searches;
|
||||
|
||||
const RecentSearchState({this.searches = const []});
|
||||
|
||||
RecentSearchState copyWith({List<String>? searches}) {
|
||||
return RecentSearchState(searches: searches ?? this.searches);
|
||||
}
|
||||
}
|
||||
|
||||
/// 최근 검색기록 컨트롤러
|
||||
class RecentSearchController extends Notifier<RecentSearchState> {
|
||||
static const int _maxHistory = 10;
|
||||
|
||||
@override
|
||||
RecentSearchState build() {
|
||||
_loadFromStorage();
|
||||
return const RecentSearchState();
|
||||
}
|
||||
|
||||
/// SharedPreferences에서 로드
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final searches = prefs.getStringList('recent_searches');
|
||||
if (searches != null) {
|
||||
state = state.copyWith(searches: searches);
|
||||
}
|
||||
} catch (e) {
|
||||
// 로드 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
/// 검색어 추가
|
||||
Future<void> addSearch(String query) async {
|
||||
if (query.trim().isEmpty) return;
|
||||
|
||||
final trimmed = query.trim();
|
||||
final newSearches = [
|
||||
trimmed,
|
||||
...state.searches.where((s) => s != trimmed),
|
||||
].take(_maxHistory).toList();
|
||||
|
||||
state = state.copyWith(searches: newSearches);
|
||||
|
||||
// SharedPreferences에 저장
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList('recent_searches', newSearches);
|
||||
} catch (e) {
|
||||
// 저장 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
/// 특정 검색어 삭제
|
||||
Future<void> removeSearch(String query) async {
|
||||
final newSearches = state.searches.where((s) => s != query).toList();
|
||||
state = state.copyWith(searches: newSearches);
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList('recent_searches', newSearches);
|
||||
} catch (e) {
|
||||
// 저장 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
/// 전체 삭제
|
||||
Future<void> clearAll() async {
|
||||
state = const RecentSearchState();
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList('recent_searches', []);
|
||||
} catch (e) {
|
||||
// 저장 실패 시 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 최근 검색기록 Provider
|
||||
final recentSearchProvider = NotifierProvider<RecentSearchController, RecentSearchState>(
|
||||
RecentSearchController.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import '../views/album/album_detail_view.dart';
|
|||
import '../views/album/album_gallery_view.dart';
|
||||
import '../views/album/track_detail_view.dart';
|
||||
import '../views/schedule/schedule_view.dart';
|
||||
import '../views/schedule/schedule_detail_view.dart';
|
||||
|
||||
/// 네비게이션 키
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -82,14 +81,5 @@ final GoRouter appRouter = GoRouter(
|
|||
return AlbumGalleryView(albumName: albumName);
|
||||
},
|
||||
),
|
||||
// 일정 상세 (셸 외부)
|
||||
GoRoute(
|
||||
path: '/schedule/:id',
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final scheduleId = int.parse(state.pathParameters['id']!);
|
||||
return ScheduleDetailView(scheduleId: scheduleId);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,116 +1,6 @@
|
|||
/// 일정 모델
|
||||
library;
|
||||
|
||||
/// 멤버 정보
|
||||
class ScheduleMember {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
ScheduleMember({required this.id, required this.name});
|
||||
|
||||
factory ScheduleMember.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduleMember(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 관련 일정 (콘서트 회차 등)
|
||||
class RelatedDate {
|
||||
final int id;
|
||||
final String date;
|
||||
final String? time;
|
||||
|
||||
RelatedDate({required this.id, required this.date, this.time});
|
||||
|
||||
factory RelatedDate.fromJson(Map<String, dynamic> json) {
|
||||
return RelatedDate(
|
||||
id: json['id'] as int,
|
||||
date: json['date'] as String,
|
||||
time: json['time'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 일정 상세 모델
|
||||
class ScheduleDetail {
|
||||
final int id;
|
||||
final String title;
|
||||
final String date;
|
||||
final String? time;
|
||||
final String? description;
|
||||
final int categoryId;
|
||||
final String? categoryName;
|
||||
final String? categoryColor;
|
||||
final String? sourceUrl;
|
||||
final String? sourceName;
|
||||
final String? imageUrl;
|
||||
final List<String> images;
|
||||
final String? locationName;
|
||||
final String? locationAddress;
|
||||
final String? locationLat;
|
||||
final String? locationLng;
|
||||
final List<ScheduleMember> members;
|
||||
final List<RelatedDate> relatedDates;
|
||||
|
||||
ScheduleDetail({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.date,
|
||||
this.time,
|
||||
this.description,
|
||||
required this.categoryId,
|
||||
this.categoryName,
|
||||
this.categoryColor,
|
||||
this.sourceUrl,
|
||||
this.sourceName,
|
||||
this.imageUrl,
|
||||
this.images = const [],
|
||||
this.locationName,
|
||||
this.locationAddress,
|
||||
this.locationLat,
|
||||
this.locationLng,
|
||||
this.members = const [],
|
||||
this.relatedDates = const [],
|
||||
});
|
||||
|
||||
factory ScheduleDetail.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduleDetail(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String,
|
||||
date: json['date'] as String,
|
||||
time: json['time'] as String?,
|
||||
description: json['description'] as String?,
|
||||
categoryId: json['category_id'] as int,
|
||||
categoryName: json['category_name'] as String?,
|
||||
categoryColor: json['category_color'] as String?,
|
||||
sourceUrl: json['source_url'] as String?,
|
||||
sourceName: json['source_name'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
images: (json['images'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
locationName: json['location_name'] as String?,
|
||||
locationAddress: json['location_address'] as String?,
|
||||
locationLat: json['location_lat'] as String?,
|
||||
locationLng: json['location_lng'] as String?,
|
||||
members: (json['members'] as List<dynamic>?)
|
||||
?.map((m) => ScheduleMember.fromJson(m))
|
||||
.toList() ??
|
||||
[],
|
||||
relatedDates: (json['related_dates'] as List<dynamic>?)
|
||||
?.map((r) => RelatedDate.fromJson(r))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/// 시간 포맷 (HH:mm)
|
||||
String? get formattedTime {
|
||||
if (time == null) return null;
|
||||
return time!.length >= 5 ? time!.substring(0, 5) : time;
|
||||
}
|
||||
}
|
||||
|
||||
class Schedule {
|
||||
final int id;
|
||||
final String title;
|
||||
|
|
|
|||
|
|
@ -63,12 +63,6 @@ Future<SearchResult> searchSchedules(String query, {int offset = 0, int limit =
|
|||
);
|
||||
}
|
||||
|
||||
/// 단일 일정 상세 조회
|
||||
Future<ScheduleDetail> getSchedule(int id) async {
|
||||
final response = await dio.get('/schedules/$id');
|
||||
return ScheduleDetail.fromJson(response.data);
|
||||
}
|
||||
|
||||
/// 추천 검색어 조회
|
||||
Future<List<String>> getSuggestions(String query, {int limit = 10}) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
|
|
|||
|
|
@ -208,20 +208,15 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
|
|||
itemBuilder: (context, index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
// child를 캐싱하여 매 프레임 rebuild 방지
|
||||
// RepaintBoundary로 리페인트 범위 제한
|
||||
child: RepaintBoundary(
|
||||
child: _buildMemberCard(membersState.members[index], controller),
|
||||
),
|
||||
builder: (context, child) {
|
||||
double value = 1.0;
|
||||
if (_pageController.position.haveDimensions) {
|
||||
value = (_pageController.page! - index).abs();
|
||||
value = (1 - (value * 0.25)).clamp(0.0, 1.0);
|
||||
value = (1 - (value * 0.15)).clamp(0.0, 1.0);
|
||||
}
|
||||
return Transform.scale(
|
||||
scale: Curves.easeOut.transform(value),
|
||||
child: child,
|
||||
child: _buildMemberCard(membersState.members[index], controller),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,6 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:expandable_page_view/expandable_page_view.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/schedule.dart';
|
||||
import '../../controllers/schedule_controller.dart';
|
||||
|
|
@ -61,7 +60,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
_calendarPageController = PageController(initialPage: _initialPage);
|
||||
_yearPageController = PageController(initialPage: _initialPage);
|
||||
_calendarAnimController = AnimationController(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_calendarAnimation = CurvedAnimation(
|
||||
|
|
@ -106,11 +105,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
ref.read(searchProvider.notifier).clear();
|
||||
ref.read(suggestionProvider.notifier).clear();
|
||||
_searchFocusNode.unfocus();
|
||||
// 검색 종료 시 선택된 날짜로 스크롤
|
||||
final selectedDate = ref.read(scheduleProvider).selectedDate;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToSelectedDate(selectedDate);
|
||||
});
|
||||
}
|
||||
|
||||
/// 추천 검색어 화면 표시 (유튜브 스타일)
|
||||
|
|
@ -142,7 +136,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
if (query.trim().isNotEmpty) {
|
||||
_lastSearchTerm = query; // 검색어 저장
|
||||
ref.read(searchProvider.notifier).search(query);
|
||||
ref.read(recentSearchProvider.notifier).addSearch(query); // 최근 검색기록 저장
|
||||
setState(() {
|
||||
_showSuggestions = false;
|
||||
});
|
||||
|
|
@ -182,9 +175,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
/// 달력 열기
|
||||
void _openCalendar(DateTime initialDate) {
|
||||
final today = DateTime.now();
|
||||
final monthDelta =
|
||||
(initialDate.year - today.year) * 12 +
|
||||
(initialDate.month - today.month);
|
||||
final monthDelta = (initialDate.year - today.year) * 12 + (initialDate.month - today.month);
|
||||
_yearRangeStart = (initialDate.year ~/ 12) * 12;
|
||||
|
||||
// 년도 PageView 페이지 계산
|
||||
|
|
@ -227,15 +218,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
|
||||
final dayIndex = selectedDate.day - 1;
|
||||
const itemWidth = 52.0; // 44 + 8 (gap)
|
||||
const horizontalPadding = 8.0; // ListView padding
|
||||
final targetOffset =
|
||||
(dayIndex * itemWidth) +
|
||||
horizontalPadding -
|
||||
final targetOffset = (dayIndex * itemWidth) -
|
||||
(MediaQuery.of(context).size.width / 2) +
|
||||
(itemWidth / 2);
|
||||
_dateScrollController.animateTo(
|
||||
targetOffset.clamp(0, _dateScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
|
@ -303,7 +291,10 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _isSearchMode
|
||||
? KeyedSubtree(
|
||||
|
|
@ -329,14 +320,14 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
duration: const Duration(milliseconds: 200),
|
||||
child: _isSearchMode
|
||||
? (_showSuggestions
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('suggestions'),
|
||||
child: _buildSuggestions(suggestionState),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey('search_results'),
|
||||
child: _buildSearchResults(searchState),
|
||||
))
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('suggestions'),
|
||||
child: _buildSuggestions(suggestionState),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey('search_results'),
|
||||
child: _buildSearchResults(searchState),
|
||||
))
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey('schedule_list'),
|
||||
child: _buildScheduleList(scheduleState),
|
||||
|
|
@ -359,9 +350,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
return GestureDetector(
|
||||
onTap: _closeCalendar,
|
||||
child: Container(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.4 * _calendarAnimation.value,
|
||||
),
|
||||
color: Colors.black.withValues(alpha: 0.4 * _calendarAnimation.value),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -486,28 +475,33 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
|
||||
/// 추천 검색어 빌드 (유튜브 스타일)
|
||||
Widget _buildSuggestions(SuggestionState suggestionState) {
|
||||
final recentSearchState = ref.watch(recentSearchProvider);
|
||||
|
||||
// 입력값이 없을 때 - 최근 검색기록 표시
|
||||
// 입력값이 없을 때
|
||||
if (_searchInputController.text.isEmpty) {
|
||||
if (recentSearchState.searches.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 100),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
'검색어를 입력하세요',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 100),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
'검색어를 입력하세요',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 최근 검색기록 목록
|
||||
return _buildRecentSearches(recentSearchState.searches);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 중
|
||||
if (suggestionState.isLoading && suggestionState.suggestions.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 100),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: const CircularProgressIndicator(color: AppColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 추천 검색어 없음
|
||||
|
|
@ -534,12 +528,11 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
itemCount: suggestionState.suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestionState.suggestions[index];
|
||||
return GestureDetector(
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_searchInputController.text = suggestion;
|
||||
_onSearch(suggestion);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -572,10 +565,9 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
GestureDetector(
|
||||
onTap: () {
|
||||
_searchInputController.text = suggestion;
|
||||
_searchInputController.selection =
|
||||
TextSelection.fromPosition(
|
||||
TextPosition(offset: suggestion.length),
|
||||
);
|
||||
_searchInputController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: suggestion.length),
|
||||
);
|
||||
_onSearchInputChanged(suggestion);
|
||||
},
|
||||
child: const Padding(
|
||||
|
|
@ -595,108 +587,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
);
|
||||
}
|
||||
|
||||
/// 최근 검색기록 빌드
|
||||
Widget _buildRecentSearches(List<String> searches) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 헤더
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'최근 검색',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ref.read(recentSearchProvider.notifier).clearAll();
|
||||
},
|
||||
child: const Text(
|
||||
'전체 삭제',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 검색기록 목록
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: searches.length,
|
||||
itemBuilder: (context, index) {
|
||||
final search = searches[index];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_searchInputController.text = search;
|
||||
_onSearch(search);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.divider.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.history,
|
||||
size: 18,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
search,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 삭제 버튼
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ref.read(recentSearchProvider.notifier).removeSearch(search);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 검색 결과 빌드
|
||||
Widget _buildSearchResults(SearchState searchState) {
|
||||
// 검색어가 없을 때
|
||||
|
|
@ -753,8 +643,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
key: ValueKey('search_list_${searchState.searchTerm}'),
|
||||
controller: _searchScrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount:
|
||||
searchState.results.length + (searchState.isFetchingMore ? 1 : 0),
|
||||
itemCount: searchState.results.length + (searchState.isFetchingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
// 로딩 인디케이터
|
||||
if (index >= searchState.results.length) {
|
||||
|
|
@ -778,12 +667,9 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
padding: EdgeInsets.only(
|
||||
bottom: index < searchState.results.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/schedule/${schedule.id}'),
|
||||
child: SearchScheduleCard(
|
||||
schedule: schedule,
|
||||
categoryColor: parseColor(schedule.categoryColor),
|
||||
),
|
||||
child: SearchScheduleCard(
|
||||
schedule: schedule,
|
||||
categoryColor: parseColor(schedule.categoryColor),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -816,9 +702,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
}
|
||||
},
|
||||
icon: const Icon(Icons.calendar_today_outlined, size: 20),
|
||||
color: _showCalendar
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
color: _showCalendar ? AppColors.primary : AppColors.textSecondary,
|
||||
),
|
||||
// 이전 월 (데이트픽커가 펼쳐지면 숨김, 페이드 애니메이션)
|
||||
AnimatedSwitcher(
|
||||
|
|
@ -849,8 +733,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
? () {
|
||||
setState(() {
|
||||
_showYearMonthPicker = !_showYearMonthPicker;
|
||||
_yearRangeStart =
|
||||
(_calendarViewDate.year ~/ 12) * 12;
|
||||
_yearRangeStart = (_calendarViewDate.year ~/ 12) * 12;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
|
|
@ -862,14 +745,8 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _showCalendar
|
||||
? const SizedBox(
|
||||
key: ValueKey('arrow_space'),
|
||||
width: 20,
|
||||
)
|
||||
: const SizedBox(
|
||||
key: ValueKey('no_space'),
|
||||
width: 0,
|
||||
),
|
||||
? const SizedBox(key: ValueKey('arrow_space'), width: 20)
|
||||
: const SizedBox(key: ValueKey('no_space'), width: 0),
|
||||
),
|
||||
// 년월 텍스트 (항상 가운데 고정)
|
||||
Text(
|
||||
|
|
@ -888,9 +765,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
duration: const Duration(milliseconds: 200),
|
||||
child: _showCalendar
|
||||
? Row(
|
||||
key: ValueKey(
|
||||
'dropdown_$_showYearMonthPicker',
|
||||
),
|
||||
key: ValueKey('dropdown_$_showYearMonthPicker'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 2),
|
||||
|
|
@ -905,10 +780,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox(
|
||||
key: ValueKey('no_dropdown'),
|
||||
width: 0,
|
||||
),
|
||||
: const SizedBox(key: ValueKey('no_dropdown'), width: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -950,10 +822,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
}
|
||||
|
||||
/// 달력 팝업 빌드
|
||||
Widget _buildCalendarPopup(
|
||||
ScheduleState state,
|
||||
ScheduleController controller,
|
||||
) {
|
||||
Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) {
|
||||
return AnimatedBuilder(
|
||||
animation: _calendarAnimation,
|
||||
builder: (context, child) {
|
||||
|
|
@ -961,7 +830,10 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: _calendarAnimation.value,
|
||||
child: Opacity(opacity: _calendarAnimation.value, child: child),
|
||||
child: Opacity(
|
||||
opacity: _calendarAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -988,8 +860,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
},
|
||||
transitionBuilder: (child, animation) {
|
||||
// 년월 선택기에만 슬라이드 효과 추가
|
||||
final isYearMonthPicker =
|
||||
child.key == const ValueKey('yearMonth');
|
||||
final isYearMonthPicker = child.key == const ValueKey('yearMonth');
|
||||
if (isYearMonthPicker) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
|
|
@ -1072,11 +943,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
// 년도 라벨
|
||||
const Text(
|
||||
'년도',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
style: TextStyle(fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 년도 그리드 (ExpandablePageView로 스와이프 애니메이션)
|
||||
|
|
@ -1102,11 +969,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
// 월 라벨
|
||||
const Text(
|
||||
'월',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
style: TextStyle(fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 월 그리드
|
||||
|
|
@ -1124,16 +987,11 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
itemBuilder: (context, index) {
|
||||
final month = index + 1;
|
||||
final isSelected = month == _calendarViewDate.month;
|
||||
final isCurrentMonth =
|
||||
month == today.month && _calendarViewDate.year == today.year;
|
||||
final isCurrentMonth = month == today.month && _calendarViewDate.year == today.year;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_calendarViewDate = DateTime(
|
||||
_calendarViewDate.year,
|
||||
month,
|
||||
1,
|
||||
);
|
||||
_calendarViewDate = DateTime(_calendarViewDate.year, month, 1);
|
||||
_showYearMonthPicker = false;
|
||||
});
|
||||
},
|
||||
|
|
@ -1150,14 +1008,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected || isCurrentMonth
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
fontWeight: isSelected || isCurrentMonth ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: isCurrentMonth
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary,
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
child: Text('$month월'),
|
||||
),
|
||||
|
|
@ -1237,14 +1093,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected || isCurrentYear
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
fontWeight: isSelected || isCurrentYear ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: isCurrentYear
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary,
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
child: Text('$year'),
|
||||
),
|
||||
|
|
@ -1255,10 +1109,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
}
|
||||
|
||||
/// 달력 그리드
|
||||
Widget _buildCalendarGrid(
|
||||
ScheduleState state,
|
||||
ScheduleController controller,
|
||||
) {
|
||||
Widget _buildCalendarGrid(ScheduleState state, ScheduleController controller) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
|
|
@ -1266,9 +1117,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
children: [
|
||||
// 요일 헤더
|
||||
Row(
|
||||
children: ['일', '월', '화', '수', '목', '금', '토'].asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
children: ['일', '월', '화', '수', '목', '금', '토'].asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final day = entry.value;
|
||||
return Expanded(
|
||||
|
|
@ -1282,15 +1131,15 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
color: index == 0
|
||||
? Colors.red.shade400
|
||||
: index == 6
|
||||
? Colors.blue.shade400
|
||||
: AppColors.textSecondary,
|
||||
? Colors.blue.shade400
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 4),
|
||||
// 날짜 그리드 (ExpandablePageView로 높이 자동 조절)
|
||||
ExpandablePageView.builder(
|
||||
controller: _calendarPageController,
|
||||
|
|
@ -1328,7 +1177,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
// 오늘 버튼
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
|
|
@ -1370,13 +1219,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
}) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
mainAxisSpacing: 6,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisExtent: 50, // Container(36) + SizedBox(6) + 여백
|
||||
mainAxisExtent: 46, // Container(36) + SizedBox(6) + 여백(4)
|
||||
),
|
||||
itemCount: allDays.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
|
@ -1385,9 +1233,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
final isSelected = controller.isSelected(date);
|
||||
final isToday = controller.isToday(date);
|
||||
final dayOfWeek = index % 7;
|
||||
final daySchedules = isCurrentMonth
|
||||
? state.getDaySchedules(date)
|
||||
: <Schedule>[];
|
||||
final daySchedules = isCurrentMonth ? state.getDaySchedules(date) : <Schedule>[];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
|
|
@ -1422,20 +1268,18 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected || isToday
|
||||
? FontWeight.bold
|
||||
: FontWeight.w400,
|
||||
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.w400,
|
||||
color: !isCurrentMonth
|
||||
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||||
: isSelected
|
||||
? Colors.white
|
||||
: isToday
|
||||
? AppColors.primary
|
||||
: dayOfWeek == 0
|
||||
? Colors.red.shade500
|
||||
: dayOfWeek == 6
|
||||
? Colors.blue.shade500
|
||||
: AppColors.textPrimary,
|
||||
? Colors.white
|
||||
: isToday
|
||||
? AppColors.primary
|
||||
: dayOfWeek == 0
|
||||
? Colors.red.shade500
|
||||
: dayOfWeek == 6
|
||||
? Colors.blue.shade500
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1466,95 +1310,91 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
}
|
||||
|
||||
/// 날짜 선택기 빌드
|
||||
Widget _buildDateSelector(
|
||||
ScheduleState state,
|
||||
ScheduleController controller,
|
||||
) {
|
||||
Widget _buildDateSelector(ScheduleState state, ScheduleController controller) {
|
||||
return Container(
|
||||
height: 80,
|
||||
color: Colors.white,
|
||||
child: ListView.builder(
|
||||
controller: _dateScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
itemCount: state.daysInMonth.length,
|
||||
itemBuilder: (context, index) {
|
||||
final date = state.daysInMonth[index];
|
||||
final isSelected = controller.isSelected(date);
|
||||
final isToday = controller.isToday(date);
|
||||
final dayOfWeek = date.weekday;
|
||||
final daySchedules = state.getDaySchedules(date);
|
||||
controller: _dateScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
itemCount: state.daysInMonth.length,
|
||||
itemBuilder: (context, index) {
|
||||
final date = state.daysInMonth[index];
|
||||
final isSelected = controller.isSelected(date);
|
||||
final isToday = controller.isToday(date);
|
||||
final dayOfWeek = date.weekday;
|
||||
final daySchedules = state.getDaySchedules(date);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onDateSelected(date),
|
||||
child: Container(
|
||||
width: 44,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 요일
|
||||
Text(
|
||||
_getDayName(date),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white.withValues(alpha: 0.8)
|
||||
: dayOfWeek == 7
|
||||
? Colors.red.shade400
|
||||
: dayOfWeek == 6
|
||||
? Colors.blue.shade400
|
||||
: AppColors.textTertiary,
|
||||
return GestureDetector(
|
||||
onTap: () => _onDateSelected(date),
|
||||
child: Container(
|
||||
width: 44,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 요일
|
||||
Text(
|
||||
_getDayName(date),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white.withValues(alpha: 0.8)
|
||||
: dayOfWeek == 7
|
||||
? Colors.red.shade400
|
||||
: dayOfWeek == 6
|
||||
? Colors.blue.shade400
|
||||
: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 날짜
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: isToday
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary,
|
||||
const SizedBox(height: 4),
|
||||
// 날짜
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: isToday
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 일정 점 (최대 3개)
|
||||
SizedBox(
|
||||
height: 6,
|
||||
child: !isSelected && daySchedules.isNotEmpty
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: daySchedules.map((schedule) {
|
||||
return Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: parseColor(schedule.categoryColor),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
// 일정 점 (최대 3개)
|
||||
SizedBox(
|
||||
height: 6,
|
||||
child: !isSelected && daySchedules.isNotEmpty
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: daySchedules.map((schedule) {
|
||||
return Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: parseColor(schedule.categoryColor),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1570,7 +1410,10 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
return Center(
|
||||
child: Text(
|
||||
'${state.selectedDate.month}월 ${state.selectedDate.day}일 일정이 없습니다',
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.textTertiary),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1583,16 +1426,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
final schedule = state.selectedDateSchedules[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/schedule/${schedule.id}'),
|
||||
child: AnimatedScheduleCard(
|
||||
key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'),
|
||||
index: index,
|
||||
schedule: schedule,
|
||||
categoryColor: parseColor(schedule.categoryColor),
|
||||
),
|
||||
bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0),
|
||||
child: AnimatedScheduleCard(
|
||||
key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'),
|
||||
index: index,
|
||||
schedule: schedule,
|
||||
categoryColor: parseColor(schedule.categoryColor),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -225,23 +225,24 @@ class ScheduleCard extends StatelessWidget {
|
|||
// 멤버
|
||||
if (memberList.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
// divider (전체 너비)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: AppColors.divider,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: memberList.length >= 5
|
||||
? [
|
||||
const MemberChip(name: '프로미스나인'),
|
||||
]
|
||||
: memberList
|
||||
.map((name) => MemberChip(name: name))
|
||||
.toList(),
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.divider, width: 1),
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: memberList.length >= 5
|
||||
? [
|
||||
const MemberChip(name: '프로미스나인'),
|
||||
]
|
||||
: memberList
|
||||
.map((name) => MemberChip(name: name))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import Foundation
|
|||
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import video_player_avfoundation
|
||||
|
|
@ -16,7 +15,6 @@ import wakelock_plus
|
|||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
|
|
|
|||
|
|
@ -664,62 +664,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.18"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ dependencies:
|
|||
video_player: ^2.9.2
|
||||
chewie: ^1.8.5
|
||||
expandable_page_view: ^1.0.17
|
||||
shared_preferences: ^2.3.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
85
backend/lib/date.js
Normal file
85
backend/lib/date.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* 날짜/시간 유틸리티 (dayjs 기반)
|
||||
* 백엔드 전체에서 공통으로 사용
|
||||
*/
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat.js";
|
||||
|
||||
// 플러그인 등록
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
// 기본 시간대: KST
|
||||
const KST = "Asia/Seoul";
|
||||
|
||||
/**
|
||||
* UTC 시간을 KST로 변환
|
||||
* @param {Date|string} utcDate - UTC 시간
|
||||
* @returns {dayjs.Dayjs} KST 시간
|
||||
*/
|
||||
export function toKST(utcDate) {
|
||||
return dayjs(utcDate).tz(KST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜를 YYYY-MM-DD 형식으로 포맷
|
||||
* @param {Date|string|dayjs.Dayjs} date - 날짜
|
||||
* @returns {string} YYYY-MM-DD
|
||||
*/
|
||||
export function formatDate(date) {
|
||||
return dayjs(date).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간을 HH:mm:ss 형식으로 포맷
|
||||
* @param {Date|string|dayjs.Dayjs} date - 시간
|
||||
* @returns {string} HH:mm:ss
|
||||
*/
|
||||
export function formatTime(date) {
|
||||
return dayjs(date).format("HH:mm:ss");
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC 시간을 KST로 변환 후 날짜/시간 분리
|
||||
* @param {Date|string} utcDate - UTC 시간
|
||||
* @returns {{date: string, time: string}} KST 날짜/시간
|
||||
*/
|
||||
export function utcToKSTDateTime(utcDate) {
|
||||
const kst = toKST(utcDate);
|
||||
return {
|
||||
date: kst.format("YYYY-MM-DD"),
|
||||
time: kst.format("HH:mm:ss"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 KST 시간 반환
|
||||
* @returns {dayjs.Dayjs} 현재 KST 시간
|
||||
*/
|
||||
export function nowKST() {
|
||||
return dayjs().tz(KST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter 날짜 문자열 파싱 (UTC 반환)
|
||||
* 예: "Jan 9, 2026 · 4:00 PM UTC" → Date 객체
|
||||
* @param {string} timeStr - Nitter 날짜 문자열
|
||||
* @returns {dayjs.Dayjs|null} UTC 시간
|
||||
*/
|
||||
export function parseNitterDateTime(timeStr) {
|
||||
if (!timeStr) return null;
|
||||
try {
|
||||
const cleaned = timeStr.replace(" · ", " ").replace(" UTC", "");
|
||||
const date = dayjs.utc(cleaned, "MMM D, YYYY h:mm A");
|
||||
if (!date.isValid()) return null;
|
||||
return date;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default dayjs;
|
||||
15
backend/lib/db.js
Normal file
15
backend/lib/db.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import mysql from "mysql2/promise";
|
||||
|
||||
// MariaDB 연결 풀 생성
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || "mariadb",
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || "fromis9",
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || "fromis9",
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export default pool;
|
||||
19
backend/lib/redis.js
Normal file
19
backend/lib/redis.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Redis from "ioredis";
|
||||
|
||||
// Redis 클라이언트 초기화
|
||||
const redis = new Redis({
|
||||
host: "fromis9-redis",
|
||||
port: 6379,
|
||||
retryDelayOnFailover: 100,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
redis.on("connect", () => {
|
||||
console.log("[Redis] 연결 성공");
|
||||
});
|
||||
|
||||
redis.on("error", (err) => {
|
||||
console.error("[Redis] 연결 오류:", err.message);
|
||||
});
|
||||
|
||||
export default redis;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
프로미스나인 NNP
|
||||
프미나 NNP
|
||||
프나 NNP
|
||||
fromis_9 SL
|
||||
fromis SL
|
||||
이새롬 NNP
|
||||
새롬 NNP
|
||||
송하영 NNP
|
||||
하영 NNP
|
||||
장규리 NNP
|
||||
규리 NNP
|
||||
박지원 NNP
|
||||
지원 NNP
|
||||
노지선 NNP
|
||||
지선 NNP
|
||||
이서연 NNP
|
||||
서연 NNP
|
||||
이채영 NNP
|
||||
채영 NNP
|
||||
이나경 NNP
|
||||
나경 NNP
|
||||
백지헌 NNP
|
||||
지헌 NNP
|
||||
플로버 NNP
|
||||
flover SL
|
||||
뮤직뱅크 NNP
|
||||
인기가요 NNP
|
||||
음악중심 NNP
|
||||
엠카운트다운 NNP
|
||||
쇼챔피언 NNP
|
||||
더쇼 NNP
|
||||
스프 NNP
|
||||
성수기 NNP
|
||||
이단장 NNP
|
||||
슈퍼E나경 NNP
|
||||
FM_1.24 SL
|
||||
팬미팅 NNG
|
||||
직캠 NNG
|
||||
컴백 NNG
|
||||
응원법 NNG
|
||||
8286
backend/package-lock.json
generated
8286
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +1,25 @@
|
|||
{
|
||||
"name": "fromis9-backend",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.970.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/swagger": "^9.0.0",
|
||||
"@scalar/fastify-api-reference": "^1.25.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"inko": "^1.1.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"kiwi-nlp": "^0.22.1",
|
||||
"meilisearch": "^0.44.0",
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
"name": "fromis9-backend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"express": "^4.18.2",
|
||||
"inko": "^1.1.1",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"meilisearch": "^0.55.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.11.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sharp": "^0.33.5",
|
||||
"fluent-ffmpeg": "^2.1.3"
|
||||
}
|
||||
}
|
||||
2095
backend/routes/admin.js
Normal file
2095
backend/routes/admin.js
Normal file
File diff suppressed because it is too large
Load diff
180
backend/routes/albums.js
Normal file
180
backend/routes/albums.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함)
|
||||
async function getAlbumDetails(album) {
|
||||
// 트랙 정보 조회 (가사 포함)
|
||||
const [tracks] = await pool.query(
|
||||
"SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number",
|
||||
[album.id]
|
||||
);
|
||||
album.tracks = tracks;
|
||||
|
||||
// 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함)
|
||||
const [teasers] = await pool.query(
|
||||
"SELECT original_url, medium_url, thumb_url, video_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
|
||||
[album.id]
|
||||
);
|
||||
album.teasers = teasers;
|
||||
|
||||
// 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함)
|
||||
const [photos] = await pool.query(
|
||||
`SELECT
|
||||
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
|
||||
p.width, p.height,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
||||
FROM album_photos p
|
||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||
LEFT JOIN members m ON pm.member_id = m.id
|
||||
WHERE p.album_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.sort_order`,
|
||||
[album.id]
|
||||
);
|
||||
|
||||
// 컨셉별로 그룹화
|
||||
const conceptPhotos = {};
|
||||
for (const photo of photos) {
|
||||
const concept = photo.concept_name || "Default";
|
||||
if (!conceptPhotos[concept]) {
|
||||
conceptPhotos[concept] = [];
|
||||
}
|
||||
conceptPhotos[concept].push({
|
||||
id: photo.id,
|
||||
original_url: photo.original_url,
|
||||
medium_url: photo.medium_url,
|
||||
thumb_url: photo.thumb_url,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
type: photo.photo_type,
|
||||
members: photo.members,
|
||||
sortOrder: photo.sort_order,
|
||||
});
|
||||
}
|
||||
album.conceptPhotos = conceptPhotos;
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
// 전체 앨범 조회 (트랙 포함)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [albums] = await pool.query(
|
||||
"SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC"
|
||||
);
|
||||
|
||||
// 각 앨범에 트랙 정보 추가
|
||||
for (const album of albums) {
|
||||
const [tracks] = await pool.query(
|
||||
"SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger FROM tracks WHERE album_id = ? ORDER BY track_number",
|
||||
[album.id]
|
||||
);
|
||||
album.tracks = tracks;
|
||||
}
|
||||
|
||||
res.json(albums);
|
||||
} catch (error) {
|
||||
console.error("앨범 조회 오류:", error);
|
||||
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 앨범명과 트랙명으로 트랙 상세 조회 (더 구체적인 경로이므로 /by-name/:name보다 앞에 배치)
|
||||
router.get("/by-name/:albumName/track/:trackTitle", async (req, res) => {
|
||||
try {
|
||||
const albumName = decodeURIComponent(req.params.albumName);
|
||||
const trackTitle = decodeURIComponent(req.params.trackTitle);
|
||||
|
||||
// 앨범 조회
|
||||
const [albums] = await pool.query(
|
||||
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
|
||||
[albumName, albumName]
|
||||
);
|
||||
|
||||
if (albums.length === 0) {
|
||||
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const album = albums[0];
|
||||
|
||||
// 해당 앨범의 트랙 조회
|
||||
const [tracks] = await pool.query(
|
||||
"SELECT * FROM tracks WHERE album_id = ? AND title = ?",
|
||||
[album.id, trackTitle]
|
||||
);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return res.status(404).json({ error: "트랙을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const track = tracks[0];
|
||||
|
||||
// 앨범의 다른 트랙 목록 조회
|
||||
const [otherTracks] = await pool.query(
|
||||
"SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number",
|
||||
[album.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
...track,
|
||||
album: {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
folder_name: album.folder_name,
|
||||
cover_thumb_url: album.cover_thumb_url,
|
||||
cover_medium_url: album.cover_medium_url,
|
||||
release_date: album.release_date,
|
||||
album_type: album.album_type,
|
||||
},
|
||||
otherTracks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("트랙 조회 오류:", error);
|
||||
res.status(500).json({ error: "트랙 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 앨범 folder_name 또는 title로 조회
|
||||
router.get("/by-name/:name", async (req, res) => {
|
||||
try {
|
||||
const name = decodeURIComponent(req.params.name);
|
||||
// folder_name 또는 title로 검색 (PC는 title, 모바일은 folder_name 사용)
|
||||
const [albums] = await pool.query(
|
||||
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
|
||||
[name, name]
|
||||
);
|
||||
|
||||
if (albums.length === 0) {
|
||||
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const album = await getAlbumDetails(albums[0]);
|
||||
res.json(album);
|
||||
} catch (error) {
|
||||
console.error("앨범 조회 오류:", error);
|
||||
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// ID로 앨범 조회
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [albums] = await pool.query("SELECT * FROM albums WHERE id = ?", [
|
||||
req.params.id,
|
||||
]);
|
||||
|
||||
if (albums.length === 0) {
|
||||
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const album = await getAlbumDetails(albums[0]);
|
||||
res.json(album);
|
||||
} catch (error) {
|
||||
console.error("앨범 조회 오류:", error);
|
||||
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
35
backend/routes/members.js
Normal file
35
backend/routes/members.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 전체 멤버 조회
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT id, name, birth_date, position, image_url, instagram, is_former FROM members ORDER BY is_former, birth_date"
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error("멤버 조회 오류:", error);
|
||||
res.status(500).json({ error: "멤버 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 멤버 조회
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query("SELECT * FROM members WHERE id = ?", [
|
||||
req.params.id,
|
||||
]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: "멤버를 찾을 수 없습니다." });
|
||||
}
|
||||
res.json(rows[0]);
|
||||
} catch (error) {
|
||||
console.error("멤버 조회 오류:", error);
|
||||
res.status(500).json({ error: "멤버 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
198
backend/routes/schedules.js
Normal file
198
backend/routes/schedules.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
import { searchSchedules } from "../services/meilisearch.js";
|
||||
import { saveSearchQuery, getSuggestions } from "../services/suggestions.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 검색어 추천 API (Bi-gram 기반)
|
||||
router.get("/suggestions", async (req, res) => {
|
||||
try {
|
||||
const { q, limit } = req.query;
|
||||
|
||||
if (!q || q.trim().length === 0) {
|
||||
return res.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const suggestions = await getSuggestions(q, parseInt(limit) || 10);
|
||||
res.json({ suggestions });
|
||||
} catch (error) {
|
||||
console.error("추천 검색어 오류:", error);
|
||||
res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 공개 일정 목록 조회 (검색 포함)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const { search, startDate, endDate, limit, year, month } = req.query;
|
||||
|
||||
// 검색어가 있으면 Meilisearch 사용
|
||||
if (search && search.trim()) {
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
const pageLimit = parseInt(req.query.limit) || 100;
|
||||
|
||||
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
|
||||
if (offset === 0) {
|
||||
saveSearchQuery(search.trim()).catch((err) =>
|
||||
console.error("검색어 저장 실패:", err.message)
|
||||
);
|
||||
}
|
||||
|
||||
// Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징)
|
||||
const results = await searchSchedules(search.trim(), {
|
||||
limit: 1000, // 내부적으로 1000개까지 검색
|
||||
});
|
||||
|
||||
// 페이징 적용
|
||||
const paginatedHits = results.hits.slice(offset, offset + pageLimit);
|
||||
|
||||
return res.json({
|
||||
schedules: paginatedHits,
|
||||
total: results.total,
|
||||
offset: offset,
|
||||
limit: pageLimit,
|
||||
hasMore: offset + paginatedHits.length < results.total,
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 필터 및 제한 조건 구성
|
||||
let whereClause = "WHERE 1=1";
|
||||
const params = [];
|
||||
|
||||
// 년/월 필터링 (월별 데이터 로딩용)
|
||||
if (year && month) {
|
||||
whereClause += " AND YEAR(s.date) = ? AND MONTH(s.date) = ?";
|
||||
params.push(parseInt(year), parseInt(month));
|
||||
} else if (year) {
|
||||
whereClause += " AND YEAR(s.date) = ?";
|
||||
params.push(parseInt(year));
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
whereClause += " AND s.date >= ?";
|
||||
params.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
whereClause += " AND s.date <= ?";
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
// limit 파라미터 처리
|
||||
const limitClause = limit ? `LIMIT ${parseInt(limit)}` : "";
|
||||
|
||||
// 검색어 없으면 DB에서 전체 조회
|
||||
const [schedules] = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.description,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
s.source_url,
|
||||
s.source_name,
|
||||
s.location_name,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
${whereClause}
|
||||
GROUP BY s.id
|
||||
ORDER BY s.date ASC, s.time ASC
|
||||
${limitClause}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json(schedules);
|
||||
} catch (error) {
|
||||
console.error("일정 목록 조회 오류:", error);
|
||||
res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 카테고리 목록 조회
|
||||
router.get("/categories", async (req, res) => {
|
||||
try {
|
||||
const [categories] = await pool.query(`
|
||||
SELECT id, name, color, sort_order
|
||||
FROM schedule_categories
|
||||
ORDER BY sort_order ASC
|
||||
`);
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
console.error("카테고리 조회 오류:", error);
|
||||
res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 개별 일정 조회
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const [schedules] = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
s.*,
|
||||
c.name as category_name,
|
||||
c.color as category_color
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
WHERE s.id = ?
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return res.status(404).json({ error: "일정을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
res.json(schedules[0]);
|
||||
} catch (error) {
|
||||
console.error("일정 조회 오류:", error);
|
||||
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// Meilisearch 동기화 API
|
||||
router.post("/sync-search", async (req, res) => {
|
||||
try {
|
||||
const { syncAllSchedules } = await import("../services/meilisearch.js");
|
||||
|
||||
// DB에서 모든 일정 조회
|
||||
const [schedules] = await pool.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.description,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
s.source_url,
|
||||
s.source_name,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
GROUP BY s.id
|
||||
`);
|
||||
|
||||
const count = await syncAllSchedules(schedules);
|
||||
res.json({ success: true, synced: count });
|
||||
} catch (error) {
|
||||
console.error("Meilisearch 동기화 오류:", error);
|
||||
res.status(500).json({ error: "동기화 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
28
backend/routes/stats.js
Normal file
28
backend/routes/stats.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 통계 조회 (멤버 수, 앨범 수)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [memberCount] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM members"
|
||||
);
|
||||
const [albumCount] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM albums"
|
||||
);
|
||||
|
||||
res.json({
|
||||
memberCount: memberCount[0].count,
|
||||
albumCount: albumCount[0].count,
|
||||
debutYear: 2018,
|
||||
fandomName: "flover",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("통계 조회 오류:", error);
|
||||
res.status(500).json({ error: "통계 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
81
backend/server.js
Normal file
81
backend/server.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import membersRouter from "./routes/members.js";
|
||||
import albumsRouter from "./routes/albums.js";
|
||||
import statsRouter from "./routes/stats.js";
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import schedulesRouter from "./routes/schedules.js";
|
||||
import { initScheduler } from "./services/youtube-scheduler.js";
|
||||
import { initMeilisearch } from "./services/meilisearch.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 80;
|
||||
|
||||
// JSON 파싱
|
||||
app.use(express.json());
|
||||
|
||||
// 정적 파일 서빙 (프론트엔드 빌드 결과물)
|
||||
app.use(express.static(path.join(__dirname, "dist")));
|
||||
|
||||
// API 라우트
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.use("/api/members", membersRouter);
|
||||
app.use("/api/albums", albumsRouter);
|
||||
app.use("/api/stats", statsRouter);
|
||||
app.use("/api/admin", adminRouter);
|
||||
app.use("/api/schedules", schedulesRouter);
|
||||
app.use("/api/schedule-categories", (req, res, next) => {
|
||||
// /api/schedule-categories -> /api/schedules/categories로 리다이렉트
|
||||
req.url = "/categories";
|
||||
schedulesRouter(req, res, next);
|
||||
});
|
||||
|
||||
// SPA 폴백 - 모든 요청을 index.html로
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "dist", "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
|
||||
|
||||
// Meilisearch 초기화 및 동기화
|
||||
try {
|
||||
await initMeilisearch();
|
||||
console.log("🔍 Meilisearch 초기화 완료");
|
||||
|
||||
// 서버 시작 시 일정 데이터 자동 동기화
|
||||
const { syncAllSchedules } = await import("./services/meilisearch.js");
|
||||
const [schedules] = await (
|
||||
await import("./lib/db.js")
|
||||
).default.query(`
|
||||
SELECT
|
||||
s.id, s.title, s.description, s.date, s.time, s.category_id, s.source_url, s.source_name,
|
||||
c.name as category_name, c.color as category_color,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
GROUP BY s.id
|
||||
`);
|
||||
const syncedCount = await syncAllSchedules(schedules);
|
||||
console.log(`🔍 Meilisearch ${syncedCount}개 일정 동기화 완료`);
|
||||
} catch (error) {
|
||||
console.error("Meilisearch 초기화/동기화 오류:", error);
|
||||
}
|
||||
|
||||
// YouTube 봇 스케줄러 초기화
|
||||
try {
|
||||
await initScheduler();
|
||||
console.log("📺 YouTube 봇 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("YouTube 스케줄러 초기화 오류:", error);
|
||||
}
|
||||
});
|
||||
84
backend/services/meilisearch-bot.js
Normal file
84
backend/services/meilisearch-bot.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Meilisearch 동기화 봇 서비스
|
||||
* 모든 일정을 Meilisearch에 동기화
|
||||
*/
|
||||
|
||||
import pool from "../lib/db.js";
|
||||
import { addOrUpdateSchedule } from "./meilisearch.js";
|
||||
|
||||
/**
|
||||
* 전체 일정 Meilisearch 동기화
|
||||
*/
|
||||
export async function syncAllSchedules(botId) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 모든 일정 조회
|
||||
const [schedules] = await pool.query(`
|
||||
SELECT s.id, s.title, s.description, s.date, s.time,
|
||||
s.category_id, s.source_url, s.source_name,
|
||||
c.name as category_name, c.color as category_color
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
`);
|
||||
|
||||
let synced = 0;
|
||||
|
||||
for (const s of schedules) {
|
||||
// 멤버 조회
|
||||
const [members] = await pool.query(
|
||||
"SELECT m.id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ?",
|
||||
[s.id]
|
||||
);
|
||||
|
||||
// Meilisearch 동기화
|
||||
await addOrUpdateSchedule({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description || "",
|
||||
date: s.date,
|
||||
time: s.time,
|
||||
category_id: s.category_id,
|
||||
category_name: s.category_name || "",
|
||||
category_color: s.category_color || "",
|
||||
source_name: s.source_name,
|
||||
source_url: s.source_url,
|
||||
members: members,
|
||||
});
|
||||
|
||||
synced++;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
const elapsedSec = (elapsedMs / 1000).toFixed(2);
|
||||
|
||||
// 봇 상태 업데이트 (schedules_added = 동기화 수, last_added_count = 소요시간 ms)
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
schedules_added = ?,
|
||||
last_added_count = ?,
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[synced, elapsedMs, botId]
|
||||
);
|
||||
|
||||
console.log(`[Meilisearch Bot] ${synced}개 동기화 완료 (${elapsedSec}초)`);
|
||||
return { synced, elapsed: elapsedSec };
|
||||
} catch (error) {
|
||||
// 오류 상태 업데이트
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?`,
|
||||
[error.message, botId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
syncAllSchedules,
|
||||
};
|
||||
227
backend/services/meilisearch.js
Normal file
227
backend/services/meilisearch.js
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { MeiliSearch } from "meilisearch";
|
||||
|
||||
// Meilisearch 클라이언트 초기화
|
||||
const client = new MeiliSearch({
|
||||
host: "http://fromis9-meilisearch:7700",
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
});
|
||||
|
||||
const SCHEDULE_INDEX = "schedules";
|
||||
|
||||
/**
|
||||
* 인덱스 초기화 및 설정
|
||||
*/
|
||||
export async function initMeilisearch() {
|
||||
try {
|
||||
// 인덱스 생성 (이미 존재하면 무시)
|
||||
await client.createIndex(SCHEDULE_INDEX, { primaryKey: "id" });
|
||||
|
||||
// 인덱스 설정
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
// 검색 가능한 필드 설정 (순서가 우선순위 결정)
|
||||
await index.updateSearchableAttributes([
|
||||
"title",
|
||||
"member_names",
|
||||
"description",
|
||||
"source_name",
|
||||
"category_name",
|
||||
]);
|
||||
|
||||
// 필터링 가능한 필드 설정
|
||||
await index.updateFilterableAttributes(["category_id", "date"]);
|
||||
|
||||
// 정렬 가능한 필드 설정
|
||||
await index.updateSortableAttributes(["date", "time"]);
|
||||
|
||||
// 랭킹 규칙 설정 (동일 유사도일 때 최신 날짜 우선)
|
||||
await index.updateRankingRules([
|
||||
"words", // 검색어 포함 개수
|
||||
"typo", // 오타 수
|
||||
"proximity", // 검색어 간 거리
|
||||
"attribute", // 필드 우선순위
|
||||
"exactness", // 정확도
|
||||
"date:desc", // 동일 유사도 시 최신 날짜 우선
|
||||
]);
|
||||
|
||||
// 오타 허용 설정 (typo tolerance)
|
||||
await index.updateTypoTolerance({
|
||||
enabled: true,
|
||||
minWordSizeForTypos: {
|
||||
oneTypo: 2,
|
||||
twoTypos: 4,
|
||||
},
|
||||
});
|
||||
|
||||
// 페이징 설정 (기본 1000개 제한 해제)
|
||||
await index.updatePagination({
|
||||
maxTotalHits: 10000, // 최대 10000개까지 조회 가능
|
||||
});
|
||||
|
||||
console.log("[Meilisearch] 인덱스 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 초기화 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 문서 추가/업데이트
|
||||
*/
|
||||
export async function addOrUpdateSchedule(schedule) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
// 멤버 이름을 쉼표로 구분하여 저장
|
||||
const memberNames = schedule.members
|
||||
? schedule.members.map((m) => m.name).join(",")
|
||||
: "";
|
||||
|
||||
const document = {
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
description: schedule.description || "",
|
||||
date: schedule.date,
|
||||
time: schedule.time || "",
|
||||
category_id: schedule.category_id,
|
||||
category_name: schedule.category_name || "",
|
||||
category_color: schedule.category_color || "",
|
||||
source_name: schedule.source_name || "",
|
||||
source_url: schedule.source_url || "",
|
||||
member_names: memberNames,
|
||||
};
|
||||
|
||||
await index.addDocuments([document]);
|
||||
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 문서 추가 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 문서 삭제
|
||||
*/
|
||||
export async function deleteSchedule(scheduleId) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
await index.deleteDocument(scheduleId);
|
||||
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 문서 삭제 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
import Inko from "inko";
|
||||
const inko = new Inko();
|
||||
|
||||
/**
|
||||
* 영문 자판으로 입력된 검색어인지 확인 (대부분 영문으로만 구성)
|
||||
*/
|
||||
function isEnglishKeyboard(text) {
|
||||
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||
// 영문이 50% 이상이고 한글이 없으면 영문 자판 입력으로 간주
|
||||
return englishChars.length > 0 && koreanChars.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색 (페이징 지원)
|
||||
*/
|
||||
export async function searchSchedules(query, options = {}) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
const searchOptions = {
|
||||
limit: options.limit || 1000, // 기본 1000개 (Meilisearch 최대)
|
||||
offset: options.offset || 0, // 페이징용 offset
|
||||
attributesToRetrieve: ["*"],
|
||||
showRankingScore: true, // 유사도 점수 포함
|
||||
};
|
||||
|
||||
// 카테고리 필터
|
||||
if (options.categoryId) {
|
||||
searchOptions.filter = `category_id = ${options.categoryId}`;
|
||||
}
|
||||
|
||||
// 정렬 지정 시에만 적용 (기본은 유사도순)
|
||||
if (options.sort) {
|
||||
searchOptions.sort = options.sort;
|
||||
}
|
||||
|
||||
// 원본 검색어로 검색
|
||||
const results = await index.search(query, searchOptions);
|
||||
let allHits = [...results.hits];
|
||||
|
||||
// 영문 자판 입력인 경우 한글로 변환하여 추가 검색
|
||||
if (isEnglishKeyboard(query)) {
|
||||
const koreanQuery = inko.en2ko(query);
|
||||
if (koreanQuery !== query) {
|
||||
const koreanResults = await index.search(koreanQuery, searchOptions);
|
||||
// 중복 제거하며 병합 (id 기준)
|
||||
const existingIds = new Set(allHits.map((h) => h.id));
|
||||
for (const hit of koreanResults.hits) {
|
||||
if (!existingIds.has(hit.id)) {
|
||||
allHits.push(hit);
|
||||
existingIds.add(hit.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 유사도 0.5 미만인 결과 필터링
|
||||
const filteredHits = allHits.filter((hit) => hit._rankingScore >= 0.5);
|
||||
|
||||
// 유사도 순으로 정렬
|
||||
filteredHits.sort(
|
||||
(a, b) => (b._rankingScore || 0) - (a._rankingScore || 0)
|
||||
);
|
||||
|
||||
// 페이징 정보 포함 반환
|
||||
return {
|
||||
hits: filteredHits,
|
||||
total: filteredHits.length, // 필터링 후 결과 수
|
||||
offset: searchOptions.offset,
|
||||
limit: searchOptions.limit,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 검색 오류:", error.message);
|
||||
return { hits: [], total: 0, offset: 0, limit: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 일정 동기화 (초기 데이터 로드용)
|
||||
*/
|
||||
export async function syncAllSchedules(schedules) {
|
||||
try {
|
||||
const index = client.index(SCHEDULE_INDEX);
|
||||
|
||||
// 기존 문서 모두 삭제
|
||||
await index.deleteAllDocuments();
|
||||
|
||||
// 문서 변환
|
||||
const documents = schedules.map((schedule) => ({
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
description: schedule.description || "",
|
||||
date: schedule.date,
|
||||
time: schedule.time || "",
|
||||
category_id: schedule.category_id,
|
||||
category_name: schedule.category_name || "",
|
||||
category_color: schedule.category_color || "",
|
||||
source_name: schedule.source_name || "",
|
||||
source_url: schedule.source_url || "",
|
||||
member_names: schedule.member_names || "",
|
||||
}));
|
||||
|
||||
// 일괄 추가
|
||||
await index.addDocuments(documents);
|
||||
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
|
||||
|
||||
return documents.length;
|
||||
} catch (error) {
|
||||
console.error("[Meilisearch] 동기화 오류:", error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export { client };
|
||||
248
backend/services/suggestions.js
Normal file
248
backend/services/suggestions.js
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import pool from "../lib/db.js";
|
||||
import redis from "../lib/redis.js";
|
||||
import Inko from "inko";
|
||||
import { searchSchedules } from "./meilisearch.js";
|
||||
|
||||
const inko = new Inko();
|
||||
|
||||
// Redis 키 prefix
|
||||
const SUGGESTION_PREFIX = "suggestions:";
|
||||
const CACHE_TTL = 86400; // 24시간
|
||||
|
||||
// 추천 검색어로 노출되기 위한 최소 비율 (최대 검색 횟수 대비)
|
||||
// 예: 0.01 = 최대 검색 횟수의 1% 이상만 노출
|
||||
const MIN_COUNT_RATIO = 0.01;
|
||||
// 최소 임계값 (데이터가 적을 때 오타 방지)
|
||||
const MIN_COUNT_FLOOR = 10;
|
||||
|
||||
/**
|
||||
* 영문만 포함된 검색어인지 확인
|
||||
*/
|
||||
function isEnglishOnly(text) {
|
||||
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||
return englishChars.length > 0 && koreanChars.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색 결과가 있는지 확인 (Meilisearch)
|
||||
*/
|
||||
async function hasScheduleResults(query) {
|
||||
try {
|
||||
const result = await searchSchedules(query, { limit: 1 });
|
||||
return result.hits.length > 0;
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 검색 확인 오류:", error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영어 입력을 분석하여 실제 영어인지 한글 오타인지 판단
|
||||
* 1. 영어로 일정 검색 → 결과 있으면 영어
|
||||
* 2. 한글 변환 후 일정 검색 → 결과 있으면 한글
|
||||
* 3. 둘 다 없으면 원본 유지
|
||||
*/
|
||||
async function resolveEnglishInput(query) {
|
||||
const koreanQuery = inko.en2ko(query);
|
||||
|
||||
// 변환 결과가 같으면 변환 의미 없음
|
||||
if (koreanQuery === query) {
|
||||
return { resolved: query, type: "english" };
|
||||
}
|
||||
|
||||
// 1. 영어로 검색
|
||||
const hasEnglishResult = await hasScheduleResults(query);
|
||||
if (hasEnglishResult) {
|
||||
return { resolved: query, type: "english" };
|
||||
}
|
||||
|
||||
// 2. 한글로 검색
|
||||
const hasKoreanResult = await hasScheduleResults(koreanQuery);
|
||||
if (hasKoreanResult) {
|
||||
return { resolved: koreanQuery, type: "korean_typo" };
|
||||
}
|
||||
|
||||
// 3. 둘 다 없으면 원본 유지
|
||||
return { resolved: query, type: "unknown" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 저장 (검색 실행 시 호출)
|
||||
* - search_queries 테이블에 Unigram 저장
|
||||
* - word_pairs 테이블에 Bi-gram 저장
|
||||
* - Redis 캐시 업데이트
|
||||
* - 영어 입력 시 일정 검색으로 언어 판단
|
||||
*/
|
||||
export async function saveSearchQuery(query) {
|
||||
if (!query || query.trim().length === 0) return;
|
||||
|
||||
let normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
// 영문만 있는 경우 일정 검색으로 언어 판단
|
||||
if (isEnglishOnly(normalizedQuery)) {
|
||||
const { resolved, type } = await resolveEnglishInput(normalizedQuery);
|
||||
if (type === "korean_typo") {
|
||||
console.log(
|
||||
`[SearchSuggestion] 한글 오타 감지: "${normalizedQuery}" → "${resolved}"`
|
||||
);
|
||||
}
|
||||
normalizedQuery = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Unigram 저장 (인기도)
|
||||
await pool.query(
|
||||
`INSERT INTO search_queries (query, count)
|
||||
VALUES (?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
|
||||
[normalizedQuery]
|
||||
);
|
||||
|
||||
// 2. Bi-gram 저장 (다음 단어 예측)
|
||||
const words = normalizedQuery.split(/\s+/).filter((w) => w.length > 0);
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const word1 = words[i];
|
||||
const word2 = words[i + 1];
|
||||
|
||||
// DB 저장
|
||||
await pool.query(
|
||||
`INSERT INTO word_pairs (word1, word2, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1`,
|
||||
[word1, word2]
|
||||
);
|
||||
|
||||
// Redis 캐시 업데이트 (Sorted Set)
|
||||
await redis.zincrby(`${SUGGESTION_PREFIX}${word1}`, 1, word2);
|
||||
}
|
||||
|
||||
console.log(`[SearchSuggestion] 검색어 저장: "${normalizedQuery}"`);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 검색어 저장 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 검색어 조회
|
||||
* - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram)
|
||||
* - 그 외: prefix 매칭 (인기순)
|
||||
* - 영어 입력 시: 일정 검색으로 영어/한글 판단
|
||||
*/
|
||||
export async function getSuggestions(query, limit = 10) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let searchQuery = query.toLowerCase();
|
||||
let koreanQuery = null;
|
||||
|
||||
// 영문만 있는 경우, 한글 변환도 같이 검색
|
||||
if (isEnglishOnly(searchQuery)) {
|
||||
const converted = inko.en2ko(searchQuery);
|
||||
if (converted !== searchQuery) {
|
||||
koreanQuery = converted;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const endsWithSpace = query.endsWith(" ");
|
||||
const words = searchQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0);
|
||||
|
||||
if (endsWithSpace && words.length > 0) {
|
||||
// 다음 단어 예측 (Bi-gram)
|
||||
const lastWord = words[words.length - 1];
|
||||
return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
||||
} else {
|
||||
// prefix 매칭 (인기순) - 영어 원본 + 한글 변환 둘 다
|
||||
return await getPrefixSuggestions(
|
||||
searchQuery.trim(),
|
||||
koreanQuery?.trim(),
|
||||
limit
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 추천 조회 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 단어 예측 (Bi-gram 기반)
|
||||
* 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음
|
||||
*/
|
||||
async function getNextWordSuggestions(lastWord, prefix, limit) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT word2, count FROM word_pairs
|
||||
WHERE word1 = ?
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM word_pairs), ?)
|
||||
ORDER BY count DESC
|
||||
LIMIT ?`,
|
||||
[lastWord, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
|
||||
// prefix + 다음 단어 조합으로 반환
|
||||
return rows.map((r) => `${prefix} ${r.word2}`);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix 매칭 (인기순)
|
||||
* @param {string} prefix - 원본 검색어 (영어 또는 한글)
|
||||
* @param {string|null} koreanPrefix - 한글 변환된 검색어 (영어 입력의 경우)
|
||||
* @param {number} limit - 결과 개수
|
||||
*/
|
||||
async function getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
||||
try {
|
||||
let rows;
|
||||
|
||||
if (koreanPrefix) {
|
||||
// 영어 원본과 한글 변환 둘 다 검색
|
||||
[rows] = await pool.query(
|
||||
`SELECT query FROM search_queries
|
||||
WHERE (query LIKE ? OR query LIKE ?)
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, `${koreanPrefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
} else {
|
||||
// 단일 검색
|
||||
[rows] = await pool.query(
|
||||
`SELECT query FROM search_queries
|
||||
WHERE query LIKE ?
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
}
|
||||
|
||||
return rows.map((r) => r.query);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] Prefix 조회 오류:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 캐시 초기화 (필요시)
|
||||
*/
|
||||
export async function clearSuggestionCache() {
|
||||
try {
|
||||
const keys = await redis.keys(`${SUGGESTION_PREFIX}*`);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
console.log(`[SearchSuggestion] ${keys.length}개 캐시 삭제`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] 캐시 초기화 오류:", error.message);
|
||||
}
|
||||
}
|
||||
597
backend/services/x-bot.js
Normal file
597
backend/services/x-bot.js
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
/**
|
||||
* X 봇 서비스
|
||||
*
|
||||
* - Nitter를 통해 @realfromis_9 트윗 수집
|
||||
* - 트윗을 schedules 테이블에 저장
|
||||
* - 유튜브 링크 감지 시 별도 일정 추가
|
||||
*/
|
||||
|
||||
import pool from "../lib/db.js";
|
||||
import { addOrUpdateSchedule } from "./meilisearch.js";
|
||||
import {
|
||||
toKST,
|
||||
formatDate,
|
||||
formatTime,
|
||||
parseNitterDateTime,
|
||||
} from "../lib/date.js";
|
||||
|
||||
// YouTube API 키
|
||||
const YOUTUBE_API_KEY =
|
||||
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
|
||||
|
||||
// X 카테고리 ID
|
||||
const X_CATEGORY_ID = 3;
|
||||
|
||||
// 유튜브 카테고리 ID
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
|
||||
/**
|
||||
* 트윗 텍스트에서 첫 문단 추출 (title용)
|
||||
*/
|
||||
export function extractTitle(text) {
|
||||
if (!text) return "";
|
||||
|
||||
// 빈 줄(\n\n)로 분리하여 첫 문단 추출
|
||||
const paragraphs = text.split(/\n\n+/);
|
||||
const firstParagraph = paragraphs[0]?.trim() || "";
|
||||
|
||||
return firstParagraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 유튜브 videoId 추출
|
||||
*/
|
||||
export function extractYoutubeVideoIds(text) {
|
||||
if (!text) return [];
|
||||
|
||||
const videoIds = [];
|
||||
|
||||
// youtu.be/{videoId} 형식
|
||||
const shortMatches = text.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/g);
|
||||
if (shortMatches) {
|
||||
shortMatches.forEach((m) => {
|
||||
const id = m.replace("youtu.be/", "");
|
||||
if (id && id.length === 11) videoIds.push(id);
|
||||
});
|
||||
}
|
||||
|
||||
// youtube.com/watch?v={videoId} 형식
|
||||
const watchMatches = text.match(
|
||||
/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g
|
||||
);
|
||||
if (watchMatches) {
|
||||
watchMatches.forEach((m) => {
|
||||
const id = m.match(/v=([a-zA-Z0-9_-]{11})/)?.[1];
|
||||
if (id) videoIds.push(id);
|
||||
});
|
||||
}
|
||||
|
||||
// youtube.com/shorts/{videoId} 형식
|
||||
const shortsMatches = text.match(
|
||||
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g
|
||||
);
|
||||
if (shortsMatches) {
|
||||
shortsMatches.forEach((m) => {
|
||||
const id = m.match(/shorts\/([a-zA-Z0-9_-]{11})/)?.[1];
|
||||
if (id) videoIds.push(id);
|
||||
});
|
||||
}
|
||||
|
||||
return [...new Set(videoIds)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 중인 채널 ID 목록 조회
|
||||
*/
|
||||
export async function getManagedChannelIds() {
|
||||
const [configs] = await pool.query(
|
||||
"SELECT channel_id FROM bot_youtube_config"
|
||||
);
|
||||
return configs.map((c) => c.channel_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube API로 영상 정보 조회
|
||||
*/
|
||||
async function fetchVideoInfo(videoId) {
|
||||
try {
|
||||
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${YOUTUBE_API_KEY}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = data.items[0];
|
||||
const snippet = video.snippet;
|
||||
const duration = video.contentDetails?.duration || "";
|
||||
|
||||
// duration 파싱
|
||||
const durationMatch = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
let seconds = 0;
|
||||
if (durationMatch) {
|
||||
seconds =
|
||||
parseInt(durationMatch[1] || 0) * 3600 +
|
||||
parseInt(durationMatch[2] || 0) * 60 +
|
||||
parseInt(durationMatch[3] || 0);
|
||||
}
|
||||
|
||||
const isShorts = seconds > 0 && seconds <= 60;
|
||||
|
||||
return {
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || "",
|
||||
channelId: snippet.channelId,
|
||||
channelTitle: snippet.channelTitle,
|
||||
publishedAt: new Date(snippet.publishedAt),
|
||||
isShorts,
|
||||
videoUrl: isShorts
|
||||
? `https://www.youtube.com/shorts/${videoId}`
|
||||
: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`영상 정보 조회 오류 (${videoId}):`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||
*/
|
||||
async function fetchTweetsFromNitter(nitterUrl, username) {
|
||||
const url = `${nitterUrl}/${username}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const html = await response.text();
|
||||
|
||||
const tweets = [];
|
||||
const tweetContainers = html.split('class="timeline-item ');
|
||||
|
||||
for (let i = 1; i < tweetContainers.length; i++) {
|
||||
const container = tweetContainers[i];
|
||||
const tweet = {};
|
||||
|
||||
// 고정 트윗 체크
|
||||
tweet.isPinned =
|
||||
tweetContainers[i - 1].includes("pinned") || container.includes("Pinned");
|
||||
|
||||
// 리트윗 체크
|
||||
tweet.isRetweet = container.includes('class="retweet-header"');
|
||||
|
||||
// 트윗 ID 추출
|
||||
const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||
tweet.id = linkMatch ? linkMatch[1] : null;
|
||||
|
||||
// 시간 추출
|
||||
const timeMatch = container.match(
|
||||
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
|
||||
);
|
||||
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
|
||||
|
||||
// 텍스트 내용 추출
|
||||
const contentMatch = container.match(
|
||||
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
|
||||
);
|
||||
if (contentMatch) {
|
||||
tweet.text = contentMatch[1]
|
||||
.replace(/<br\s*\/?>/g, "\n")
|
||||
.replace(/<a[^>]*>([^<]*)<\/a>/g, "$1")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// URL 생성
|
||||
tweet.url = tweet.id
|
||||
? `https://x.com/${username}/status/${tweet.id}`
|
||||
: null;
|
||||
|
||||
if (tweet.id && !tweet.isRetweet && !tweet.isPinned) {
|
||||
tweets.push(tweet);
|
||||
}
|
||||
}
|
||||
|
||||
return tweets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 전체 트윗 수집 (페이지네이션)
|
||||
*/
|
||||
async function fetchAllTweetsFromNitter(nitterUrl, username) {
|
||||
const allTweets = [];
|
||||
let cursor = null;
|
||||
let pageNum = 1;
|
||||
let consecutiveEmpty = 0;
|
||||
const DELAY_MS = 1000;
|
||||
|
||||
while (true) {
|
||||
const url = cursor
|
||||
? `${nitterUrl}/${username}?cursor=${cursor}`
|
||||
: `${nitterUrl}/${username}`;
|
||||
|
||||
console.log(`[페이지 ${pageNum}] 스크래핑 중...`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const html = await response.text();
|
||||
|
||||
const tweets = [];
|
||||
const tweetContainers = html.split('class="timeline-item ');
|
||||
|
||||
for (let i = 1; i < tweetContainers.length; i++) {
|
||||
const container = tweetContainers[i];
|
||||
const tweet = {};
|
||||
|
||||
tweet.isPinned =
|
||||
tweetContainers[i - 1].includes("pinned") ||
|
||||
container.includes("Pinned");
|
||||
tweet.isRetweet = container.includes('class="retweet-header"');
|
||||
|
||||
const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||
tweet.id = linkMatch ? linkMatch[1] : null;
|
||||
|
||||
const timeMatch = container.match(
|
||||
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
|
||||
);
|
||||
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
|
||||
|
||||
const contentMatch = container.match(
|
||||
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
|
||||
);
|
||||
if (contentMatch) {
|
||||
tweet.text = contentMatch[1]
|
||||
.replace(/<br\s*\/?>/g, "\n")
|
||||
.replace(/<a[^>]*>([^<]*)<\/a>/g, "$1")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
tweet.url = tweet.id
|
||||
? `https://x.com/${username}/status/${tweet.id}`
|
||||
: null;
|
||||
|
||||
if (tweet.id && !tweet.isRetweet && !tweet.isPinned) {
|
||||
tweets.push(tweet);
|
||||
}
|
||||
}
|
||||
|
||||
if (tweets.length === 0) {
|
||||
consecutiveEmpty++;
|
||||
console.log(` -> 트윗 없음 (연속 ${consecutiveEmpty}회)`);
|
||||
if (consecutiveEmpty >= 3) break;
|
||||
} else {
|
||||
consecutiveEmpty = 0;
|
||||
allTweets.push(...tweets);
|
||||
console.log(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`);
|
||||
}
|
||||
|
||||
// 다음 페이지 cursor 추출
|
||||
const cursorMatch = html.match(
|
||||
/class="show-more"[^>]*>\s*<a href="\?cursor=([^"]+)"/
|
||||
);
|
||||
if (!cursorMatch) {
|
||||
console.log("\n다음 페이지 없음. 스크래핑 완료.");
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = cursorMatch[1];
|
||||
pageNum++;
|
||||
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS));
|
||||
} catch (error) {
|
||||
console.error(` -> 오류: ${error.message}`);
|
||||
consecutiveEmpty++;
|
||||
if (consecutiveEmpty >= 5) break;
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS * 3));
|
||||
}
|
||||
}
|
||||
|
||||
return allTweets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트윗을 일정으로 저장
|
||||
*/
|
||||
async function createScheduleFromTweet(tweet) {
|
||||
// source_url로 중복 체크
|
||||
const [existing] = await pool.query(
|
||||
"SELECT id FROM schedules WHERE source_url = ?",
|
||||
[tweet.url]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return null; // 이미 존재
|
||||
}
|
||||
|
||||
const kstDate = toKST(tweet.time);
|
||||
const date = formatDate(kstDate);
|
||||
const time = formatTime(kstDate);
|
||||
const title = extractTitle(tweet.text);
|
||||
const description = tweet.text;
|
||||
|
||||
// 일정 생성
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO schedules (title, description, date, time, category_id, source_url, source_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
|
||||
[title, description, date, time, X_CATEGORY_ID, tweet.url]
|
||||
);
|
||||
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// Meilisearch 동기화
|
||||
try {
|
||||
const [categoryInfo] = await pool.query(
|
||||
"SELECT name, color FROM schedule_categories WHERE id = ?",
|
||||
[X_CATEGORY_ID]
|
||||
);
|
||||
await addOrUpdateSchedule({
|
||||
id: scheduleId,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
time,
|
||||
category_id: X_CATEGORY_ID,
|
||||
category_name: categoryInfo[0]?.name || "",
|
||||
category_color: categoryInfo[0]?.color || "",
|
||||
source_name: null,
|
||||
source_url: tweet.url,
|
||||
members: [],
|
||||
});
|
||||
} catch (searchError) {
|
||||
console.error("Meilisearch 동기화 오류:", searchError.message);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 영상을 일정으로 저장
|
||||
*/
|
||||
async function createScheduleFromYoutube(video) {
|
||||
// source_url로 중복 체크
|
||||
const [existing] = await pool.query(
|
||||
"SELECT id FROM schedules WHERE source_url = ?",
|
||||
[video.videoUrl]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return null; // 이미 존재
|
||||
}
|
||||
|
||||
const kstDate = toKST(video.publishedAt);
|
||||
const date = formatDate(kstDate);
|
||||
const time = formatTime(kstDate);
|
||||
|
||||
// 일정 생성 (source_name에 채널명 저장)
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
video.title,
|
||||
date,
|
||||
time,
|
||||
YOUTUBE_CATEGORY_ID,
|
||||
video.videoUrl,
|
||||
video.channelTitle || null,
|
||||
]
|
||||
);
|
||||
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// Meilisearch 동기화
|
||||
try {
|
||||
const [categoryInfo] = await pool.query(
|
||||
"SELECT name, color FROM schedule_categories WHERE id = ?",
|
||||
[YOUTUBE_CATEGORY_ID]
|
||||
);
|
||||
await addOrUpdateSchedule({
|
||||
id: scheduleId,
|
||||
title: video.title,
|
||||
description: "",
|
||||
date,
|
||||
time,
|
||||
category_id: YOUTUBE_CATEGORY_ID,
|
||||
category_name: categoryInfo[0]?.name || "",
|
||||
category_color: categoryInfo[0]?.color || "",
|
||||
source_name: video.channelTitle || null,
|
||||
source_url: video.videoUrl,
|
||||
members: [],
|
||||
});
|
||||
} catch (searchError) {
|
||||
console.error("Meilisearch 동기화 오류:", searchError.message);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 트윗 동기화 (첫 페이지만 - 1분 간격 실행용)
|
||||
*/
|
||||
export async function syncNewTweets(botId) {
|
||||
try {
|
||||
// 봇 정보 조회
|
||||
const [bots] = await pool.query(
|
||||
`SELECT b.*, c.username, c.nitter_url
|
||||
FROM bots b
|
||||
LEFT JOIN bot_x_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?`,
|
||||
[botId]
|
||||
);
|
||||
|
||||
if (bots.length === 0) {
|
||||
throw new Error("봇을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
if (!bot.username) {
|
||||
throw new Error("Username이 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const nitterUrl = bot.nitter_url || "http://nitter:8080";
|
||||
|
||||
// 관리 중인 채널 목록 조회
|
||||
const managedChannelIds = await getManagedChannelIds();
|
||||
|
||||
// Nitter에서 트윗 수집 (첫 페이지만)
|
||||
const tweets = await fetchTweetsFromNitter(nitterUrl, bot.username);
|
||||
|
||||
let addedCount = 0;
|
||||
let ytAddedCount = 0;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
// 트윗 저장
|
||||
const scheduleId = await createScheduleFromTweet(tweet);
|
||||
if (scheduleId) addedCount++;
|
||||
|
||||
// 유튜브 링크 처리
|
||||
const videoIds = extractYoutubeVideoIds(tweet.text);
|
||||
for (const videoId of videoIds) {
|
||||
const video = await fetchVideoInfo(videoId);
|
||||
if (!video) continue;
|
||||
|
||||
// 관리 중인 채널이면 스킵
|
||||
if (managedChannelIds.includes(video.channelId)) continue;
|
||||
|
||||
// 유튜브 일정 저장
|
||||
const ytScheduleId = await createScheduleFromYoutube(video);
|
||||
if (ytScheduleId) ytAddedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 봇 상태 업데이트
|
||||
// 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지)
|
||||
const totalAdded = addedCount + ytAddedCount;
|
||||
if (totalAdded > 0) {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
schedules_added = schedules_added + ?,
|
||||
last_added_count = ?,
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[totalAdded, totalAdded, botId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[botId]
|
||||
);
|
||||
}
|
||||
|
||||
return { addedCount, ytAddedCount, total: tweets.length };
|
||||
} catch (error) {
|
||||
// 오류 상태 업데이트
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?`,
|
||||
[error.message, botId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 트윗 동기화 (전체 페이지 - 초기화용)
|
||||
*/
|
||||
export async function syncAllTweets(botId) {
|
||||
try {
|
||||
// 봇 정보 조회
|
||||
const [bots] = await pool.query(
|
||||
`SELECT b.*, c.username, c.nitter_url
|
||||
FROM bots b
|
||||
LEFT JOIN bot_x_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?`,
|
||||
[botId]
|
||||
);
|
||||
|
||||
if (bots.length === 0) {
|
||||
throw new Error("봇을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
if (!bot.username) {
|
||||
throw new Error("Username이 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const nitterUrl = bot.nitter_url || "http://nitter:8080";
|
||||
|
||||
// 관리 중인 채널 목록 조회
|
||||
const managedChannelIds = await getManagedChannelIds();
|
||||
|
||||
// Nitter에서 전체 트윗 수집
|
||||
const tweets = await fetchAllTweetsFromNitter(nitterUrl, bot.username);
|
||||
|
||||
let addedCount = 0;
|
||||
let ytAddedCount = 0;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
// 트윗 저장
|
||||
const scheduleId = await createScheduleFromTweet(tweet);
|
||||
if (scheduleId) addedCount++;
|
||||
|
||||
// 유튜브 링크 처리
|
||||
const videoIds = extractYoutubeVideoIds(tweet.text);
|
||||
for (const videoId of videoIds) {
|
||||
const video = await fetchVideoInfo(videoId);
|
||||
if (!video) continue;
|
||||
|
||||
// 관리 중인 채널이면 스킵
|
||||
if (managedChannelIds.includes(video.channelId)) continue;
|
||||
|
||||
// 유튜브 일정 저장
|
||||
const ytScheduleId = await createScheduleFromYoutube(video);
|
||||
if (ytScheduleId) ytAddedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 봇 상태 업데이트
|
||||
// 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지)
|
||||
const totalAdded = addedCount + ytAddedCount;
|
||||
if (totalAdded > 0) {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
schedules_added = schedules_added + ?,
|
||||
last_added_count = ?,
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[totalAdded, totalAdded, botId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[botId]
|
||||
);
|
||||
}
|
||||
|
||||
return { addedCount, ytAddedCount, total: tweets.length };
|
||||
} catch (error) {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?`,
|
||||
[error.message, botId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
syncNewTweets,
|
||||
syncAllTweets,
|
||||
extractTitle,
|
||||
extractYoutubeVideoIds,
|
||||
};
|
||||
648
backend/services/youtube-bot.js
Normal file
648
backend/services/youtube-bot.js
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
import Parser from "rss-parser";
|
||||
import pool from "../lib/db.js";
|
||||
import { addOrUpdateSchedule } from "./meilisearch.js";
|
||||
import { toKST, formatDate, formatTime } from "../lib/date.js";
|
||||
|
||||
// YouTube API 키
|
||||
const YOUTUBE_API_KEY =
|
||||
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
|
||||
|
||||
// 봇별 커스텀 설정 (DB 대신 코드에서 관리)
|
||||
// botId를 키로 사용
|
||||
const BOT_CUSTOM_CONFIG = {
|
||||
// MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출
|
||||
3: {
|
||||
titleFilter: "성수기",
|
||||
defaultMemberId: 7, // 이채영
|
||||
extractMembersFromDesc: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 봇 커스텀 설정 조회
|
||||
*/
|
||||
function getBotCustomConfig(botId) {
|
||||
return (
|
||||
BOT_CUSTOM_CONFIG[botId] || {
|
||||
titleFilter: null,
|
||||
defaultMemberId: null,
|
||||
extractMembersFromDesc: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// RSS 파서 설정 (media:description 포함)
|
||||
const rssParser = new Parser({
|
||||
customFields: {
|
||||
item: [
|
||||
["yt:videoId", "videoId"],
|
||||
["yt:channelId", "channelId"],
|
||||
["media:group", "mediaGroup"],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* '유튜브' 카테고리 ID 조회 (없으면 생성)
|
||||
*/
|
||||
export async function getYoutubeCategory() {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT id FROM schedule_categories WHERE name = '유튜브'"
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
// 없으면 생성
|
||||
const [result] = await pool.query(
|
||||
"INSERT INTO schedule_categories (name, color, sort_order) VALUES ('유튜브', '#ff0033', 99)"
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 URL에서 유형 판별 (video/shorts)
|
||||
*/
|
||||
export function getVideoType(url) {
|
||||
if (url.includes("/shorts/")) {
|
||||
return "shorts";
|
||||
}
|
||||
return "video";
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 URL 생성
|
||||
*/
|
||||
export function getVideoUrl(videoId, videoType) {
|
||||
if (videoType === "shorts") {
|
||||
return `https://www.youtube.com/shorts/${videoId}`;
|
||||
}
|
||||
return `https://www.youtube.com/watch?v=${videoId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* RSS 피드 파싱하여 영상 목록 반환
|
||||
*/
|
||||
export async function parseRSSFeed(rssUrl) {
|
||||
try {
|
||||
const feed = await rssParser.parseURL(rssUrl);
|
||||
|
||||
return feed.items.map((item) => {
|
||||
const videoId = item.videoId;
|
||||
const link = item.link || "";
|
||||
const videoType = getVideoType(link);
|
||||
const publishedAt = toKST(new Date(item.pubDate));
|
||||
|
||||
// media:group에서 description 추출
|
||||
let description = "";
|
||||
if (item.mediaGroup && item.mediaGroup["media:description"]) {
|
||||
description = item.mediaGroup["media:description"][0] || "";
|
||||
}
|
||||
|
||||
return {
|
||||
videoId,
|
||||
title: item.title,
|
||||
description,
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoUrl: link || getVideoUrl(videoId, videoType),
|
||||
videoType,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("RSS 파싱 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 8601 duration (PT1M30S) → 초 변환
|
||||
*/
|
||||
function parseDuration(duration) {
|
||||
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
if (!match) return 0;
|
||||
|
||||
const hours = parseInt(match[1] || 0);
|
||||
const minutes = parseInt(match[2] || 0);
|
||||
const seconds = parseInt(match[3] || 0);
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube API로 최근 N개 영상 수집 (정기 동기화용)
|
||||
* @param {string} channelId - 채널 ID
|
||||
* @param {number} maxResults - 조회할 영상 수 (기본 10)
|
||||
*/
|
||||
export async function fetchRecentVideosFromAPI(channelId, maxResults = 10) {
|
||||
const videos = [];
|
||||
|
||||
try {
|
||||
// 채널의 업로드 플레이리스트 ID 조회
|
||||
const channelResponse = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}`
|
||||
);
|
||||
const channelData = await channelResponse.json();
|
||||
|
||||
if (!channelData.items || channelData.items.length === 0) {
|
||||
throw new Error("채널을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const uploadsPlaylistId =
|
||||
channelData.items[0].contentDetails.relatedPlaylists.uploads;
|
||||
|
||||
// 플레이리스트 아이템 조회 (최근 N개만)
|
||||
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=${maxResults}&key=${YOUTUBE_API_KEY}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
// 영상 ID 목록 추출
|
||||
const videoIds = data.items.map((item) => item.snippet.resourceId.videoId);
|
||||
|
||||
// videos API로 duration 조회 (Shorts 판별용)
|
||||
const videosResponse = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join(
|
||||
","
|
||||
)}&key=${YOUTUBE_API_KEY}`
|
||||
);
|
||||
const videosData = await videosResponse.json();
|
||||
|
||||
// duration으로 Shorts 판별 맵 생성
|
||||
const durationMap = {};
|
||||
if (videosData.items) {
|
||||
for (const v of videosData.items) {
|
||||
const duration = v.contentDetails.duration;
|
||||
const seconds = parseDuration(duration);
|
||||
durationMap[v.id] = seconds <= 60 ? "shorts" : "video";
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of data.items) {
|
||||
const snippet = item.snippet;
|
||||
const videoId = snippet.resourceId.videoId;
|
||||
const publishedAt = toKST(new Date(snippet.publishedAt));
|
||||
const videoType = durationMap[videoId] || "video";
|
||||
|
||||
videos.push({
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || "",
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoUrl: getVideoUrl(videoId, videoType),
|
||||
videoType,
|
||||
});
|
||||
}
|
||||
|
||||
return videos;
|
||||
} catch (error) {
|
||||
console.error("YouTube API 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube API로 전체 영상 수집 (초기 동기화용)
|
||||
* Shorts 판별: duration이 60초 이하이면 Shorts
|
||||
*/
|
||||
export async function fetchAllVideosFromAPI(channelId) {
|
||||
const videos = [];
|
||||
let pageToken = "";
|
||||
|
||||
try {
|
||||
// 채널의 업로드 플레이리스트 ID 조회
|
||||
const channelResponse = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}`
|
||||
);
|
||||
const channelData = await channelResponse.json();
|
||||
|
||||
if (!channelData.items || channelData.items.length === 0) {
|
||||
throw new Error("채널을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const uploadsPlaylistId =
|
||||
channelData.items[0].contentDetails.relatedPlaylists.uploads;
|
||||
|
||||
// 플레이리스트 아이템 조회 (페이징)
|
||||
do {
|
||||
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=50&key=${YOUTUBE_API_KEY}${
|
||||
pageToken ? `&pageToken=${pageToken}` : ""
|
||||
}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
// 영상 ID 목록 추출
|
||||
const videoIds = data.items.map(
|
||||
(item) => item.snippet.resourceId.videoId
|
||||
);
|
||||
|
||||
// videos API로 duration 조회 (50개씩 배치)
|
||||
const videosResponse = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join(
|
||||
","
|
||||
)}&key=${YOUTUBE_API_KEY}`
|
||||
);
|
||||
const videosData = await videosResponse.json();
|
||||
|
||||
// duration으로 Shorts 판별 맵 생성
|
||||
const durationMap = {};
|
||||
if (videosData.items) {
|
||||
for (const v of videosData.items) {
|
||||
const duration = v.contentDetails.duration;
|
||||
const seconds = parseDuration(duration);
|
||||
durationMap[v.id] = seconds <= 60 ? "shorts" : "video";
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of data.items) {
|
||||
const snippet = item.snippet;
|
||||
const videoId = snippet.resourceId.videoId;
|
||||
const publishedAt = toKST(new Date(snippet.publishedAt));
|
||||
const videoType = durationMap[videoId] || "video";
|
||||
|
||||
videos.push({
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || "",
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoUrl: getVideoUrl(videoId, videoType),
|
||||
videoType,
|
||||
});
|
||||
}
|
||||
|
||||
pageToken = data.nextPageToken || "";
|
||||
} while (pageToken);
|
||||
|
||||
// 과거순 정렬 (오래된 영상부터 추가)
|
||||
videos.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt));
|
||||
|
||||
return videos;
|
||||
} catch (error) {
|
||||
console.error("YouTube API 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상을 일정으로 추가 (source_url로 중복 체크)
|
||||
* @param {Object} video - 영상 정보
|
||||
* @param {number} categoryId - 카테고리 ID
|
||||
* @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택)
|
||||
* @param {string} sourceName - 출처 이름 (선택)
|
||||
*/
|
||||
export async function createScheduleFromVideo(
|
||||
video,
|
||||
categoryId,
|
||||
memberIds = [],
|
||||
sourceName = null
|
||||
) {
|
||||
try {
|
||||
// source_url로 중복 체크
|
||||
const [existing] = await pool.query(
|
||||
"SELECT id FROM schedules WHERE source_url = ?",
|
||||
[video.videoUrl]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return null; // 이미 존재
|
||||
}
|
||||
|
||||
// 일정 생성
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
video.title,
|
||||
video.date,
|
||||
video.time,
|
||||
categoryId,
|
||||
video.videoUrl,
|
||||
sourceName,
|
||||
]
|
||||
);
|
||||
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// 멤버 연결
|
||||
if (memberIds.length > 0) {
|
||||
const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거
|
||||
const memberValues = uniqueMemberIds.map((memberId) => [
|
||||
scheduleId,
|
||||
memberId,
|
||||
]);
|
||||
await pool.query(
|
||||
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
|
||||
[memberValues]
|
||||
);
|
||||
}
|
||||
|
||||
// Meilisearch에 동기화
|
||||
try {
|
||||
const [categoryInfo] = await pool.query(
|
||||
"SELECT name, color FROM schedule_categories WHERE id = ?",
|
||||
[categoryId]
|
||||
);
|
||||
const [memberInfo] = await pool.query(
|
||||
"SELECT id, name FROM members WHERE id IN (?)",
|
||||
[memberIds.length > 0 ? [...new Set(memberIds)] : [0]]
|
||||
);
|
||||
await addOrUpdateSchedule({
|
||||
id: scheduleId,
|
||||
title: video.title,
|
||||
description: "",
|
||||
date: video.date,
|
||||
time: video.time,
|
||||
category_id: categoryId,
|
||||
category_name: categoryInfo[0]?.name || "",
|
||||
category_color: categoryInfo[0]?.color || "",
|
||||
source_name: sourceName,
|
||||
source_url: video.videoUrl,
|
||||
members: memberInfo,
|
||||
});
|
||||
} catch (searchError) {
|
||||
console.error("Meilisearch 동기화 오류:", searchError.message);
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
} catch (error) {
|
||||
console.error("일정 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 이름 목록 조회
|
||||
*/
|
||||
async function getMemberNameMap() {
|
||||
const [members] = await pool.query("SELECT id, name FROM members");
|
||||
const nameMap = {};
|
||||
for (const m of members) {
|
||||
nameMap[m.name] = m.id;
|
||||
}
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* description에서 멤버 이름 추출
|
||||
*/
|
||||
function extractMemberIdsFromDescription(description, memberNameMap) {
|
||||
if (!description) return [];
|
||||
|
||||
const memberIds = [];
|
||||
for (const [name, id] of Object.entries(memberNameMap)) {
|
||||
if (description.includes(name)) {
|
||||
memberIds.push(id);
|
||||
}
|
||||
}
|
||||
return memberIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇의 새 영상 동기화 (YouTube API 기반)
|
||||
*/
|
||||
export async function syncNewVideos(botId) {
|
||||
try {
|
||||
// 봇 정보 조회 (bot_youtube_config 조인)
|
||||
const [bots] = await pool.query(
|
||||
`
|
||||
SELECT b.*, c.channel_id
|
||||
FROM bots b
|
||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?
|
||||
`,
|
||||
[botId]
|
||||
);
|
||||
|
||||
if (bots.length === 0) {
|
||||
throw new Error("봇을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
if (!bot.channel_id) {
|
||||
throw new Error("Channel ID가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
// 봇별 커스텀 설정 조회
|
||||
const customConfig = getBotCustomConfig(botId);
|
||||
|
||||
const categoryId = await getYoutubeCategory();
|
||||
|
||||
// YouTube API로 최근 10개 영상 조회
|
||||
const videos = await fetchRecentVideosFromAPI(bot.channel_id, 10);
|
||||
let addedCount = 0;
|
||||
|
||||
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
||||
let memberNameMap = null;
|
||||
if (customConfig.extractMembersFromDesc) {
|
||||
memberNameMap = await getMemberNameMap();
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
// 제목 필터 적용 (설정된 경우)
|
||||
if (
|
||||
customConfig.titleFilter &&
|
||||
!video.title.includes(customConfig.titleFilter)
|
||||
) {
|
||||
continue; // 필터에 맞지 않으면 스킵
|
||||
}
|
||||
|
||||
// 멤버 ID 수집
|
||||
const memberIds = [];
|
||||
|
||||
// 기본 멤버 추가
|
||||
if (customConfig.defaultMemberId) {
|
||||
memberIds.push(customConfig.defaultMemberId);
|
||||
}
|
||||
|
||||
// description에서 멤버 추출 (설정된 경우)
|
||||
if (customConfig.extractMembersFromDesc && memberNameMap) {
|
||||
const extractedIds = extractMemberIdsFromDescription(
|
||||
video.description,
|
||||
memberNameMap
|
||||
);
|
||||
memberIds.push(...extractedIds);
|
||||
}
|
||||
|
||||
const scheduleId = await createScheduleFromVideo(
|
||||
video,
|
||||
categoryId,
|
||||
memberIds,
|
||||
bot.name
|
||||
);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수)
|
||||
// addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지)
|
||||
if (addedCount > 0) {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
schedules_added = schedules_added + ?,
|
||||
last_added_count = ?,
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[addedCount, addedCount, botId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[botId]
|
||||
);
|
||||
}
|
||||
|
||||
return { addedCount, total: videos.length };
|
||||
} catch (error) {
|
||||
// 오류 상태 업데이트
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?`,
|
||||
[error.message, botId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 영상 동기화 (API 기반, 초기화용)
|
||||
*/
|
||||
export async function syncAllVideos(botId) {
|
||||
try {
|
||||
// 봇 정보 조회 (bot_youtube_config 조인)
|
||||
const [bots] = await pool.query(
|
||||
`
|
||||
SELECT b.*, c.channel_id
|
||||
FROM bots b
|
||||
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
||||
WHERE b.id = ?
|
||||
`,
|
||||
[botId]
|
||||
);
|
||||
|
||||
if (bots.length === 0) {
|
||||
throw new Error("봇을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
if (!bot.channel_id) {
|
||||
throw new Error("Channel ID가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
// 봇별 커스텀 설정 조회
|
||||
const customConfig = getBotCustomConfig(botId);
|
||||
|
||||
const categoryId = await getYoutubeCategory();
|
||||
|
||||
// API로 전체 영상 수집
|
||||
const videos = await fetchAllVideosFromAPI(bot.channel_id);
|
||||
let addedCount = 0;
|
||||
|
||||
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
||||
let memberNameMap = null;
|
||||
if (customConfig.extractMembersFromDesc) {
|
||||
memberNameMap = await getMemberNameMap();
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
// 제목 필터 적용 (설정된 경우)
|
||||
if (
|
||||
customConfig.titleFilter &&
|
||||
!video.title.includes(customConfig.titleFilter)
|
||||
) {
|
||||
continue; // 필터에 맞지 않으면 스킵
|
||||
}
|
||||
|
||||
// 멤버 ID 수집
|
||||
const memberIds = [];
|
||||
|
||||
// 기본 멤버 추가
|
||||
if (customConfig.defaultMemberId) {
|
||||
memberIds.push(customConfig.defaultMemberId);
|
||||
}
|
||||
|
||||
// description에서 멤버 추출 (설정된 경우)
|
||||
if (customConfig.extractMembersFromDesc && memberNameMap) {
|
||||
const extractedIds = extractMemberIdsFromDescription(
|
||||
video.description,
|
||||
memberNameMap
|
||||
);
|
||||
memberIds.push(...extractedIds);
|
||||
}
|
||||
|
||||
const scheduleId = await createScheduleFromVideo(
|
||||
video,
|
||||
categoryId,
|
||||
memberIds,
|
||||
bot.name
|
||||
);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수)
|
||||
// addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지)
|
||||
if (addedCount > 0) {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
schedules_added = schedules_added + ?,
|
||||
last_added_count = ?,
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[addedCount, addedCount, botId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
last_check_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[botId]
|
||||
);
|
||||
}
|
||||
|
||||
return { addedCount, total: videos.length };
|
||||
} catch (error) {
|
||||
await pool.query(
|
||||
`UPDATE bots SET
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?`,
|
||||
[error.message, botId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
parseRSSFeed,
|
||||
fetchAllVideosFromAPI,
|
||||
syncNewVideos,
|
||||
syncAllVideos,
|
||||
getYoutubeCategory,
|
||||
};
|
||||
201
backend/services/youtube-scheduler.js
Normal file
201
backend/services/youtube-scheduler.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import cron from "node-cron";
|
||||
import pool from "../lib/db.js";
|
||||
import { syncNewVideos } from "./youtube-bot.js";
|
||||
import { syncNewTweets } from "./x-bot.js";
|
||||
import { syncAllSchedules } from "./meilisearch-bot.js";
|
||||
|
||||
// 봇별 스케줄러 인스턴스 저장
|
||||
const schedulers = new Map();
|
||||
|
||||
/**
|
||||
* 봇 타입에 따라 적절한 동기화 함수 호출
|
||||
*/
|
||||
async function syncBot(botId) {
|
||||
const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [
|
||||
botId,
|
||||
]);
|
||||
if (bots.length === 0) throw new Error("봇을 찾을 수 없습니다.");
|
||||
|
||||
const botType = bots[0].type;
|
||||
|
||||
if (botType === "youtube") {
|
||||
return await syncNewVideos(botId);
|
||||
} else if (botType === "x") {
|
||||
return await syncNewTweets(botId);
|
||||
} else if (botType === "meilisearch") {
|
||||
return await syncAllSchedules(botId);
|
||||
} else {
|
||||
throw new Error(`지원하지 않는 봇 타입: ${botType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇이 메모리에서 실행 중인지 확인
|
||||
*/
|
||||
export function isBotRunning(botId) {
|
||||
const id = parseInt(botId);
|
||||
return schedulers.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 봇 스케줄 등록
|
||||
*/
|
||||
export function registerBot(botId, intervalMinutes = 2, cronExpression = null) {
|
||||
const id = parseInt(botId);
|
||||
// 기존 스케줄이 있으면 제거
|
||||
unregisterBot(id);
|
||||
|
||||
// cron 표현식: 지정된 표현식 사용, 없으면 기본값 생성
|
||||
const expression = cronExpression || `1-59/${intervalMinutes} * * * *`;
|
||||
|
||||
const task = cron.schedule(expression, async () => {
|
||||
console.log(`[Bot ${id}] 동기화 시작...`);
|
||||
try {
|
||||
const result = await syncBot(id);
|
||||
console.log(`[Bot ${id}] 동기화 완료: ${result.addedCount}개 추가`);
|
||||
} catch (error) {
|
||||
console.error(`[Bot ${id}] 동기화 오류:`, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
schedulers.set(id, task);
|
||||
console.log(`[Bot ${id}] 스케줄 등록됨 (cron: ${expression})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 봇 스케줄 해제
|
||||
*/
|
||||
export function unregisterBot(botId) {
|
||||
const id = parseInt(botId);
|
||||
if (schedulers.has(id)) {
|
||||
schedulers.get(id).stop();
|
||||
schedulers.delete(id);
|
||||
console.log(`[Bot ${id}] 스케줄 해제됨`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 10초 간격으로 메모리 상태와 DB status 동기화
|
||||
*/
|
||||
async function syncBotStatuses() {
|
||||
try {
|
||||
const [bots] = await pool.query("SELECT id, status FROM bots");
|
||||
|
||||
for (const bot of bots) {
|
||||
const botId = parseInt(bot.id);
|
||||
const isRunningInMemory = schedulers.has(botId);
|
||||
const isRunningInDB = bot.status === "running";
|
||||
|
||||
// 메모리에 없는데 DB가 running이면 → 서버 크래시 등으로 불일치
|
||||
// 이 경우 DB를 stopped로 변경하는 대신, 메모리에 봇을 다시 등록
|
||||
if (!isRunningInMemory && isRunningInDB) {
|
||||
console.log(`[Scheduler] Bot ${botId} 메모리에 없음, 재등록 시도...`);
|
||||
try {
|
||||
const [botInfo] = await pool.query(
|
||||
"SELECT check_interval, cron_expression FROM bots WHERE id = ?",
|
||||
[botId]
|
||||
);
|
||||
if (botInfo.length > 0) {
|
||||
const { check_interval, cron_expression } = botInfo[0];
|
||||
// 직접 registerBot 함수 호출 (import 순환 방지를 위해 내부 로직 사용)
|
||||
const expression =
|
||||
cron_expression || `1-59/${check_interval} * * * *`;
|
||||
const task = cron.schedule(expression, async () => {
|
||||
console.log(`[Bot ${botId}] 동기화 시작...`);
|
||||
try {
|
||||
const result = await syncBot(botId);
|
||||
console.log(
|
||||
`[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[Bot ${botId}] 동기화 오류:`, error.message);
|
||||
}
|
||||
});
|
||||
schedulers.set(botId, task);
|
||||
console.log(
|
||||
`[Scheduler] Bot ${botId} 재등록 완료 (cron: ${expression})`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Bot ${botId} 재등록 오류:`, error.message);
|
||||
// 재등록 실패 시에만 stopped로 변경
|
||||
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [
|
||||
botId,
|
||||
]);
|
||||
console.log(`[Scheduler] Bot ${botId} 상태 동기화: stopped`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Scheduler] 상태 동기화 오류:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시작 시 실행 중인 봇들 스케줄 등록
|
||||
*/
|
||||
export async function initScheduler() {
|
||||
try {
|
||||
const [bots] = await pool.query(
|
||||
"SELECT id, check_interval, cron_expression FROM bots WHERE status = 'running'"
|
||||
);
|
||||
|
||||
for (const bot of bots) {
|
||||
registerBot(bot.id, bot.check_interval, bot.cron_expression);
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`);
|
||||
|
||||
// 10초 간격으로 상태 동기화 (DB status와 메모리 상태 일치 유지)
|
||||
setInterval(syncBotStatuses, 10000);
|
||||
console.log(`[Scheduler] 10초 간격 상태 동기화 시작`);
|
||||
} catch (error) {
|
||||
console.error("[Scheduler] 초기화 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
*/
|
||||
export async function startBot(botId) {
|
||||
const [bots] = await pool.query("SELECT * FROM bots WHERE id = ?", [botId]);
|
||||
if (bots.length === 0) {
|
||||
throw new Error("봇을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const bot = bots[0];
|
||||
|
||||
// 스케줄 등록 (cron_expression 우선 사용)
|
||||
registerBot(botId, bot.check_interval, bot.cron_expression);
|
||||
|
||||
// 상태 업데이트
|
||||
await pool.query(
|
||||
"UPDATE bots SET status = 'running', error_message = NULL WHERE id = ?",
|
||||
[botId]
|
||||
);
|
||||
|
||||
// 즉시 1회 실행
|
||||
try {
|
||||
await syncBot(botId);
|
||||
} catch (error) {
|
||||
console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 정지
|
||||
*/
|
||||
export async function stopBot(botId) {
|
||||
unregisterBot(botId);
|
||||
|
||||
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [botId]);
|
||||
}
|
||||
|
||||
export default {
|
||||
initScheduler,
|
||||
registerBot,
|
||||
unregisterBot,
|
||||
startBot,
|
||||
stopBot,
|
||||
isBotRunning,
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
-- 추천 검색어 테이블
|
||||
|
||||
-- 검색어 테이블 (Unigram)
|
||||
CREATE TABLE IF NOT EXISTS suggestion_queries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
query VARCHAR(255) NOT NULL UNIQUE,
|
||||
count INT DEFAULT 1,
|
||||
last_searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_query_prefix (query(50)),
|
||||
INDEX idx_count (count DESC),
|
||||
INDEX idx_last_searched (last_searched_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 단어 쌍 테이블 (Bi-gram)
|
||||
CREATE TABLE IF NOT EXISTS suggestion_word_pairs (
|
||||
word1 VARCHAR(100) NOT NULL,
|
||||
word2 VARCHAR(100) NOT NULL,
|
||||
count INT DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (word1, word2),
|
||||
INDEX idx_word1_count (word1, count DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 초성 인덱스 테이블
|
||||
CREATE TABLE IF NOT EXISTS suggestion_chosung (
|
||||
chosung VARCHAR(50) NOT NULL,
|
||||
word VARCHAR(100) NOT NULL,
|
||||
count INT DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (chosung, word),
|
||||
INDEX idx_chosung (chosung),
|
||||
INDEX idx_count (count DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import scalarApiReference from '@scalar/fastify-api-reference';
|
||||
import multipart from '@fastify/multipart';
|
||||
import config from './config/index.js';
|
||||
|
||||
// 플러그인
|
||||
import dbPlugin from './plugins/db.js';
|
||||
import redisPlugin from './plugins/redis.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import meilisearchPlugin from './plugins/meilisearch.js';
|
||||
import youtubeBotPlugin from './services/youtube/index.js';
|
||||
import xBotPlugin from './services/x/index.js';
|
||||
import schedulerPlugin from './plugins/scheduler.js';
|
||||
|
||||
// 라우트
|
||||
import routes from './routes/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export async function buildApp(opts = {}) {
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: opts.logLevel || 'info',
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
|
||||
// config 데코레이터 등록
|
||||
fastify.decorate('config', config);
|
||||
|
||||
// multipart 플러그인 등록 (파일 업로드용)
|
||||
await fastify.register(multipart, {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
},
|
||||
});
|
||||
|
||||
// 플러그인 등록 (순서 중요)
|
||||
await fastify.register(dbPlugin);
|
||||
await fastify.register(redisPlugin);
|
||||
await fastify.register(authPlugin);
|
||||
await fastify.register(meilisearchPlugin);
|
||||
await fastify.register(youtubeBotPlugin);
|
||||
await fastify.register(xBotPlugin);
|
||||
await fastify.register(schedulerPlugin);
|
||||
|
||||
// Swagger (OpenAPI) 설정
|
||||
await fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'fromis_9 API',
|
||||
description: 'fromis_9 팬사이트 백엔드 API',
|
||||
version: '2.0.0',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Current server' },
|
||||
],
|
||||
tags: [
|
||||
{ name: 'auth', description: '인증 API' },
|
||||
{ name: 'members', description: '멤버 API' },
|
||||
{ name: 'albums', description: '앨범 API' },
|
||||
{ name: 'schedules', description: '일정 API' },
|
||||
{ name: 'stats', description: '통계 API' },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Scalar API Reference UI
|
||||
await fastify.register(scalarApiReference, {
|
||||
routePrefix: '/docs',
|
||||
configuration: {
|
||||
theme: 'purple',
|
||||
spec: {
|
||||
url: '/docs/json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// OpenAPI JSON 엔드포인트
|
||||
fastify.get('/docs/json', { schema: { hide: true } }, async () => {
|
||||
return fastify.swagger();
|
||||
});
|
||||
|
||||
// 라우트 등록
|
||||
await fastify.register(routes, { prefix: '/api' });
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
fastify.get('/api/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
// 봇 상태 조회 엔드포인트
|
||||
fastify.get('/api/bots', async () => {
|
||||
const bots = fastify.scheduler.getBots();
|
||||
const statuses = await Promise.all(
|
||||
bots.map(async bot => {
|
||||
const status = await fastify.scheduler.getStatus(bot.id);
|
||||
return { ...bot, ...status };
|
||||
})
|
||||
);
|
||||
return statuses;
|
||||
});
|
||||
|
||||
// 정적 파일 서빙 (프론트엔드 빌드 결과물) - 프로덕션 모드에서만
|
||||
const distPath = path.join(__dirname, '../dist');
|
||||
if (fs.existsSync(distPath)) {
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: distPath,
|
||||
prefix: '/',
|
||||
});
|
||||
|
||||
// SPA fallback - API 라우트가 아닌 모든 요청에 index.html 반환
|
||||
fastify.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
return reply.code(404).send({ error: 'Not found' });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
return fastify;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
export default [
|
||||
{
|
||||
id: 'youtube-fromis9',
|
||||
type: 'youtube',
|
||||
channelId: 'UCXbRURMKT3H_w8dT-DWLIxA',
|
||||
channelName: 'fromis_9',
|
||||
cron: '*/2 * * * *',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'youtube-studio',
|
||||
type: 'youtube',
|
||||
channelId: 'UCeUJ8B3krxw8zuDi19AlhaA',
|
||||
channelName: '스프 : 스튜디오 프로미스나인',
|
||||
cron: '*/2 * * * *',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'youtube-musinsa',
|
||||
type: 'youtube',
|
||||
channelId: 'UCtfyAiqf095_0_ux8ruwGfA',
|
||||
channelName: 'MUSINSA TV',
|
||||
cron: '*/2 * * * *',
|
||||
enabled: true,
|
||||
titleFilter: '성수기',
|
||||
defaultMemberId: 7,
|
||||
extractMembersFromDesc: true,
|
||||
},
|
||||
{
|
||||
id: 'x-fromis9',
|
||||
type: 'x',
|
||||
username: 'realfromis_9',
|
||||
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
|
||||
cron: '*/1 * * * *',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
export default {
|
||||
server: {
|
||||
port: parseInt(process.env.PORT) || 80,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
db: {
|
||||
host: process.env.DB_HOST || 'mariadb',
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'fromis9',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'fromis9',
|
||||
connectionLimit: 10,
|
||||
waitForConnections: true,
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'fromis9-redis',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
},
|
||||
youtube: {
|
||||
apiKey: process.env.YOUTUBE_API_KEY,
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
|
||||
expiresIn: '30d',
|
||||
},
|
||||
s3: {
|
||||
endpoint: process.env.RUSTFS_ENDPOINT,
|
||||
accessKey: process.env.RUSTFS_ACCESS_KEY,
|
||||
secretKey: process.env.RUSTFS_SECRET_KEY,
|
||||
bucket: process.env.RUSTFS_BUCKET || 'fromis-9',
|
||||
publicUrl: process.env.RUSTFS_PUBLIC_URL,
|
||||
},
|
||||
meilisearch: {
|
||||
host: process.env.MEILI_HOST || 'http://fromis9-meilisearch:7700',
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import fastifyJwt from '@fastify/jwt';
|
||||
import config from '../config/index.js';
|
||||
|
||||
async function authPlugin(fastify, opts) {
|
||||
// JWT 플러그인 등록
|
||||
await fastify.register(fastifyJwt, {
|
||||
secret: config.jwt.secret,
|
||||
sign: {
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
},
|
||||
});
|
||||
|
||||
// 인증 데코레이터
|
||||
fastify.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: '인증이 필요합니다.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(authPlugin, {
|
||||
name: 'auth',
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
async function dbPlugin(fastify, opts) {
|
||||
const pool = mysql.createPool(fastify.config.db);
|
||||
|
||||
// 연결 테스트
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
fastify.log.info('MariaDB 연결 성공');
|
||||
conn.release();
|
||||
} catch (err) {
|
||||
fastify.log.error('MariaDB 연결 실패:', err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
fastify.decorate('db', pool);
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await pool.end();
|
||||
fastify.log.info('MariaDB 연결 종료');
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(dbPlugin, { name: 'db' });
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
|
||||
const INDEX_NAME = 'schedules';
|
||||
|
||||
async function meilisearchPlugin(fastify, opts) {
|
||||
const { host, apiKey } = fastify.config.meilisearch;
|
||||
|
||||
const client = new MeiliSearch({
|
||||
host,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// 연결 테스트 및 인덱스 초기화
|
||||
try {
|
||||
await client.health();
|
||||
fastify.log.info('Meilisearch 연결 성공');
|
||||
|
||||
// 인덱스 초기화
|
||||
await initIndex(client, fastify.log);
|
||||
} catch (err) {
|
||||
fastify.log.error('Meilisearch 연결 실패:', err.message);
|
||||
// Meilisearch가 없어도 서버는 동작하도록 에러를 던지지 않음
|
||||
}
|
||||
|
||||
fastify.decorate('meilisearch', client);
|
||||
fastify.decorate('meilisearchIndex', INDEX_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 초기화 및 설정
|
||||
*/
|
||||
async function initIndex(client, log) {
|
||||
try {
|
||||
// 인덱스 생성 (이미 존재하면 무시)
|
||||
try {
|
||||
await client.createIndex(INDEX_NAME, { primaryKey: 'id' });
|
||||
} catch (err) {
|
||||
// 이미 존재하는 경우 무시
|
||||
}
|
||||
|
||||
const index = client.index(INDEX_NAME);
|
||||
|
||||
// 검색 가능한 필드 설정 (순서가 우선순위 결정)
|
||||
await index.updateSearchableAttributes([
|
||||
'title',
|
||||
'member_names',
|
||||
'description',
|
||||
'source_name',
|
||||
'category_name',
|
||||
]);
|
||||
|
||||
// 필터링 가능한 필드 설정
|
||||
await index.updateFilterableAttributes(['category_id', 'date']);
|
||||
|
||||
// 정렬 가능한 필드 설정
|
||||
await index.updateSortableAttributes(['date', 'time']);
|
||||
|
||||
// 랭킹 규칙 설정 (동일 유사도일 때 최신 날짜 우선)
|
||||
await index.updateRankingRules([
|
||||
'words',
|
||||
'typo',
|
||||
'proximity',
|
||||
'attribute',
|
||||
'exactness',
|
||||
'date:desc',
|
||||
]);
|
||||
|
||||
// 오타 허용 설정
|
||||
await index.updateTypoTolerance({
|
||||
enabled: true,
|
||||
minWordSizeForTypos: {
|
||||
oneTypo: 2,
|
||||
twoTypos: 4,
|
||||
},
|
||||
});
|
||||
|
||||
// 페이징 설정
|
||||
await index.updatePagination({
|
||||
maxTotalHits: 10000,
|
||||
});
|
||||
|
||||
log.info('Meilisearch 인덱스 초기화 완료');
|
||||
} catch (err) {
|
||||
log.error('Meilisearch 인덱스 초기화 오류:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(meilisearchPlugin, {
|
||||
name: 'meilisearch',
|
||||
dependencies: ['db'],
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
async function redisPlugin(fastify, opts) {
|
||||
const redis = new Redis({
|
||||
host: fastify.config.redis.host,
|
||||
port: fastify.config.redis.port,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
fastify.log.info('Redis 연결 성공');
|
||||
} catch (err) {
|
||||
fastify.log.error('Redis 연결 실패:', err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
fastify.decorate('redis', redis);
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await redis.quit();
|
||||
fastify.log.info('Redis 연결 종료');
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(redisPlugin, { name: 'redis' });
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import cron from 'node-cron';
|
||||
import bots from '../config/bots.js';
|
||||
|
||||
const REDIS_PREFIX = 'bot:status:';
|
||||
|
||||
async function schedulerPlugin(fastify, opts) {
|
||||
const tasks = new Map();
|
||||
|
||||
/**
|
||||
* 봇 상태 Redis에 저장
|
||||
*/
|
||||
async function updateStatus(botId, status) {
|
||||
const current = await getStatus(botId);
|
||||
const updated = { ...current, ...status, updatedAt: new Date().toISOString() };
|
||||
await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 상태 Redis에서 조회
|
||||
*/
|
||||
async function getStatus(botId) {
|
||||
const data = await fastify.redis.get(`${REDIS_PREFIX}${botId}`);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return {
|
||||
status: 'stopped',
|
||||
lastCheckAt: null,
|
||||
lastAddedCount: 0,
|
||||
totalAdded: 0,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 동기화 함수 가져오기
|
||||
*/
|
||||
function getSyncFunction(bot) {
|
||||
if (bot.type === 'youtube') {
|
||||
return fastify.youtubeBot.syncNewVideos;
|
||||
} else if (bot.type === 'x') {
|
||||
return fastify.xBot.syncNewTweets;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
*/
|
||||
async function startBot(botId) {
|
||||
const bot = bots.find(b => b.id === botId);
|
||||
if (!bot) {
|
||||
throw new Error(`봇을 찾을 수 없습니다: ${botId}`);
|
||||
}
|
||||
|
||||
// 기존 태스크가 있으면 정지
|
||||
if (tasks.has(botId)) {
|
||||
tasks.get(botId).stop();
|
||||
tasks.delete(botId);
|
||||
}
|
||||
|
||||
const syncFn = getSyncFunction(bot);
|
||||
if (!syncFn) {
|
||||
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
|
||||
}
|
||||
|
||||
// cron 태스크 등록
|
||||
const task = cron.schedule(bot.cron, async () => {
|
||||
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const status = await getStatus(botId);
|
||||
await updateStatus(botId, {
|
||||
status: 'running',
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
lastAddedCount: result.addedCount,
|
||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||
errorMessage: null,
|
||||
});
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
|
||||
} catch (err) {
|
||||
await updateStatus(botId, {
|
||||
status: 'error',
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
errorMessage: err.message,
|
||||
});
|
||||
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
tasks.set(botId, task);
|
||||
await updateStatus(botId, { status: 'running' });
|
||||
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
||||
|
||||
// 즉시 1회 실행
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const status = await getStatus(botId);
|
||||
await updateStatus(botId, {
|
||||
lastCheckAt: new Date().toISOString(),
|
||||
lastAddedCount: result.addedCount,
|
||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||
});
|
||||
fastify.log.info(`[${botId}] 초기 동기화 완료: ${result.addedCount}개 추가`);
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 정지
|
||||
*/
|
||||
async function stopBot(botId) {
|
||||
if (tasks.has(botId)) {
|
||||
tasks.get(botId).stop();
|
||||
tasks.delete(botId);
|
||||
}
|
||||
await updateStatus(botId, { status: 'stopped' });
|
||||
fastify.log.info(`[${botId}] 스케줄 정지`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 활성 봇 시작
|
||||
*/
|
||||
async function startAll() {
|
||||
for (const bot of bots) {
|
||||
if (bot.enabled) {
|
||||
try {
|
||||
await startBot(bot.id);
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${bot.id}] 시작 실패: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 봇 정지
|
||||
*/
|
||||
async function stopAll() {
|
||||
for (const [botId, task] of tasks) {
|
||||
task.stop();
|
||||
await updateStatus(botId, { status: 'stopped' });
|
||||
}
|
||||
tasks.clear();
|
||||
}
|
||||
|
||||
// 데코레이터 등록
|
||||
fastify.decorate('scheduler', {
|
||||
startBot,
|
||||
stopBot,
|
||||
startAll,
|
||||
stopAll,
|
||||
getStatus,
|
||||
getBots: () => bots,
|
||||
});
|
||||
|
||||
// 앱 종료 시 모든 봇 정지
|
||||
fastify.addHook('onClose', async () => {
|
||||
await stopAll();
|
||||
fastify.log.info('모든 봇 스케줄 정지');
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(schedulerPlugin, {
|
||||
name: 'scheduler',
|
||||
dependencies: ['db', 'redis', 'youtubeBot', 'xBot'],
|
||||
});
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
import {
|
||||
uploadAlbumCover,
|
||||
deleteAlbumCover,
|
||||
} from '../../services/image.js';
|
||||
import photosRoutes from './photos.js';
|
||||
import teasersRoutes from './teasers.js';
|
||||
|
||||
/**
|
||||
* 앨범 라우트
|
||||
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
||||
*/
|
||||
export default async function albumsRoutes(fastify) {
|
||||
const { db } = fastify;
|
||||
|
||||
// 하위 라우트 등록
|
||||
fastify.register(photosRoutes);
|
||||
fastify.register(teasersRoutes);
|
||||
|
||||
/**
|
||||
* 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함)
|
||||
*/
|
||||
async function getAlbumDetails(album) {
|
||||
const [tracks] = await db.query(
|
||||
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
|
||||
[album.id]
|
||||
);
|
||||
album.tracks = tracks;
|
||||
|
||||
const [teasers] = await db.query(
|
||||
`SELECT original_url, medium_url, thumb_url, video_url, media_type
|
||||
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
|
||||
[album.id]
|
||||
);
|
||||
album.teasers = teasers;
|
||||
|
||||
const [photos] = await db.query(
|
||||
`SELECT
|
||||
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
|
||||
p.width, p.height,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
||||
FROM album_photos p
|
||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||
LEFT JOIN members m ON pm.member_id = m.id
|
||||
WHERE p.album_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.sort_order`,
|
||||
[album.id]
|
||||
);
|
||||
|
||||
const conceptPhotos = {};
|
||||
for (const photo of photos) {
|
||||
const concept = photo.concept_name || 'Default';
|
||||
if (!conceptPhotos[concept]) {
|
||||
conceptPhotos[concept] = [];
|
||||
}
|
||||
conceptPhotos[concept].push({
|
||||
id: photo.id,
|
||||
original_url: photo.original_url,
|
||||
medium_url: photo.medium_url,
|
||||
thumb_url: photo.thumb_url,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
type: photo.photo_type,
|
||||
members: photo.members,
|
||||
sortOrder: photo.sort_order,
|
||||
});
|
||||
}
|
||||
album.conceptPhotos = conceptPhotos;
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
// ==================== GET (공개) ====================
|
||||
|
||||
/**
|
||||
* GET /api/albums
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '전체 앨범 목록 조회',
|
||||
},
|
||||
}, async () => {
|
||||
const [albums] = await db.query(`
|
||||
SELECT id, title, folder_name, album_type, album_type_short, release_date,
|
||||
cover_original_url, cover_medium_url, cover_thumb_url, description
|
||||
FROM albums
|
||||
ORDER BY release_date DESC
|
||||
`);
|
||||
|
||||
for (const album of albums) {
|
||||
const [tracks] = await db.query(
|
||||
`SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger
|
||||
FROM album_tracks WHERE album_id = ? ORDER BY track_number`,
|
||||
[album.id]
|
||||
);
|
||||
album.tracks = tracks;
|
||||
}
|
||||
|
||||
return albums;
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/albums/by-name/:albumName/track/:trackTitle
|
||||
*/
|
||||
fastify.get('/by-name/:albumName/track/:trackTitle', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범명과 트랙명으로 트랙 조회',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const albumName = decodeURIComponent(request.params.albumName);
|
||||
const trackTitle = decodeURIComponent(request.params.trackTitle);
|
||||
|
||||
const [albums] = await db.query(
|
||||
'SELECT * FROM albums WHERE folder_name = ? OR title = ?',
|
||||
[albumName, albumName]
|
||||
);
|
||||
|
||||
if (albums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const album = albums[0];
|
||||
|
||||
const [tracks] = await db.query(
|
||||
'SELECT * FROM album_tracks WHERE album_id = ? AND title = ?',
|
||||
[album.id, trackTitle]
|
||||
);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const track = tracks[0];
|
||||
|
||||
const [otherTracks] = await db.query(
|
||||
'SELECT id, track_number, title, is_title_track, duration FROM album_tracks WHERE album_id = ? ORDER BY track_number',
|
||||
[album.id]
|
||||
);
|
||||
|
||||
return {
|
||||
...track,
|
||||
album: {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
folder_name: album.folder_name,
|
||||
cover_thumb_url: album.cover_thumb_url,
|
||||
cover_medium_url: album.cover_medium_url,
|
||||
release_date: album.release_date,
|
||||
album_type: album.album_type,
|
||||
},
|
||||
otherTracks,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/albums/by-name/:name
|
||||
*/
|
||||
fastify.get('/by-name/:name', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범명으로 앨범 조회',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const name = decodeURIComponent(request.params.name);
|
||||
|
||||
const [albums] = await db.query(
|
||||
'SELECT * FROM albums WHERE folder_name = ? OR title = ?',
|
||||
[name, name]
|
||||
);
|
||||
|
||||
if (albums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return getAlbumDetails(albums[0]);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/albums/:id
|
||||
*/
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: 'ID로 앨범 조회',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [
|
||||
request.params.id,
|
||||
]);
|
||||
|
||||
if (albums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return getAlbumDetails(albums[0]);
|
||||
});
|
||||
|
||||
// ==================== POST/PUT/DELETE (인증 필요) ====================
|
||||
|
||||
/**
|
||||
* POST /api/albums
|
||||
*/
|
||||
fastify.post('/', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 생성',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const parts = request.parts();
|
||||
let data = null;
|
||||
let coverBuffer = null;
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file' && part.fieldname === 'cover') {
|
||||
coverBuffer = await part.toBuffer();
|
||||
} else if (part.fieldname === 'data') {
|
||||
data = JSON.parse(part.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return reply.code(400).send({ error: '데이터가 필요합니다.' });
|
||||
}
|
||||
|
||||
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
|
||||
|
||||
if (!title || !album_type || !release_date || !folder_name) {
|
||||
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
|
||||
}
|
||||
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
let coverOriginalUrl = null;
|
||||
let coverMediumUrl = null;
|
||||
let coverThumbUrl = null;
|
||||
|
||||
if (coverBuffer) {
|
||||
const urls = await uploadAlbumCover(folder_name, coverBuffer);
|
||||
coverOriginalUrl = urls.originalUrl;
|
||||
coverMediumUrl = urls.mediumUrl;
|
||||
coverThumbUrl = urls.thumbUrl;
|
||||
}
|
||||
|
||||
const [albumResult] = await connection.query(
|
||||
`INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name,
|
||||
cover_original_url, cover_medium_url, cover_thumb_url, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[title, album_type, album_type_short || null, release_date, folder_name,
|
||||
coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null]
|
||||
);
|
||||
|
||||
const albumId = albumResult.insertId;
|
||||
|
||||
if (tracks && tracks.length > 0) {
|
||||
for (const track of tracks) {
|
||||
await connection.query(
|
||||
`INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track,
|
||||
lyricist, composer, arranger, lyrics, music_video_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[albumId, track.track_number, track.title, track.duration || null,
|
||||
track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null,
|
||||
track.arranger || null, track.lyrics || null, track.music_video_url || null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
return { message: '앨범이 생성되었습니다.', albumId };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/albums/:id
|
||||
*/
|
||||
fastify.put('/:id', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 수정',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const parts = request.parts();
|
||||
let data = null;
|
||||
let coverBuffer = null;
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file' && part.fieldname === 'cover') {
|
||||
coverBuffer = await part.toBuffer();
|
||||
} else if (part.fieldname === 'data') {
|
||||
data = JSON.parse(part.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return reply.code(400).send({ error: '데이터가 필요합니다.' });
|
||||
}
|
||||
|
||||
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
|
||||
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
if (existingAlbums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const existing = existingAlbums[0];
|
||||
let coverOriginalUrl = existing.cover_original_url;
|
||||
let coverMediumUrl = existing.cover_medium_url;
|
||||
let coverThumbUrl = existing.cover_thumb_url;
|
||||
|
||||
if (coverBuffer) {
|
||||
const urls = await uploadAlbumCover(folder_name, coverBuffer);
|
||||
coverOriginalUrl = urls.originalUrl;
|
||||
coverMediumUrl = urls.mediumUrl;
|
||||
coverThumbUrl = urls.thumbUrl;
|
||||
}
|
||||
|
||||
await connection.query(
|
||||
`UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?,
|
||||
folder_name = ?, cover_original_url = ?, cover_medium_url = ?,
|
||||
cover_thumb_url = ?, description = ?
|
||||
WHERE id = ?`,
|
||||
[title, album_type, album_type_short || null, release_date, folder_name,
|
||||
coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, id]
|
||||
);
|
||||
|
||||
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
|
||||
|
||||
if (tracks && tracks.length > 0) {
|
||||
for (const track of tracks) {
|
||||
await connection.query(
|
||||
`INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track,
|
||||
lyricist, composer, arranger, lyrics, music_video_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, track.track_number, track.title, track.duration || null,
|
||||
track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null,
|
||||
track.arranger || null, track.lyrics || null, track.music_video_url || null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
return { message: '앨범이 수정되었습니다.' };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/albums/:id
|
||||
*/
|
||||
fastify.delete('/:id', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 삭제',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
if (existingAlbums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const album = existingAlbums[0];
|
||||
|
||||
if (album.cover_original_url && album.folder_name) {
|
||||
await deleteAlbumCover(album.folder_name);
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
|
||||
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
|
||||
|
||||
await connection.commit();
|
||||
return { message: '앨범이 삭제되었습니다.' };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
import {
|
||||
uploadAlbumPhoto,
|
||||
deleteAlbumPhoto,
|
||||
uploadAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
|
||||
/**
|
||||
* 앨범 사진 라우트
|
||||
* GET: 공개, POST/DELETE: 인증 필요
|
||||
*/
|
||||
export default async function photosRoutes(fastify) {
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/albums/:albumId/photos
|
||||
*/
|
||||
fastify.get('/:albumId/photos', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 컨셉 포토 목록',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { albumId } = request.params;
|
||||
|
||||
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
||||
if (albums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const [photos] = await db.query(
|
||||
`SELECT
|
||||
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name,
|
||||
p.sort_order, p.width, p.height, p.file_size,
|
||||
GROUP_CONCAT(pm.member_id) as member_ids
|
||||
FROM album_photos p
|
||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||
WHERE p.album_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.sort_order ASC`,
|
||||
[albumId]
|
||||
);
|
||||
|
||||
return photos.map((photo) => ({
|
||||
...photo,
|
||||
members: photo.member_ids ? photo.member_ids.split(',').map(Number) : [],
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/albums/:albumId/photos (SSE)
|
||||
*/
|
||||
fastify.post('/:albumId/photos', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 사진 업로드',
|
||||
description: 'SSE로 진행률 반환',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { albumId } = request.params;
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
const sendProgress = (current, total, message) => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ current, total, message })}\n\n`);
|
||||
};
|
||||
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [albums] = await connection.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
||||
if (albums.length === 0) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: '앨범을 찾을 수 없습니다.' })}\n\n`);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = albums[0].folder_name;
|
||||
const parts = request.parts();
|
||||
|
||||
let metadata = [];
|
||||
let startNumber = null;
|
||||
let photoType = 'concept';
|
||||
const files = [];
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file' && part.fieldname === 'photos') {
|
||||
const buffer = await part.toBuffer();
|
||||
files.push({ buffer, mimetype: part.mimetype });
|
||||
} else if (part.fieldname === 'metadata') {
|
||||
metadata = JSON.parse(part.value);
|
||||
} else if (part.fieldname === 'startNumber') {
|
||||
startNumber = parseInt(part.value) || null;
|
||||
} else if (part.fieldname === 'photoType') {
|
||||
photoType = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
let nextOrder;
|
||||
if (startNumber && startNumber > 0) {
|
||||
nextOrder = startNumber;
|
||||
} else {
|
||||
const [existingPhotos] = await connection.query(
|
||||
'SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?',
|
||||
[albumId]
|
||||
);
|
||||
nextOrder = (existingPhotos[0].maxOrder || 0) + 1;
|
||||
}
|
||||
|
||||
const uploadedPhotos = [];
|
||||
const totalFiles = files.length;
|
||||
const subFolder = photoType === 'teaser' ? 'teaser' : 'photo';
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const meta = metadata[i] || {};
|
||||
const orderNum = String(nextOrder + i).padStart(2, '0');
|
||||
const isVideo = file.mimetype === 'video/mp4';
|
||||
const filename = `${orderNum}.${isVideo ? 'mp4' : 'webp'}`;
|
||||
|
||||
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
|
||||
|
||||
let originalUrl, mediumUrl, thumbUrl, videoUrl;
|
||||
let photoMetadata = {};
|
||||
|
||||
if (isVideo) {
|
||||
videoUrl = await uploadAlbumVideo(folderName, filename, file.buffer);
|
||||
originalUrl = videoUrl;
|
||||
mediumUrl = videoUrl;
|
||||
thumbUrl = videoUrl;
|
||||
} else {
|
||||
const result = await uploadAlbumPhoto(folderName, subFolder, filename, file.buffer);
|
||||
originalUrl = result.originalUrl;
|
||||
mediumUrl = result.mediumUrl;
|
||||
thumbUrl = result.thumbUrl;
|
||||
photoMetadata = result.metadata;
|
||||
}
|
||||
|
||||
let photoId;
|
||||
|
||||
if (photoType === 'teaser') {
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
const [result] = await connection.query(
|
||||
`INSERT INTO album_teasers
|
||||
(album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[albumId, originalUrl, mediumUrl, thumbUrl, videoUrl || null, nextOrder + i, mediaType]
|
||||
);
|
||||
photoId = result.insertId;
|
||||
} else {
|
||||
const [result] = await connection.query(
|
||||
`INSERT INTO album_photos
|
||||
(album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[albumId, originalUrl, mediumUrl, thumbUrl, meta.groupType || 'group',
|
||||
meta.conceptName || null, nextOrder + i, photoMetadata.width || null,
|
||||
photoMetadata.height || null, photoMetadata.size || null]
|
||||
);
|
||||
photoId = result.insertId;
|
||||
|
||||
if (meta.members && meta.members.length > 0) {
|
||||
for (const memberId of meta.members) {
|
||||
await connection.query(
|
||||
'INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)',
|
||||
[photoId, memberId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uploadedPhotos.push({
|
||||
id: photoId,
|
||||
original_url: originalUrl,
|
||||
medium_url: mediumUrl,
|
||||
thumb_url: thumbUrl,
|
||||
video_url: videoUrl || null,
|
||||
filename,
|
||||
media_type: isVideo ? 'video' : 'image',
|
||||
});
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
|
||||
reply.raw.write(`data: ${JSON.stringify({
|
||||
done: true,
|
||||
message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`,
|
||||
photos: uploadedPhotos,
|
||||
})}\n\n`);
|
||||
reply.raw.end();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
console.error('사진 업로드 오류:', error);
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`);
|
||||
reply.raw.end();
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/albums/:albumId/photos/:photoId
|
||||
*/
|
||||
fastify.delete('/:albumId/photos/:photoId', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '컨셉 포토 삭제',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { albumId, photoId } = request.params;
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [photos] = await connection.query(
|
||||
`SELECT p.*, a.folder_name
|
||||
FROM album_photos p
|
||||
JOIN albums a ON p.album_id = a.id
|
||||
WHERE p.id = ? AND p.album_id = ?`,
|
||||
[photoId, albumId]
|
||||
);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const photo = photos[0];
|
||||
const filename = photo.original_url.split('/').pop();
|
||||
|
||||
await deleteAlbumPhoto(photo.folder_name, 'photo', filename);
|
||||
await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]);
|
||||
await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]);
|
||||
|
||||
await connection.commit();
|
||||
return { message: '사진이 삭제되었습니다.' };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import {
|
||||
deleteAlbumPhoto,
|
||||
deleteAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
|
||||
/**
|
||||
* 앨범 티저 라우트
|
||||
* GET: 공개, DELETE: 인증 필요
|
||||
*/
|
||||
export default async function teasersRoutes(fastify) {
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/albums/:albumId/teasers
|
||||
*/
|
||||
fastify.get('/:albumId/teasers', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '앨범 티저 목록',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { albumId } = request.params;
|
||||
|
||||
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
|
||||
if (albums.length === 0) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const [teasers] = await db.query(
|
||||
`SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type
|
||||
FROM album_teasers
|
||||
WHERE album_id = ?
|
||||
ORDER BY sort_order ASC`,
|
||||
[albumId]
|
||||
);
|
||||
|
||||
return teasers;
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/albums/:albumId/teasers/:teaserId
|
||||
*/
|
||||
fastify.delete('/:albumId/teasers/:teaserId', {
|
||||
schema: {
|
||||
tags: ['albums'],
|
||||
summary: '티저 삭제',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { albumId, teaserId } = request.params;
|
||||
const connection = await db.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [teasers] = await connection.query(
|
||||
`SELECT t.*, a.folder_name
|
||||
FROM album_teasers t
|
||||
JOIN albums a ON t.album_id = a.id
|
||||
WHERE t.id = ? AND t.album_id = ?`,
|
||||
[teaserId, albumId]
|
||||
);
|
||||
|
||||
if (teasers.length === 0) {
|
||||
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const teaser = teasers[0];
|
||||
const filename = teaser.original_url.split('/').pop();
|
||||
|
||||
await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename);
|
||||
|
||||
if (teaser.video_url) {
|
||||
const videoFilename = teaser.video_url.split('/').pop();
|
||||
await deleteAlbumVideo(teaser.folder_name, videoFilename);
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
|
||||
|
||||
await connection.commit();
|
||||
return { message: '티저가 삭제되었습니다.' };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* 인증 라우트
|
||||
* /api/auth/*
|
||||
*/
|
||||
export default async function authRoutes(fastify, opts) {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* 관리자 로그인
|
||||
*/
|
||||
fastify.post('/login', {
|
||||
schema: {
|
||||
tags: ['auth'],
|
||||
summary: '관리자 로그인',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string', description: '관리자 아이디' },
|
||||
password: { type: 'string', description: '비밀번호' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
token: { type: 'string' },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
username: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { username, password } = request.body || {};
|
||||
|
||||
if (!username || !password) {
|
||||
return reply.status(400).send({ error: '아이디와 비밀번호를 입력해주세요.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [users] = await fastify.db.query(
|
||||
'SELECT * FROM admin_users WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = fastify.jwt.sign({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
return {
|
||||
message: '로그인 성공',
|
||||
token,
|
||||
user: { id: user.id, username: user.username },
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/verify
|
||||
* 토큰 검증
|
||||
*/
|
||||
fastify.get('/verify', {
|
||||
schema: {
|
||||
tags: ['auth'],
|
||||
summary: '토큰 검증',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
valid: { type: 'boolean' },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
username: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
return { valid: true, user: request.user };
|
||||
});
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import authRoutes from './auth.js';
|
||||
import membersRoutes from './members/index.js';
|
||||
import albumsRoutes from './albums/index.js';
|
||||
import schedulesRoutes from './schedules/index.js';
|
||||
import statsRoutes from './stats/index.js';
|
||||
|
||||
/**
|
||||
* 라우트 통합
|
||||
* /api/*
|
||||
*/
|
||||
export default async function routes(fastify) {
|
||||
// 인증 라우트
|
||||
fastify.register(authRoutes, { prefix: '/auth' });
|
||||
|
||||
// 멤버 라우트
|
||||
fastify.register(membersRoutes, { prefix: '/members' });
|
||||
|
||||
// 앨범 라우트
|
||||
fastify.register(albumsRoutes, { prefix: '/albums' });
|
||||
|
||||
// 일정 라우트
|
||||
fastify.register(schedulesRoutes, { prefix: '/schedules' });
|
||||
|
||||
// 통계 라우트
|
||||
fastify.register(statsRoutes, { prefix: '/stats' });
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
import { uploadMemberImage } from '../../services/image.js';
|
||||
|
||||
/**
|
||||
* 멤버 라우트
|
||||
* GET: 공개, PUT: 인증 필요
|
||||
*/
|
||||
export default async function membersRoutes(fastify, opts) {
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/members
|
||||
* 전체 멤버 목록 조회 (공개)
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['members'],
|
||||
summary: '전체 멤버 목록 조회',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const [members] = await db.query(`
|
||||
SELECT
|
||||
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
|
||||
i.original_url as image_original,
|
||||
i.medium_url as image_medium,
|
||||
i.thumb_url as image_thumb
|
||||
FROM members m
|
||||
LEFT JOIN images i ON m.image_id = i.id
|
||||
ORDER BY m.is_former ASC, m.id ASC
|
||||
`);
|
||||
|
||||
// 별명 조회
|
||||
const [nicknames] = await db.query(
|
||||
'SELECT member_id, nickname FROM member_nicknames'
|
||||
);
|
||||
|
||||
// 멤버별 별명 매핑
|
||||
const nicknameMap = {};
|
||||
for (const n of nicknames) {
|
||||
if (!nicknameMap[n.member_id]) {
|
||||
nicknameMap[n.member_id] = [];
|
||||
}
|
||||
nicknameMap[n.member_id].push(n.nickname);
|
||||
}
|
||||
|
||||
// 멤버 데이터에 별명 추가
|
||||
const result = members.map(m => ({
|
||||
...m,
|
||||
nicknames: nicknameMap[m.id] || [],
|
||||
image_url: m.image_thumb || m.image_medium || m.image_original,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/members/:name
|
||||
* 멤버 상세 조회 (공개)
|
||||
*/
|
||||
fastify.get('/:name', {
|
||||
schema: {
|
||||
tags: ['members'],
|
||||
summary: '멤버 상세 조회',
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '멤버 이름' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { name } = request.params;
|
||||
|
||||
try {
|
||||
const [members] = await db.query(`
|
||||
SELECT
|
||||
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
|
||||
i.original_url as image_original,
|
||||
i.medium_url as image_medium,
|
||||
i.thumb_url as image_thumb
|
||||
FROM members m
|
||||
LEFT JOIN images i ON m.image_id = i.id
|
||||
WHERE m.name = ?
|
||||
`, [decodeURIComponent(name)]);
|
||||
|
||||
if (members.length === 0) {
|
||||
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const member = members[0];
|
||||
|
||||
// 별명 조회
|
||||
const [nicknames] = await db.query(
|
||||
'SELECT nickname FROM member_nicknames WHERE member_id = ?',
|
||||
[member.id]
|
||||
);
|
||||
|
||||
return {
|
||||
...member,
|
||||
nicknames: nicknames.map(n => n.nickname),
|
||||
image_url: member.image_original || member.image_medium || member.image_thumb,
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/members/:name
|
||||
* 멤버 수정 (인증 필요)
|
||||
*/
|
||||
fastify.put('/:name', {
|
||||
schema: {
|
||||
tags: ['members'],
|
||||
summary: '멤버 수정',
|
||||
description: 'multipart/form-data로 이미지와 정보를 함께 전송',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '멤버 이름' },
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { name } = request.params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
try {
|
||||
// 기존 멤버 조회
|
||||
const [existing] = await db.query(
|
||||
'SELECT id, image_id FROM members WHERE name = ?',
|
||||
[decodedName]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const memberId = existing[0].id;
|
||||
let imageId = existing[0].image_id;
|
||||
|
||||
// multipart 데이터 파싱
|
||||
const parts = request.parts();
|
||||
const fields = {};
|
||||
let imageBuffer = null;
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file' && part.fieldname === 'image') {
|
||||
// 이미지 파일
|
||||
const chunks = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
imageBuffer = Buffer.concat(chunks);
|
||||
} else if (part.type === 'field') {
|
||||
// 일반 필드
|
||||
fields[part.fieldname] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 새 이미지가 있으면 업로드
|
||||
if (imageBuffer && imageBuffer.length > 0) {
|
||||
const newName = fields.name || decodedName;
|
||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadMemberImage(newName, imageBuffer);
|
||||
|
||||
// images 테이블에 저장
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
||||
[originalUrl, mediumUrl, thumbUrl]
|
||||
);
|
||||
imageId = result.insertId;
|
||||
}
|
||||
|
||||
// 멤버 정보 업데이트
|
||||
await db.query(`
|
||||
UPDATE members SET
|
||||
name = ?,
|
||||
name_en = ?,
|
||||
birth_date = ?,
|
||||
instagram = ?,
|
||||
image_id = ?,
|
||||
is_former = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
fields.name || decodedName,
|
||||
fields.name_en || null,
|
||||
fields.birth_date || null,
|
||||
fields.instagram || null,
|
||||
imageId,
|
||||
fields.is_former === 'true' || fields.is_former === '1' ? 1 : 0,
|
||||
memberId,
|
||||
]);
|
||||
|
||||
// 별명 업데이트 (기존 삭제 후 새로 추가)
|
||||
if (fields.nicknames) {
|
||||
await db.query(
|
||||
'DELETE FROM member_nicknames WHERE member_id = ?',
|
||||
[memberId]
|
||||
);
|
||||
|
||||
const nicknames = JSON.parse(fields.nicknames);
|
||||
if (nicknames.length > 0) {
|
||||
const values = nicknames.map(n => [memberId, n]);
|
||||
await db.query(
|
||||
'INSERT INTO member_nicknames (member_id, nickname) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 수정 실패: ' + err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
/**
|
||||
* 일정 라우트
|
||||
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
||||
*/
|
||||
import suggestionsRoutes from './suggestions.js';
|
||||
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js';
|
||||
|
||||
export default async function schedulesRoutes(fastify) {
|
||||
const { db, meilisearch, redis } = fastify;
|
||||
|
||||
// 추천 검색어 라우트 등록
|
||||
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
|
||||
|
||||
/**
|
||||
* GET /api/schedules
|
||||
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
|
||||
* 월별 조회 모드: year, month 파라미터로 월별 조회
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: '일정 조회 (검색 또는 월별)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
search: { type: 'string', description: '검색어' },
|
||||
year: { type: 'integer', description: '년도' },
|
||||
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
|
||||
offset: { type: 'integer', default: 0, description: '페이지 오프셋' },
|
||||
limit: { type: 'integer', default: 100, description: '결과 개수' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { search, year, month, offset = 0, limit = 100 } = request.query;
|
||||
|
||||
// 검색 모드
|
||||
if (search && search.trim()) {
|
||||
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
|
||||
}
|
||||
|
||||
// 월별 조회 모드
|
||||
if (!year || !month) {
|
||||
return reply.code(400).send({ error: 'search 또는 year/month는 필수입니다.' });
|
||||
}
|
||||
|
||||
return await handleMonthlySchedules(db, parseInt(year), parseInt(month));
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/schedules/sync-search
|
||||
* Meilisearch 전체 동기화 (관리자 전용)
|
||||
*/
|
||||
fastify.post('/sync-search', {
|
||||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: 'Meilisearch 전체 동기화',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const count = await syncAllSchedules(meilisearch, db);
|
||||
return { success: true, synced: count };
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schedules/:id
|
||||
* 일정 상세 조회
|
||||
*/
|
||||
fastify.get('/:id', {
|
||||
schema: {
|
||||
tags: ['schedules'],
|
||||
summary: '일정 상세 조회',
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const s = schedules[0];
|
||||
const result = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
date: s.date,
|
||||
time: s.time,
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
};
|
||||
|
||||
// source 정보 추가 (YouTube: 2, X: 3)
|
||||
if (s.category_id === 2 && s.youtube_video_id) {
|
||||
const videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
result.source = {
|
||||
name: s.youtube_channel || 'YouTube',
|
||||
url: videoUrl,
|
||||
};
|
||||
} else if (s.category_id === 3 && s.x_post_id) {
|
||||
result.source = {
|
||||
name: 'X',
|
||||
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 처리
|
||||
*/
|
||||
async function handleSearch(fastify, query, offset, limit) {
|
||||
const { db, meilisearch, redis } = fastify;
|
||||
|
||||
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
|
||||
if (offset === 0) {
|
||||
// 비동기로 저장 (응답 지연 방지)
|
||||
saveSearchQueryAsync(fastify, query);
|
||||
}
|
||||
|
||||
// Meilisearch 검색
|
||||
const results = await searchSchedules(meilisearch, db, query, { limit: 1000 });
|
||||
|
||||
// 페이징 적용
|
||||
const paginatedHits = results.hits.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
schedules: paginatedHits,
|
||||
total: results.total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + paginatedHits.length < results.total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 비동기 저장
|
||||
*/
|
||||
async function saveSearchQueryAsync(fastify, query) {
|
||||
try {
|
||||
// suggestions 서비스의 saveSearchQuery 사용
|
||||
const { SuggestionService } = await import('../../services/suggestions/index.js');
|
||||
const service = new SuggestionService(fastify.db, fastify.redis);
|
||||
await service.saveSearchQuery(query);
|
||||
} catch (err) {
|
||||
console.error('[Search] 검색어 저장 실패:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 일정 조회 (생일 포함)
|
||||
*/
|
||||
async function handleMonthlySchedules(db, year, month) {
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
|
||||
|
||||
// 일정 조회 (YouTube, X 소스 정보 포함)
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
WHERE s.date BETWEEN ? AND ?
|
||||
ORDER BY s.date ASC, s.time ASC
|
||||
`, [startDate, endDate]);
|
||||
|
||||
// 생일 조회
|
||||
const [birthdays] = await db.query(`
|
||||
SELECT m.id, m.name, m.name_en, m.birth_date,
|
||||
i.thumb_url as image_url
|
||||
FROM members m
|
||||
LEFT JOIN images i ON m.image_id = i.id
|
||||
WHERE m.is_former = 0 AND MONTH(m.birth_date) = ?
|
||||
`, [month]);
|
||||
|
||||
// 날짜별로 그룹화
|
||||
const grouped = {};
|
||||
|
||||
// 일정 추가
|
||||
for (const s of schedules) {
|
||||
const dateKey = s.date instanceof Date
|
||||
? s.date.toISOString().split('T')[0]
|
||||
: s.date;
|
||||
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = {
|
||||
categories: [],
|
||||
schedules: [],
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
time: s.time,
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
};
|
||||
|
||||
// source 정보 추가 (YouTube: 2, X: 3)
|
||||
if (s.category_id === 2 && s.youtube_video_id) {
|
||||
const videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
schedule.source = {
|
||||
name: s.youtube_channel || 'YouTube',
|
||||
url: videoUrl,
|
||||
};
|
||||
} else if (s.category_id === 3 && s.x_post_id) {
|
||||
schedule.source = {
|
||||
name: 'X',
|
||||
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
grouped[dateKey].schedules.push(schedule);
|
||||
|
||||
// 카테고리 카운트
|
||||
const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id);
|
||||
if (existingCategory) {
|
||||
existingCategory.count++;
|
||||
} else {
|
||||
grouped[dateKey].categories.push({
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 생일 일정 추가
|
||||
for (const member of birthdays) {
|
||||
const birthDate = new Date(member.birth_date);
|
||||
const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||
const dateKey = birthdayThisYear.toISOString().split('T')[0];
|
||||
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = {
|
||||
categories: [],
|
||||
schedules: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 생일 카테고리 (id: 8)
|
||||
const BIRTHDAY_CATEGORY = {
|
||||
id: 8,
|
||||
name: '생일',
|
||||
color: '#f472b6',
|
||||
};
|
||||
|
||||
const birthdaySchedule = {
|
||||
id: `birthday-${member.id}`,
|
||||
title: `HAPPY ${member.name_en} DAY`,
|
||||
time: null,
|
||||
category: BIRTHDAY_CATEGORY,
|
||||
is_birthday: true,
|
||||
member_name: member.name,
|
||||
member_image: member.image_url,
|
||||
};
|
||||
|
||||
grouped[dateKey].schedules.push(birthdaySchedule);
|
||||
|
||||
// 생일 카테고리 카운트
|
||||
const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === 8);
|
||||
if (existingBirthdayCategory) {
|
||||
existingBirthdayCategory.count++;
|
||||
} else {
|
||||
grouped[dateKey].categories.push({
|
||||
...BIRTHDAY_CATEGORY,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
/**
|
||||
* 추천 검색어 API 라우트
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { SuggestionService } from '../../services/suggestions/index.js';
|
||||
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
|
||||
|
||||
let suggestionService = null;
|
||||
|
||||
export default async function suggestionsRoutes(fastify) {
|
||||
const { db, redis } = fastify;
|
||||
|
||||
// 서비스 초기화 (한 번만)
|
||||
if (!suggestionService) {
|
||||
suggestionService = new SuggestionService(db, redis);
|
||||
// 비동기 초기화 (형태소 분석기 로드)
|
||||
suggestionService.initialize().catch(err => {
|
||||
console.error('[Suggestions] 서비스 초기화 실패:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/schedules/suggestions
|
||||
* 추천 검색어 조회
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['suggestions'],
|
||||
summary: '추천 검색어 조회',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: '검색어' },
|
||||
limit: { type: 'integer', default: 10, description: '결과 개수' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { q, limit = 10 } = request.query;
|
||||
|
||||
if (!q || q.trim().length === 0) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const suggestions = await suggestionService.getSuggestions(q, limit);
|
||||
return { suggestions };
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schedules/suggestions/popular
|
||||
* 인기 검색어 조회
|
||||
*/
|
||||
fastify.get('/popular', {
|
||||
schema: {
|
||||
tags: ['suggestions'],
|
||||
summary: '인기 검색어 조회',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', default: 10, description: '결과 개수' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
queries: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { limit = 10 } = request.query;
|
||||
const queries = await suggestionService.getPopularQueries(limit);
|
||||
return { queries };
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/schedules/suggestions/save
|
||||
* 검색어 저장 (검색 실행 시 호출)
|
||||
*/
|
||||
fastify.post('/save', {
|
||||
schema: {
|
||||
tags: ['suggestions'],
|
||||
summary: '검색어 저장',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
properties: {
|
||||
query: { type: 'string', description: '검색어' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { query } = request.body;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
await suggestionService.saveSearchQuery(query);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schedules/suggestions/dict
|
||||
* 사용자 사전 조회 (관리자 전용)
|
||||
*/
|
||||
fastify.get('/dict', {
|
||||
schema: {
|
||||
tags: ['suggestions'],
|
||||
summary: '사용자 사전 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: { type: 'string', description: '사전 내용' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const dictPath = getUserDictPath();
|
||||
const content = readFileSync(dictPath, 'utf-8');
|
||||
return { content };
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return { content: '' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/schedules/suggestions/dict
|
||||
* 사용자 사전 저장 및 리로드 (관리자 전용)
|
||||
*/
|
||||
fastify.put('/dict', {
|
||||
schema: {
|
||||
tags: ['suggestions'],
|
||||
summary: '사용자 사전 저장',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['content'],
|
||||
properties: {
|
||||
content: { type: 'string', description: '사전 내용' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { content } = request.body;
|
||||
|
||||
try {
|
||||
const dictPath = getUserDictPath();
|
||||
writeFileSync(dictPath, content, 'utf-8');
|
||||
|
||||
// 형태소 분석기 리로드
|
||||
await reloadMorpheme();
|
||||
|
||||
return { success: true, message: '사전이 저장되었습니다.' };
|
||||
} catch (error) {
|
||||
console.error('[Suggestions] 사전 저장 오류:', error.message);
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
message: '사전 저장 중 오류가 발생했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
/**
|
||||
* 통계 라우트
|
||||
* 인증 필요
|
||||
*/
|
||||
export default async function statsRoutes(fastify, opts) {
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/stats
|
||||
* 대시보드 통계 조회 (인증 필요)
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
tags: ['stats'],
|
||||
summary: '대시보드 통계 조회',
|
||||
description: '멤버, 앨범, 사진, 일정, 트랙 수를 조회합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
members: { type: 'integer', description: '활동 중인 멤버 수' },
|
||||
albums: { type: 'integer', description: '앨범 수' },
|
||||
photos: { type: 'integer', description: '사진 수 (컨셉포토 + 티저)' },
|
||||
schedules: { type: 'integer', description: '전체 일정 수' },
|
||||
tracks: { type: 'integer', description: '트랙 수' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
// 멤버 수 (현재 활동 중인 멤버만)
|
||||
const [[{ memberCount }]] = await db.query(
|
||||
'SELECT COUNT(*) as memberCount FROM members WHERE is_former = 0'
|
||||
);
|
||||
|
||||
// 앨범 수
|
||||
const [[{ albumCount }]] = await db.query(
|
||||
'SELECT COUNT(*) as albumCount FROM albums'
|
||||
);
|
||||
|
||||
// 컨셉 포토 수
|
||||
const [[{ photoCount }]] = await db.query(
|
||||
'SELECT COUNT(*) as photoCount FROM album_photos'
|
||||
);
|
||||
|
||||
// 티저 수
|
||||
const [[{ teaserCount }]] = await db.query(
|
||||
'SELECT COUNT(*) as teaserCount FROM album_teasers'
|
||||
);
|
||||
|
||||
// 일정 수 (전체)
|
||||
const [[{ scheduleCount }]] = await db.query(
|
||||
'SELECT COUNT(*) as scheduleCount FROM schedules'
|
||||
);
|
||||
|
||||
// 트랙 수
|
||||
const [[{ trackCount }]] = await db.query(
|
||||
'SELECT COUNT(*) as trackCount FROM album_tracks'
|
||||
);
|
||||
|
||||
return {
|
||||
members: memberCount,
|
||||
albums: albumCount,
|
||||
photos: photoCount + teaserCount,
|
||||
schedules: scheduleCount,
|
||||
tracks: trackCount,
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '통계 조회 실패' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { buildApp } from './app.js';
|
||||
import config from './config/index.js';
|
||||
|
||||
async function start() {
|
||||
const app = await buildApp();
|
||||
|
||||
try {
|
||||
// 서버 시작
|
||||
await app.listen({
|
||||
port: config.server.port,
|
||||
host: config.server.host,
|
||||
});
|
||||
|
||||
// 모든 봇 스케줄 시작
|
||||
await app.scheduler.startAll();
|
||||
|
||||
app.log.info(`서버 시작: http://${config.server.host}:${config.server.port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import sharp from 'sharp';
|
||||
import config from '../config/index.js';
|
||||
|
||||
// S3 클라이언트 생성
|
||||
const s3Client = new S3Client({
|
||||
endpoint: config.s3.endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: config.s3.accessKey,
|
||||
secretAccessKey: config.s3.secretKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const BUCKET = config.s3.bucket;
|
||||
const PUBLIC_URL = config.s3.publicUrl;
|
||||
|
||||
/**
|
||||
* 이미지를 3가지 해상도로 변환
|
||||
*/
|
||||
async function processImage(buffer) {
|
||||
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
||||
sharp(buffer).webp({ lossless: true }).toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(800, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(400, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
return { originalBuffer, mediumBuffer, thumbBuffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* S3에 이미지 업로드
|
||||
*/
|
||||
async function uploadToS3(key, buffer, contentType = 'image/webp') {
|
||||
await s3Client.send(new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}));
|
||||
return `${PUBLIC_URL}/${BUCKET}/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3에서 이미지 삭제
|
||||
*/
|
||||
async function deleteFromS3(key) {
|
||||
try {
|
||||
await s3Client.send(new DeleteObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`S3 삭제 오류 (${key}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 프로필 이미지 업로드
|
||||
* @param {string} name - 멤버 이름
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
||||
*/
|
||||
export async function uploadMemberImage(name, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
|
||||
const basePath = `member/${name}`;
|
||||
const filename = `${name}.webp`;
|
||||
|
||||
// 병렬 업로드
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 프로필 이미지 삭제
|
||||
* @param {string} name - 멤버 이름
|
||||
*/
|
||||
export async function deleteMemberImage(name) {
|
||||
const basePath = `member/${name}`;
|
||||
const filename = `${name}.webp`;
|
||||
const sizes = ['original', 'medium_800', 'thumb_400'];
|
||||
|
||||
await Promise.all(
|
||||
sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 커버 이미지 업로드
|
||||
* @param {string} folderName - 앨범 폴더명
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
||||
*/
|
||||
export async function uploadAlbumCover(folderName, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
|
||||
const basePath = `album/${folderName}/cover`;
|
||||
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/cover.webp`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/cover.webp`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/cover.webp`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 커버 이미지 삭제
|
||||
* @param {string} folderName - 앨범 폴더명
|
||||
*/
|
||||
export async function deleteAlbumCover(folderName) {
|
||||
const basePath = `album/${folderName}/cover`;
|
||||
const sizes = ['original', 'medium_800', 'thumb_400'];
|
||||
|
||||
await Promise.all(
|
||||
sizes.map(size => deleteFromS3(`${basePath}/${size}/cover.webp`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 업로드 (컨셉포토 또는 티저)
|
||||
* @param {string} folderName - 앨범 폴더명
|
||||
* @param {string} subFolder - 'photo' 또는 'teaser'
|
||||
* @param {string} filename - 파일명 (예: '01.webp')
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string, metadata: object}>}
|
||||
*/
|
||||
export async function uploadAlbumPhoto(folderName, subFolder, filename, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
const metadata = await sharp(originalBuffer).metadata();
|
||||
|
||||
const basePath = `album/${folderName}/${subFolder}`;
|
||||
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return {
|
||||
originalUrl,
|
||||
mediumUrl,
|
||||
thumbUrl,
|
||||
metadata: {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
size: originalBuffer.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 삭제
|
||||
* @param {string} folderName - 앨범 폴더명
|
||||
* @param {string} subFolder - 'photo' 또는 'teaser'
|
||||
* @param {string} filename - 파일명
|
||||
*/
|
||||
export async function deleteAlbumPhoto(folderName, subFolder, filename) {
|
||||
const basePath = `album/${folderName}/${subFolder}`;
|
||||
const sizes = ['original', 'medium_800', 'thumb_400'];
|
||||
|
||||
await Promise.all(
|
||||
sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 비디오 업로드 (티저 전용)
|
||||
* @param {string} folderName - 앨범 폴더명
|
||||
* @param {string} filename - 파일명 (예: '01.mp4')
|
||||
* @param {Buffer} buffer - 비디오 버퍼
|
||||
* @returns {Promise<string>} - 비디오 URL
|
||||
*/
|
||||
export async function uploadAlbumVideo(folderName, filename, buffer) {
|
||||
const key = `album/${folderName}/teaser/video/${filename}`;
|
||||
return await uploadToS3(key, buffer, 'video/mp4');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 비디오 삭제
|
||||
* @param {string} folderName - 앨범 폴더명
|
||||
* @param {string} filename - 파일명
|
||||
*/
|
||||
export async function deleteAlbumVideo(folderName, filename) {
|
||||
await deleteFromS3(`album/${folderName}/teaser/video/${filename}`);
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
/**
|
||||
* Meilisearch 검색 서비스
|
||||
* - 일정 검색 (멤버 별명 → 이름 변환)
|
||||
* - 영문 자판 → 한글 변환
|
||||
* - 유사도 필터링
|
||||
* - 일정 동기화
|
||||
*/
|
||||
import Inko from 'inko';
|
||||
|
||||
const inko = new Inko();
|
||||
const INDEX_NAME = 'schedules';
|
||||
|
||||
/**
|
||||
* 영문 자판으로 입력된 검색어인지 확인
|
||||
*/
|
||||
function isEnglishKeyboard(text) {
|
||||
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||
return englishChars.length > 0 && koreanChars.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 별명/이름으로 멤버 이름 조회
|
||||
*/
|
||||
export async function resolveMemberNames(db, query) {
|
||||
const searchTerm = `%${query}%`;
|
||||
const [members] = await db.query(`
|
||||
SELECT DISTINCT m.name
|
||||
FROM members m
|
||||
LEFT JOIN member_nicknames mn ON m.id = mn.member_id
|
||||
WHERE m.name LIKE ? OR mn.nickname LIKE ?
|
||||
`, [searchTerm, searchTerm]);
|
||||
|
||||
return members.map(m => m.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색
|
||||
* @param {object} meilisearch - Meilisearch 클라이언트
|
||||
* @param {object} db - DB 연결 풀
|
||||
* @param {string} query - 검색어
|
||||
* @param {object} options - 검색 옵션
|
||||
*/
|
||||
export async function searchSchedules(meilisearch, db, query, options = {}) {
|
||||
const { limit = 1000, offset = 0 } = options;
|
||||
|
||||
try {
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
|
||||
const searchOptions = {
|
||||
limit,
|
||||
offset: 0, // 내부적으로 전체 검색 후 필터링
|
||||
attributesToRetrieve: ['*'],
|
||||
showRankingScore: true,
|
||||
};
|
||||
|
||||
// 검색어 목록 구성
|
||||
const searchQueries = [query];
|
||||
|
||||
// 영문 자판 입력 → 한글 변환
|
||||
if (isEnglishKeyboard(query)) {
|
||||
const koreanQuery = inko.en2ko(query);
|
||||
if (koreanQuery !== query) {
|
||||
searchQueries.push(koreanQuery);
|
||||
}
|
||||
}
|
||||
|
||||
// 별명 → 멤버 이름 변환
|
||||
const memberNames = await resolveMemberNames(db, query);
|
||||
for (const name of memberNames) {
|
||||
if (!searchQueries.includes(name)) {
|
||||
searchQueries.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 검색어로 검색 후 병합
|
||||
const allHits = new Map(); // id 기준 중복 제거
|
||||
|
||||
for (const q of searchQueries) {
|
||||
const results = await index.search(q, searchOptions);
|
||||
for (const hit of results.hits) {
|
||||
// 더 높은 점수로 업데이트
|
||||
if (!allHits.has(hit.id) || allHits.get(hit.id)._rankingScore < hit._rankingScore) {
|
||||
allHits.set(hit.id, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 유사도 0.5 미만 필터링
|
||||
let filteredHits = Array.from(allHits.values())
|
||||
.filter(hit => hit._rankingScore >= 0.5);
|
||||
|
||||
// 유사도 순 정렬
|
||||
filteredHits.sort((a, b) => (b._rankingScore || 0) - (a._rankingScore || 0));
|
||||
|
||||
const total = filteredHits.length;
|
||||
|
||||
// 페이징 적용
|
||||
const paginatedHits = filteredHits.slice(offset, offset + limit);
|
||||
|
||||
// 응답 형식 변환
|
||||
const formattedHits = paginatedHits.map(formatScheduleResponse);
|
||||
|
||||
return {
|
||||
hits: formattedHits,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + paginatedHits.length < total,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 검색 오류:', err.message);
|
||||
return { hits: [], total: 0, offset: 0, limit, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 응답 형식 변환
|
||||
*/
|
||||
function formatScheduleResponse(hit) {
|
||||
// date + time 합치기
|
||||
let datetime = null;
|
||||
if (hit.date) {
|
||||
const dateStr = hit.date instanceof Date
|
||||
? hit.date.toISOString().split('T')[0]
|
||||
: String(hit.date).split('T')[0];
|
||||
|
||||
if (hit.time) {
|
||||
datetime = `${dateStr}T${hit.time}`;
|
||||
} else {
|
||||
datetime = dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// member_names를 배열로 변환
|
||||
const members = hit.member_names
|
||||
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: hit.id,
|
||||
title: hit.title,
|
||||
datetime,
|
||||
category: {
|
||||
id: hit.category_id,
|
||||
name: hit.category_name,
|
||||
color: hit.category_color,
|
||||
},
|
||||
source_name: hit.source_name || null,
|
||||
members,
|
||||
_rankingScore: hit._rankingScore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 추가/업데이트
|
||||
*/
|
||||
export async function addOrUpdateSchedule(meilisearch, schedule) {
|
||||
try {
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
|
||||
const document = {
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
description: schedule.description || '',
|
||||
date: schedule.date,
|
||||
time: schedule.time || '',
|
||||
category_id: schedule.category_id,
|
||||
category_name: schedule.category_name || '',
|
||||
category_color: schedule.category_color || '',
|
||||
source_name: schedule.source_name || '',
|
||||
member_names: schedule.member_names || '',
|
||||
};
|
||||
|
||||
await index.addDocuments([document]);
|
||||
console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`);
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 문서 추가 오류:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제
|
||||
*/
|
||||
export async function deleteSchedule(meilisearch, scheduleId) {
|
||||
try {
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
await index.deleteDocument(scheduleId);
|
||||
console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`);
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 문서 삭제 오류:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 일정 동기화
|
||||
*/
|
||||
export async function syncAllSchedules(meilisearch, db) {
|
||||
try {
|
||||
// DB에서 모든 일정 조회
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.description,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as source_name,
|
||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
GROUP BY s.id
|
||||
`);
|
||||
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
|
||||
// 기존 문서 모두 삭제
|
||||
await index.deleteAllDocuments();
|
||||
|
||||
// 문서 변환
|
||||
const documents = schedules.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description || '',
|
||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
||||
time: s.time || '',
|
||||
category_id: s.category_id,
|
||||
category_name: s.category_name || '',
|
||||
category_color: s.category_color || '',
|
||||
source_name: s.source_name || '',
|
||||
member_names: s.member_names || '',
|
||||
}));
|
||||
|
||||
// 일괄 추가
|
||||
await index.addDocuments(documents);
|
||||
console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`);
|
||||
|
||||
return documents.length;
|
||||
} catch (err) {
|
||||
console.error('[Meilisearch] 동기화 오류:', err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* 초성 변환/검색 모듈
|
||||
* 한글 텍스트를 초성으로 변환하고 초성 검색 지원
|
||||
*/
|
||||
|
||||
// 초성 목록 (유니코드 순서)
|
||||
const CHOSUNG = [
|
||||
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ',
|
||||
'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
|
||||
];
|
||||
|
||||
// 한글 유니코드 범위
|
||||
const HANGUL_START = 0xAC00;
|
||||
const HANGUL_END = 0xD7A3;
|
||||
|
||||
/**
|
||||
* 문자가 한글인지 확인
|
||||
*/
|
||||
function isHangul(char) {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= HANGUL_START && code <= HANGUL_END;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자가 초성인지 확인
|
||||
*/
|
||||
function isChosung(char) {
|
||||
return CHOSUNG.includes(char);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 텍스트를 초성으로 변환
|
||||
* @param {string} text - 변환할 텍스트
|
||||
* @returns {string} - 초성 문자열
|
||||
* @example "프로미스나인" → "ㅍㄹㅁㅅㄴㅇ"
|
||||
*/
|
||||
export function getChosung(text) {
|
||||
if (!text) return '';
|
||||
|
||||
let result = '';
|
||||
for (const char of text) {
|
||||
if (isHangul(char)) {
|
||||
const code = char.charCodeAt(0) - HANGUL_START;
|
||||
const chosungIndex = Math.floor(code / 588);
|
||||
result += CHOSUNG[chosungIndex];
|
||||
} else if (isChosung(char)) {
|
||||
// 이미 초성이면 그대로
|
||||
result += char;
|
||||
}
|
||||
// 한글이 아닌 문자는 무시
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력이 초성으로만 구성되어 있는지 확인
|
||||
* @param {string} text - 확인할 텍스트
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isChosungOnly(text) {
|
||||
if (!text) return false;
|
||||
for (const char of text) {
|
||||
if (!isChosung(char) && char !== ' ') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 초성 패턴이 단어와 매칭되는지 확인
|
||||
* @param {string} chosung - 초성 패턴
|
||||
* @param {string} word - 비교할 단어
|
||||
* @returns {boolean}
|
||||
* @example isChosungMatch("ㅍㄹㅁ", "프로미스") → true
|
||||
*/
|
||||
export function isChosungMatch(chosung, word) {
|
||||
const wordChosung = getChosung(word);
|
||||
return wordChosung.startsWith(chosung);
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
/**
|
||||
* 추천 검색어 서비스
|
||||
* - 형태소 분석으로 명사 추출
|
||||
* - Bi-gram 기반 다음 단어 예측
|
||||
* - 초성 검색 지원
|
||||
* - 영어 오타 감지 (Inko)
|
||||
*/
|
||||
import Inko from 'inko';
|
||||
import { extractNouns, initMorpheme, isReady } from './morpheme.js';
|
||||
import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js';
|
||||
|
||||
const inko = new Inko();
|
||||
|
||||
// 설정
|
||||
const CONFIG = {
|
||||
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
|
||||
MIN_COUNT_RATIO: 0.01,
|
||||
// 최소 임계값 (데이터 적을 때)
|
||||
MIN_COUNT_FLOOR: 5,
|
||||
// Redis 키 prefix
|
||||
REDIS_PREFIX: 'suggest:',
|
||||
// 캐시 TTL (초)
|
||||
CACHE_TTL: {
|
||||
PREFIX: 3600, // prefix 검색: 1시간
|
||||
BIGRAM: 86400, // bi-gram: 24시간
|
||||
POPULAR: 600, // 인기 검색어: 10분
|
||||
MAX_COUNT: 3600, // 최대 횟수: 1시간
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 추천 검색어 서비스 클래스
|
||||
*/
|
||||
export class SuggestionService {
|
||||
constructor(db, redis) {
|
||||
this.db = db;
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 초기화 (형태소 분석기 로드)
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
await initMorpheme();
|
||||
console.log('[Suggestion] 서비스 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 서비스 초기화 실패:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영문만 포함된 검색어인지 확인
|
||||
*/
|
||||
isEnglishOnly(text) {
|
||||
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||
return englishChars.length > 0 && koreanChars.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영어 입력을 한글로 변환 (오타 감지)
|
||||
*/
|
||||
convertEnglishToKorean(text) {
|
||||
const converted = inko.en2ko(text);
|
||||
return converted !== text ? converted : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 저장 (검색 실행 시 호출)
|
||||
* - 형태소 분석으로 명사 추출
|
||||
* - Unigram + Bi-gram 저장
|
||||
*/
|
||||
async saveSearchQuery(query) {
|
||||
if (!query || query.trim().length === 0) return;
|
||||
|
||||
let normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
// 영어 입력 → 한글 변환 시도
|
||||
if (this.isEnglishOnly(normalizedQuery)) {
|
||||
const korean = this.convertEnglishToKorean(normalizedQuery);
|
||||
if (korean) {
|
||||
console.log(`[Suggestion] 한글 변환: "${normalizedQuery}" → "${korean}"`);
|
||||
normalizedQuery = korean;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 전체 검색어 저장 (Unigram)
|
||||
await this.db.query(
|
||||
`INSERT INTO suggestion_queries (query, count)
|
||||
VALUES (?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
|
||||
[normalizedQuery]
|
||||
);
|
||||
|
||||
// 2. 형태소 분석으로 명사 추출
|
||||
let nouns;
|
||||
if (isReady()) {
|
||||
nouns = await extractNouns(normalizedQuery);
|
||||
} else {
|
||||
// fallback: 공백 분리
|
||||
nouns = normalizedQuery.split(/\s+/).filter(w => w.length > 0);
|
||||
}
|
||||
|
||||
// 3. Bi-gram 저장 (명사 쌍)
|
||||
for (let i = 0; i < nouns.length - 1; i++) {
|
||||
const word1 = nouns[i].toLowerCase();
|
||||
const word2 = nouns[i + 1].toLowerCase();
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO suggestion_word_pairs (word1, word2, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1`,
|
||||
[word1, word2]
|
||||
);
|
||||
|
||||
// Redis 캐시 업데이트
|
||||
await this.redis.zincrby(`${CONFIG.REDIS_PREFIX}bigram:${word1}`, 1, word2);
|
||||
}
|
||||
|
||||
// 4. 초성 인덱스 저장
|
||||
for (const noun of nouns) {
|
||||
const chosung = getChosung(noun);
|
||||
if (chosung.length >= 2) {
|
||||
await this.db.query(
|
||||
`INSERT INTO suggestion_chosung (chosung, word, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1`,
|
||||
[chosung, noun.toLowerCase()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Suggestion] 저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 저장 오류:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 검색어 조회
|
||||
*/
|
||||
async getSuggestions(query, limit = 10) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let searchQuery = query.toLowerCase();
|
||||
let koreanQuery = null;
|
||||
|
||||
// 영어 입력 → 한글 변환
|
||||
if (this.isEnglishOnly(searchQuery)) {
|
||||
koreanQuery = this.convertEnglishToKorean(searchQuery);
|
||||
}
|
||||
|
||||
try {
|
||||
// 초성 검색 모드
|
||||
if (isChosungOnly(searchQuery.replace(/\s/g, ''))) {
|
||||
return await this.getChosungSuggestions(searchQuery.replace(/\s/g, ''), limit);
|
||||
}
|
||||
|
||||
const endsWithSpace = query.endsWith(' ');
|
||||
const words = searchQuery.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
|
||||
if (endsWithSpace && words.length > 0) {
|
||||
// 다음 단어 예측 (Bi-gram)
|
||||
const lastWord = words[words.length - 1];
|
||||
return await this.getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
||||
} else {
|
||||
// Prefix 매칭
|
||||
return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 조회 오류:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 단어 예측 (Bi-gram)
|
||||
*/
|
||||
async getNextWordSuggestions(lastWord, prefix, limit) {
|
||||
try {
|
||||
// Redis 캐시 확인
|
||||
const cacheKey = `${CONFIG.REDIS_PREFIX}bigram:${lastWord}`;
|
||||
const cached = await this.redis.zrevrange(cacheKey, 0, limit - 1);
|
||||
|
||||
if (cached && cached.length > 0) {
|
||||
return cached.map(word => `${prefix} ${word}`);
|
||||
}
|
||||
|
||||
// DB 조회
|
||||
const [rows] = await this.db.query(
|
||||
`SELECT word2, count FROM suggestion_word_pairs
|
||||
WHERE word1 = ?
|
||||
ORDER BY count DESC
|
||||
LIMIT ?`,
|
||||
[lastWord, limit]
|
||||
);
|
||||
|
||||
return rows.map(r => `${prefix} ${r.word2}`);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] Bi-gram 조회 오류:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix 매칭
|
||||
*/
|
||||
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
||||
try {
|
||||
let rows;
|
||||
|
||||
if (koreanPrefix) {
|
||||
// 영어 + 한글 변환 둘 다 검색
|
||||
[rows] = await this.db.query(
|
||||
`SELECT query, count FROM suggestion_queries
|
||||
WHERE query LIKE ? OR query LIKE ?
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
||||
);
|
||||
} else {
|
||||
[rows] = await this.db.query(
|
||||
`SELECT query, count FROM suggestion_queries
|
||||
WHERE query LIKE ?
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, limit]
|
||||
);
|
||||
}
|
||||
|
||||
return rows.map(r => r.query);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] Prefix 조회 오류:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초성 검색
|
||||
*/
|
||||
async getChosungSuggestions(chosung, limit) {
|
||||
try {
|
||||
const [rows] = await this.db.query(
|
||||
`SELECT word, count FROM suggestion_chosung
|
||||
WHERE chosung LIKE ?
|
||||
ORDER BY count DESC
|
||||
LIMIT ?`,
|
||||
[`${chosung}%`, limit]
|
||||
);
|
||||
|
||||
return rows.map(r => r.word);
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 초성 검색 오류:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 검색어 조회
|
||||
*/
|
||||
async getPopularQueries(limit = 10) {
|
||||
try {
|
||||
// Redis 캐시 확인
|
||||
const cacheKey = `${CONFIG.REDIS_PREFIX}popular`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// DB 조회
|
||||
const [rows] = await this.db.query(
|
||||
`SELECT query, count FROM suggestion_queries
|
||||
ORDER BY count DESC
|
||||
LIMIT ?`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
const result = rows.map(r => r.query);
|
||||
|
||||
// 캐시 저장
|
||||
await this.redis.setex(cacheKey, CONFIG.CACHE_TTL.POPULAR, JSON.stringify(result));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Suggestion] 인기 검색어 조회 오류:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SuggestionService;
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
/**
|
||||
* 형태소 분석 모듈 (kiwi-nlp)
|
||||
* 검색어에서 명사(NNG, NNP, SL)만 추출
|
||||
*/
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let kiwi = null;
|
||||
let isInitialized = false;
|
||||
let initPromise = null;
|
||||
|
||||
// 추출 대상 품사 태그 (세종 품사 태그)
|
||||
const NOUN_TAGS = [
|
||||
'NNG', // 일반명사
|
||||
'NNP', // 고유명사
|
||||
'NNB', // 의존명사
|
||||
'NR', // 수사
|
||||
'SL', // 외국어
|
||||
'SH', // 한자
|
||||
];
|
||||
|
||||
// 모델 파일 목록
|
||||
const MODEL_FILES = [
|
||||
'combiningRule.txt',
|
||||
'default.dict',
|
||||
'dialect.dict',
|
||||
'extract.mdl',
|
||||
'multi.dict',
|
||||
'sj.morph',
|
||||
'typo.dict',
|
||||
'cong.mdl',
|
||||
];
|
||||
|
||||
// 사용자 사전 파일
|
||||
const USER_DICT = 'user.dict';
|
||||
|
||||
/**
|
||||
* kiwi-nlp 초기화 (한 번만 실행)
|
||||
*/
|
||||
export async function initMorpheme() {
|
||||
if (isInitialized) return;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log('[Morpheme] kiwi-nlp 초기화 시작...');
|
||||
|
||||
// kiwi-nlp 동적 import (ESM)
|
||||
const { KiwiBuilder } = await import('kiwi-nlp');
|
||||
|
||||
// wasm 파일 경로
|
||||
const wasmPath = join(__dirname, '../../../node_modules/kiwi-nlp/dist/kiwi-wasm.wasm');
|
||||
|
||||
// 모델 파일 경로
|
||||
const modelDir = join(__dirname, '../../../models/kiwi/models/cong/base');
|
||||
const userDictPath = join(__dirname, '../../../models/kiwi', USER_DICT);
|
||||
|
||||
// KiwiBuilder 생성
|
||||
const builder = await KiwiBuilder.create(wasmPath);
|
||||
|
||||
// 모델 파일 로드
|
||||
const modelFiles = {};
|
||||
for (const filename of MODEL_FILES) {
|
||||
const filepath = join(modelDir, filename);
|
||||
try {
|
||||
modelFiles[filename] = new Uint8Array(readFileSync(filepath));
|
||||
} catch (err) {
|
||||
console.warn(`[Morpheme] 모델 파일 로드 실패: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 사전 로드
|
||||
let userDicts = [];
|
||||
try {
|
||||
modelFiles[USER_DICT] = new Uint8Array(readFileSync(userDictPath));
|
||||
userDicts = [USER_DICT];
|
||||
console.log('[Morpheme] 사용자 사전 로드 완료');
|
||||
} catch (err) {
|
||||
console.warn('[Morpheme] 사용자 사전 없음, 기본 사전만 사용');
|
||||
}
|
||||
|
||||
// Kiwi 인스턴스 생성
|
||||
kiwi = await builder.build({ modelFiles, userDicts });
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[Morpheme] kiwi-nlp 초기화 완료');
|
||||
} catch (error) {
|
||||
console.error('[Morpheme] 초기화 실패:', error.message);
|
||||
// 초기화 실패해도 서비스는 계속 동작 (fallback 사용)
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 명사 추출
|
||||
* @param {string} text - 분석할 텍스트
|
||||
* @returns {Promise<string[]>} - 추출된 명사 배열
|
||||
*/
|
||||
export async function extractNouns(text) {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 초기화 확인
|
||||
if (!isInitialized) {
|
||||
await initMorpheme();
|
||||
}
|
||||
|
||||
// kiwi가 초기화되지 않았으면 fallback
|
||||
if (!kiwi) {
|
||||
console.warn('[Morpheme] kiwi 미초기화, fallback 사용');
|
||||
return fallbackExtract(text);
|
||||
}
|
||||
|
||||
try {
|
||||
// 형태소 분석 실행
|
||||
const result = kiwi.analyze(text);
|
||||
const nouns = [];
|
||||
|
||||
// 분석 결과에서 명사만 추출
|
||||
// result 구조: { score: number, tokens: Array<{str, tag, start, len}> }
|
||||
if (result && result.tokens) {
|
||||
for (const token of result.tokens) {
|
||||
const tag = token.tag;
|
||||
const surface = token.str;
|
||||
|
||||
if (NOUN_TAGS.includes(tag) && surface.length > 0) {
|
||||
const normalized = surface.trim().toLowerCase();
|
||||
if (!nouns.includes(normalized)) {
|
||||
nouns.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nouns.length > 0 ? nouns : fallbackExtract(text);
|
||||
} catch (error) {
|
||||
console.error('[Morpheme] 형태소 분석 오류:', error.message);
|
||||
return fallbackExtract(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: 공백 기준 분리 (형태소 분석 실패 시)
|
||||
*/
|
||||
function fallbackExtract(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 상태 확인
|
||||
*/
|
||||
export function isReady() {
|
||||
return isInitialized && kiwi !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 형태소 분석기 리로드 (사전 변경 시 호출)
|
||||
*/
|
||||
export async function reloadMorpheme() {
|
||||
console.log('[Morpheme] 리로드 시작...');
|
||||
isInitialized = false;
|
||||
kiwi = null;
|
||||
initPromise = null;
|
||||
await initMorpheme();
|
||||
console.log('[Morpheme] 리로드 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 사전 파일 경로 반환
|
||||
*/
|
||||
export function getUserDictPath() {
|
||||
return join(__dirname, '../../../models/kiwi', USER_DICT);
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||
import { fetchVideoInfo } from '../youtube/api.js';
|
||||
import { formatDate, formatTime } from '../../utils/date.js';
|
||||
import bots from '../../config/bots.js';
|
||||
|
||||
const X_CATEGORY_ID = 3;
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
const PROFILE_CACHE_PREFIX = 'x_profile:';
|
||||
const PROFILE_TTL = 604800; // 7일
|
||||
|
||||
async function xBotPlugin(fastify, opts) {
|
||||
/**
|
||||
* 관리 중인 YouTube 채널 ID 목록
|
||||
*/
|
||||
function getManagedChannelIds() {
|
||||
return bots
|
||||
.filter(b => b.type === 'youtube')
|
||||
.map(b => b.channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* X 프로필 캐시 저장
|
||||
*/
|
||||
async function cacheProfile(username, profile) {
|
||||
if (!profile.displayName && !profile.avatarUrl) return;
|
||||
|
||||
const data = {
|
||||
username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await fastify.redis.setex(
|
||||
`${PROFILE_CACHE_PREFIX}${username}`,
|
||||
PROFILE_TTL,
|
||||
JSON.stringify(data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 트윗을 DB에 저장
|
||||
*/
|
||||
async function saveTweet(tweet) {
|
||||
// 중복 체크 (post_id로)
|
||||
const [existing] = await fastify.db.query(
|
||||
'SELECT id FROM schedule_x WHERE post_id = ?',
|
||||
[tweet.id]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = formatDate(tweet.time);
|
||||
const time = formatTime(tweet.time);
|
||||
const title = extractTitle(tweet.text);
|
||||
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await fastify.db.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[X_CATEGORY_ID, title, date, time]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// schedule_x 테이블에 저장
|
||||
await fastify.db.query(
|
||||
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
||||
[
|
||||
scheduleId,
|
||||
tweet.id,
|
||||
tweet.text,
|
||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||
]
|
||||
);
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube 영상을 DB에 저장 (트윗에서 감지된 링크)
|
||||
*/
|
||||
async function saveYoutubeFromTweet(video) {
|
||||
// 중복 체크
|
||||
const [existing] = await fastify.db.query(
|
||||
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
||||
[video.videoId]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await fastify.db.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// schedule_youtube 테이블에 저장
|
||||
await fastify.db.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
|
||||
);
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트윗에서 YouTube 링크 처리
|
||||
*/
|
||||
async function processYoutubeLinks(tweet) {
|
||||
const videoIds = extractYoutubeVideoIds(tweet.text);
|
||||
if (videoIds.length === 0) return 0;
|
||||
|
||||
const managedChannels = getManagedChannelIds();
|
||||
let addedCount = 0;
|
||||
|
||||
for (const videoId of videoIds) {
|
||||
try {
|
||||
const video = await fetchVideoInfo(videoId);
|
||||
if (!video) continue;
|
||||
|
||||
// 관리 중인 채널이면 스킵
|
||||
if (managedChannels.includes(video.channelId)) continue;
|
||||
|
||||
const saved = await saveYoutubeFromTweet(video);
|
||||
if (saved) addedCount++;
|
||||
} catch (err) {
|
||||
fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 트윗 동기화 (정기 실행)
|
||||
*/
|
||||
async function syncNewTweets(bot) {
|
||||
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
|
||||
|
||||
// 프로필 캐시 업데이트
|
||||
await cacheProfile(bot.username, profile);
|
||||
|
||||
let addedCount = 0;
|
||||
let ytAddedCount = 0;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
const scheduleId = await saveTweet(tweet);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
// YouTube 링크 처리
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 트윗 동기화 (초기화)
|
||||
*/
|
||||
async function syncAllTweets(bot) {
|
||||
const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log);
|
||||
|
||||
let addedCount = 0;
|
||||
let ytAddedCount = 0;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
const scheduleId = await saveTweet(tweet);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
ytAddedCount += await processYoutubeLinks(tweet);
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* X 프로필 조회
|
||||
*/
|
||||
async function getProfile(username) {
|
||||
const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
fastify.decorate('xBot', {
|
||||
syncNewTweets,
|
||||
syncAllTweets,
|
||||
getProfile,
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(xBotPlugin, {
|
||||
name: 'xBot',
|
||||
dependencies: ['db', 'redis'],
|
||||
});
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { parseNitterDateTime } from '../../utils/date.js';
|
||||
|
||||
/**
|
||||
* 트윗 텍스트에서 첫 문단 추출 (title용)
|
||||
*/
|
||||
export function extractTitle(text) {
|
||||
if (!text) return '';
|
||||
const paragraphs = text.split(/\n\n+/);
|
||||
return paragraphs[0]?.trim() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 이미지 URL 추출
|
||||
*/
|
||||
export function extractImageUrls(html) {
|
||||
const urls = [];
|
||||
const regex = /href="\/pic\/(orig\/)?media%2F([^"]+)"/g;
|
||||
let match;
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const mediaPath = decodeURIComponent(match[2]);
|
||||
const cleanPath = mediaPath.split('%3F')[0].split('?')[0];
|
||||
urls.push(`https://pbs.twimg.com/media/${cleanPath}`);
|
||||
}
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 유튜브 videoId 추출
|
||||
*/
|
||||
export function extractYoutubeVideoIds(text) {
|
||||
if (!text) return [];
|
||||
const ids = new Set();
|
||||
|
||||
// youtu.be/{id}
|
||||
const shortRegex = /youtu\.be\/([a-zA-Z0-9_-]{11})/g;
|
||||
let m;
|
||||
while ((m = shortRegex.exec(text)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
|
||||
// youtube.com/watch?v={id}
|
||||
const watchRegex = /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g;
|
||||
while ((m = watchRegex.exec(text)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
|
||||
// youtube.com/shorts/{id}
|
||||
const shortsRegex = /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g;
|
||||
while ((m = shortsRegex.exec(text)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 프로필 정보 추출
|
||||
*/
|
||||
export function extractProfile(html) {
|
||||
const profile = { displayName: null, avatarUrl: null };
|
||||
|
||||
const nameMatch = html.match(/class="profile-card-fullname"[^>]*title="([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
profile.displayName = nameMatch[1].trim();
|
||||
}
|
||||
|
||||
const avatarMatch = html.match(/class="profile-card-avatar"[^>]*>[\s\S]*?<img[^>]*src="([^"]+)"/);
|
||||
if (avatarMatch) {
|
||||
let url = avatarMatch[1];
|
||||
const encodedMatch = url.match(/\/pic\/(.+)/);
|
||||
if (encodedMatch) {
|
||||
url = decodeURIComponent(encodedMatch[1]);
|
||||
}
|
||||
profile.avatarUrl = url;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 트윗 목록 파싱
|
||||
*/
|
||||
export function parseTweets(html, username) {
|
||||
const tweets = [];
|
||||
const containers = html.split('class="timeline-item ');
|
||||
|
||||
for (let i = 1; i < containers.length; i++) {
|
||||
const container = containers[i];
|
||||
|
||||
// 고정/리트윗 제외
|
||||
const isPinned = container.includes('class="pinned"');
|
||||
const isRetweet = container.includes('class="retweet-header"');
|
||||
if (isPinned || isRetweet) continue;
|
||||
|
||||
// 트윗 ID
|
||||
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||
if (!idMatch) continue;
|
||||
const id = idMatch[1];
|
||||
|
||||
// 시간
|
||||
const timeMatch = container.match(/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/);
|
||||
const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
|
||||
if (!time) continue;
|
||||
|
||||
// 텍스트
|
||||
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
||||
let text = '';
|
||||
if (contentMatch) {
|
||||
text = contentMatch[1]
|
||||
.replace(/<br\s*\/?>/g, '\n')
|
||||
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 이미지
|
||||
const imageUrls = extractImageUrls(container);
|
||||
|
||||
tweets.push({
|
||||
id,
|
||||
time,
|
||||
text,
|
||||
imageUrls,
|
||||
url: `https://x.com/${username}/status/${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return tweets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||
*/
|
||||
export async function fetchTweets(nitterUrl, username) {
|
||||
const url = `${nitterUrl}/${username}`;
|
||||
const res = await fetch(url);
|
||||
const html = await res.text();
|
||||
|
||||
// 프로필 정보
|
||||
const profile = extractProfile(html);
|
||||
|
||||
// 트윗 파싱
|
||||
const tweets = parseTweets(html, username);
|
||||
|
||||
return { tweets, profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 전체 트윗 수집 (페이지네이션)
|
||||
*/
|
||||
export async function fetchAllTweets(nitterUrl, username, log) {
|
||||
const allTweets = [];
|
||||
let cursor = null;
|
||||
let pageNum = 1;
|
||||
let emptyCount = 0;
|
||||
|
||||
while (true) {
|
||||
const url = cursor
|
||||
? `${nitterUrl}/${username}?cursor=${cursor}`
|
||||
: `${nitterUrl}/${username}`;
|
||||
|
||||
log?.info(`[페이지 ${pageNum}] 스크래핑 중...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const html = await res.text();
|
||||
const tweets = parseTweets(html, username);
|
||||
|
||||
if (tweets.length === 0) {
|
||||
emptyCount++;
|
||||
if (emptyCount >= 3) break;
|
||||
} else {
|
||||
emptyCount = 0;
|
||||
allTweets.push(...tweets);
|
||||
log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`);
|
||||
}
|
||||
|
||||
// 다음 페이지 cursor
|
||||
const cursorMatch = html.match(/class="show-more"[^>]*>\s*<a href="\?cursor=([^"]+)"/);
|
||||
if (!cursorMatch) break;
|
||||
|
||||
cursor = cursorMatch[1];
|
||||
pageNum++;
|
||||
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
} catch (err) {
|
||||
log?.error(` -> 오류: ${err.message}`);
|
||||
emptyCount++;
|
||||
if (emptyCount >= 5) break;
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
|
||||
return allTweets;
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import config from '../../config/index.js';
|
||||
import { formatDate, formatTime } from '../../utils/date.js';
|
||||
|
||||
const API_KEY = config.youtube.apiKey;
|
||||
const API_BASE = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
/**
|
||||
* ISO 8601 duration (PT1M30S) → 초 변환
|
||||
*/
|
||||
function parseDuration(duration) {
|
||||
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
if (!match) return 0;
|
||||
return (
|
||||
parseInt(match[1] || 0) * 3600 +
|
||||
parseInt(match[2] || 0) * 60 +
|
||||
parseInt(match[3] || 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 URL 생성
|
||||
*/
|
||||
function getVideoUrl(videoId, isShorts) {
|
||||
return isShorts
|
||||
? `https://www.youtube.com/shorts/${videoId}`
|
||||
: `https://www.youtube.com/watch?v=${videoId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널의 업로드 플레이리스트 ID 조회
|
||||
*/
|
||||
async function getUploadsPlaylistId(channelId) {
|
||||
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
if (!data.items?.length) {
|
||||
throw new Error('채널을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
return data.items[0].contentDetails.relatedPlaylists.uploads;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 ID 목록으로 duration 조회 (Shorts 판별용)
|
||||
*/
|
||||
async function getVideoDurations(videoIds) {
|
||||
const url = `${API_BASE}/videos?part=contentDetails&id=${videoIds.join(',')}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
const durations = {};
|
||||
if (data.items) {
|
||||
for (const v of data.items) {
|
||||
const seconds = parseDuration(v.contentDetails.duration);
|
||||
durations[v.id] = seconds <= 60;
|
||||
}
|
||||
}
|
||||
return durations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 N개 영상 조회
|
||||
*/
|
||||
export async function fetchRecentVideos(channelId, maxResults = 10) {
|
||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
||||
|
||||
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
const videoIds = data.items.map(item => item.snippet.resourceId.videoId);
|
||||
const shortsMap = await getVideoDurations(videoIds);
|
||||
|
||||
return data.items.map(item => {
|
||||
const { snippet } = item;
|
||||
const videoId = snippet.resourceId.videoId;
|
||||
const isShorts = shortsMap[videoId] || false;
|
||||
const publishedAt = new Date(snippet.publishedAt);
|
||||
|
||||
return {
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || '',
|
||||
channelId: snippet.channelId,
|
||||
channelTitle: snippet.channelTitle,
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoType: isShorts ? 'shorts' : 'video',
|
||||
videoUrl: getVideoUrl(videoId, isShorts),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 영상 조회 (페이지네이션)
|
||||
*/
|
||||
export async function fetchAllVideos(channelId) {
|
||||
const uploadsId = await getUploadsPlaylistId(channelId);
|
||||
const videos = [];
|
||||
let pageToken = '';
|
||||
|
||||
do {
|
||||
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=50&key=${API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
const videoIds = data.items.map(item => item.snippet.resourceId.videoId);
|
||||
const shortsMap = await getVideoDurations(videoIds);
|
||||
|
||||
for (const item of data.items) {
|
||||
const { snippet } = item;
|
||||
const videoId = snippet.resourceId.videoId;
|
||||
const isShorts = shortsMap[videoId] || false;
|
||||
const publishedAt = new Date(snippet.publishedAt);
|
||||
|
||||
videos.push({
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || '',
|
||||
channelId: snippet.channelId,
|
||||
channelTitle: snippet.channelTitle,
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoType: isShorts ? 'shorts' : 'video',
|
||||
videoUrl: getVideoUrl(videoId, isShorts),
|
||||
});
|
||||
}
|
||||
|
||||
pageToken = data.nextPageToken || '';
|
||||
} while (pageToken);
|
||||
|
||||
// 과거순 정렬
|
||||
videos.sort((a, b) => a.publishedAt - b.publishedAt);
|
||||
return videos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 영상 정보 조회
|
||||
*/
|
||||
export async function fetchVideoInfo(videoId) {
|
||||
const url = `${API_BASE}/videos?part=snippet,contentDetails&id=${videoId}&key=${API_KEY}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.items?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = data.items[0];
|
||||
const { snippet, contentDetails } = video;
|
||||
const seconds = parseDuration(contentDetails.duration);
|
||||
const isShorts = seconds > 0 && seconds <= 60;
|
||||
const publishedAt = new Date(snippet.publishedAt);
|
||||
|
||||
return {
|
||||
videoId,
|
||||
title: snippet.title,
|
||||
description: snippet.description || '',
|
||||
channelId: snippet.channelId,
|
||||
channelTitle: snippet.channelTitle,
|
||||
publishedAt,
|
||||
date: formatDate(publishedAt),
|
||||
time: formatTime(publishedAt),
|
||||
videoType: isShorts ? 'shorts' : 'video',
|
||||
videoUrl: getVideoUrl(videoId, isShorts),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { fetchRecentVideos, fetchAllVideos } from './api.js';
|
||||
import bots from '../../config/bots.js';
|
||||
|
||||
const YOUTUBE_CATEGORY_ID = 2;
|
||||
|
||||
async function youtubeBotPlugin(fastify, opts) {
|
||||
/**
|
||||
* 멤버 이름 맵 조회
|
||||
*/
|
||||
async function getMemberNameMap() {
|
||||
const [rows] = await fastify.db.query('SELECT id, name FROM members');
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
map[r.name] = r.id;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* description에서 멤버 추출
|
||||
*/
|
||||
function extractMemberIds(description, memberNameMap) {
|
||||
if (!description) return [];
|
||||
const ids = [];
|
||||
for (const [name, id] of Object.entries(memberNameMap)) {
|
||||
if (description.includes(name)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상을 DB에 저장
|
||||
*/
|
||||
async function saveVideo(video, bot) {
|
||||
// 중복 체크 (video_id로)
|
||||
const [existing] = await fastify.db.query(
|
||||
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
||||
[video.videoId]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 커스텀 설정 적용
|
||||
if (bot.titleFilter && !video.title.includes(bot.titleFilter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await fastify.db.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// schedule_youtube 테이블에 저장
|
||||
await fastify.db.query(
|
||||
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
|
||||
);
|
||||
|
||||
// 멤버 연결 (커스텀 설정)
|
||||
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
|
||||
const memberIds = [];
|
||||
if (bot.defaultMemberId) {
|
||||
memberIds.push(bot.defaultMemberId);
|
||||
}
|
||||
if (bot.extractMembersFromDesc) {
|
||||
const nameMap = await getMemberNameMap();
|
||||
memberIds.push(...extractMemberIds(video.description, nameMap));
|
||||
}
|
||||
if (memberIds.length > 0) {
|
||||
const uniqueIds = [...new Set(memberIds)];
|
||||
const values = uniqueIds.map(id => [scheduleId, id]);
|
||||
await fastify.db.query(
|
||||
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 영상 동기화 (정기 실행)
|
||||
*/
|
||||
async function syncNewVideos(bot) {
|
||||
const videos = await fetchRecentVideos(bot.channelId, 10);
|
||||
let addedCount = 0;
|
||||
|
||||
for (const video of videos) {
|
||||
const scheduleId = await saveVideo(video, bot);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount, total: videos.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 영상 동기화 (초기화)
|
||||
*/
|
||||
async function syncAllVideos(bot) {
|
||||
const videos = await fetchAllVideos(bot.channelId);
|
||||
let addedCount = 0;
|
||||
|
||||
for (const video of videos) {
|
||||
const scheduleId = await saveVideo(video, bot);
|
||||
if (scheduleId) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { addedCount, total: videos.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 중인 채널 ID 목록
|
||||
*/
|
||||
function getManagedChannelIds() {
|
||||
return bots
|
||||
.filter(b => b.type === 'youtube')
|
||||
.map(b => b.channelId);
|
||||
}
|
||||
|
||||
fastify.decorate('youtubeBot', {
|
||||
syncNewVideos,
|
||||
syncAllVideos,
|
||||
getManagedChannelIds,
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(youtubeBotPlugin, {
|
||||
name: 'youtubeBot',
|
||||
dependencies: ['db'],
|
||||
});
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import timezone from 'dayjs/plugin/timezone.js';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const KST = 'Asia/Seoul';
|
||||
|
||||
/**
|
||||
* UTC Date를 KST dayjs 객체로 변환
|
||||
*/
|
||||
export function toKST(date) {
|
||||
return dayjs(date).tz(KST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜를 YYYY-MM-DD 형식으로 포맷 (KST)
|
||||
*/
|
||||
export function formatDate(date) {
|
||||
return dayjs(date).tz(KST).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간을 HH:mm:ss 형식으로 포맷 (KST)
|
||||
*/
|
||||
export function formatTime(date) {
|
||||
return dayjs(date).tz(KST).format('HH:mm:ss');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter 날짜 문자열 파싱
|
||||
* 예: "Jan 15, 2026 · 10:30 PM UTC"
|
||||
*/
|
||||
export function parseNitterDateTime(timeStr) {
|
||||
if (!timeStr) return null;
|
||||
const cleaned = timeStr.replace(' · ', ' ').replace(' UTC', '');
|
||||
const date = new Date(cleaned + ' UTC');
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
60
docker-compose.dev.yml
Normal file
60
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
services:
|
||||
# 프론트엔드 - Vite 개발 서버
|
||||
fromis9-frontend:
|
||||
image: node:20-alpine
|
||||
container_name: fromis9-frontend
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 80"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
networks:
|
||||
- app
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
# 백엔드 - Express API 서버
|
||||
fromis9-backend:
|
||||
image: node:20-alpine
|
||||
container_name: fromis9-backend
|
||||
working_dir: /app
|
||||
command: sh -c "apk add --no-cache ffmpeg && npm install && node server.js"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
networks:
|
||||
- app
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
# Meilisearch - 검색 엔진
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.6
|
||||
container_name: fromis9-meilisearch
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
volumes:
|
||||
- ./meilisearch_data:/meili_data
|
||||
networks:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis - 추천 검색어 캐시
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: fromis9-redis
|
||||
volumes:
|
||||
- ./redis_data:/data
|
||||
networks:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
app:
|
||||
external: true
|
||||
db:
|
||||
external: true
|
||||
|
|
@ -1,19 +1,11 @@
|
|||
services:
|
||||
fromis9-frontend:
|
||||
fromis9-web:
|
||||
build: .
|
||||
container_name: fromis9-frontend
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
env_file:
|
||||
- .env
|
||||
# 개발 모드
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
- ./frontend:/app/frontend
|
||||
- backend_modules:/app/backend/node_modules
|
||||
- frontend_modules:/app/frontend/node_modules
|
||||
# 배포 모드 (사용 시 위 volumes를 주석처리)
|
||||
# volumes: []
|
||||
networks:
|
||||
- app
|
||||
- db
|
||||
|
|
@ -30,19 +22,6 @@ services:
|
|||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: fromis9-redis
|
||||
volumes:
|
||||
- ./redis_data:/data
|
||||
networks:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend_modules:
|
||||
frontend_modules:
|
||||
|
||||
networks:
|
||||
app:
|
||||
external: true
|
||||
|
|
|
|||
852
docs/PROJECT_STRUCTURE.md
Normal file
852
docs/PROJECT_STRUCTURE.md
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
# fromis_9 팬사이트 프로젝트 분석 결과
|
||||
|
||||
> **분석일**: 2026-01-11
|
||||
> **프로젝트 경로**: `/docker/fromis_9`
|
||||
> **사이트 URL**: `https://fromis9.caadiq.co.kr`
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT] > **현재 개발 환경 활성화 상태**
|
||||
> `docker-compose.dev.yml`로 실행 중이며, 프론트엔드는 Vite HMR, 백엔드는 Node.js로 분리 운영됩니다.
|
||||
>
|
||||
> - **프론트엔드**: `fromis9-frontend` (Vite dev server, 포트 80)
|
||||
> - **백엔드**: `fromis9-backend` (Express, 포트 3000)
|
||||
> - 파일 수정 시 **자동 반영** (빌드 불필요)
|
||||
|
||||
## 1. 시스템 아키텍처 개요
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "클라이언트"
|
||||
PC[PC 브라우저]
|
||||
Mobile[모바일 브라우저]
|
||||
end
|
||||
|
||||
subgraph "Caddy 역방향 프록시"
|
||||
Caddy[fromis9.caadiq.co.kr<br/>500MB 업로드 허용]
|
||||
end
|
||||
|
||||
subgraph "Docker 컨테이너"
|
||||
Frontend[fromis9-frontend:80<br/>React + Express]
|
||||
Meili[fromis9-meilisearch:7700<br/>검색 엔진]
|
||||
end
|
||||
|
||||
subgraph "외부 서비스"
|
||||
MariaDB[(MariaDB<br/>fromis9 DB)]
|
||||
RustFS[RustFS S3<br/>이미지 스토리지]
|
||||
YouTube[YouTube API]
|
||||
Nitter[Nitter<br/>X/Twitter 브릿지]
|
||||
end
|
||||
|
||||
PC --> Caddy
|
||||
Mobile --> Caddy
|
||||
Caddy --> Frontend
|
||||
Frontend --> MariaDB
|
||||
Frontend --> Meili
|
||||
Frontend --> RustFS
|
||||
Frontend --> YouTube
|
||||
Frontend --> Nitter
|
||||
```
|
||||
|
||||
### 기술 스택
|
||||
|
||||
| 계층 | 기술 |
|
||||
| ------------------- | ----------------------------- |
|
||||
| **프론트엔드** | React 18 + Vite + TailwindCSS |
|
||||
| **백엔드** | Node.js (Express) |
|
||||
| **데이터베이스** | MariaDB (`fromis9` DB) |
|
||||
| **검색 엔진** | Meilisearch v1.6 |
|
||||
| **미디어 스토리지** | RustFS (S3 호환) |
|
||||
| **역방향 프록시** | Caddy (SSL 자동화) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 디렉토리 구조
|
||||
|
||||
```
|
||||
/docker/fromis_9
|
||||
├── .env # 환경 변수 (DB, S3, API 키)
|
||||
├── docker-compose.yml # 프로덕션 오케스트레이션
|
||||
├── docker-compose.dev.yml # 개발 환경 (HMR 지원)
|
||||
├── Dockerfile # 빌드 정의
|
||||
│
|
||||
├── backend/ # Express API 서버
|
||||
│ ├── server.js # 진입점, 라우팅, Meilisearch 초기화
|
||||
│ ├── routes/
|
||||
│ │ ├── admin.js # 관리자 CRUD (60KB, 핵심 로직)
|
||||
│ │ ├── albums.js # 앨범 조회 API
|
||||
│ │ ├── members.js # 멤버 조회 API
|
||||
│ │ ├── schedules.js # 일정 조회/검색 API
|
||||
│ │ └── stats.js # 통계 API
|
||||
│ ├── services/
|
||||
│ │ ├── meilisearch.js # 검색 인덱스 관리
|
||||
│ │ ├── meilisearch-bot.js # 1시간 주기 동기화 봇
|
||||
│ │ ├── youtube-bot.js # YouTube API 수집 봇
|
||||
│ │ ├── youtube-scheduler.js # Cron 스케줄러
|
||||
│ │ └── x-bot.js # X(Nitter) 수집 봇
|
||||
│ └── lib/
|
||||
│ ├── db.js # MariaDB 커넥션 풀
|
||||
│ └── date.js # Day.js 기반 날짜 유틸리티
|
||||
│
|
||||
└── frontend/ # React SPA
|
||||
├── vite.config.js # 빌드 및 프록시 설정
|
||||
├── tailwind.config.js # 테마 (Primary: #FF4D8D)
|
||||
└── src/
|
||||
├── App.jsx # 라우팅 (PC/Mobile 분기)
|
||||
├── main.jsx # 진입점
|
||||
├── index.css # 글로벌 스타일
|
||||
├── pc.css # PC 전용 스타일
|
||||
├── mobile.css # Mobile 전용 스타일
|
||||
├── pages/
|
||||
│ ├── pc/public/ # PC 공개 페이지
|
||||
│ ├── pc/admin/ # PC 관리자 페이지
|
||||
│ ├── mobile/public/ # Mobile 공개 페이지
|
||||
│ └── mobile/admin/ # Mobile 관리자 페이지
|
||||
├── components/ # 재사용 컴포넌트
|
||||
├── api/ # API 호출 유틸리티
|
||||
├── stores/ # Zustand 상태 관리
|
||||
└── utils/ # 공통 유틸리티
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터베이스 스키마 (MariaDB `fromis9`)
|
||||
|
||||
### 테이블 목록 (14개)
|
||||
|
||||
```
|
||||
admin_users # 관리자 계정
|
||||
members # 그룹 멤버 프로필
|
||||
albums # 앨범 메타데이터
|
||||
tracks # 앨범 트랙 목록
|
||||
album_photos # 앨범 컨셉 포토
|
||||
album_photo_members # 포토-멤버 매핑
|
||||
album_teasers # 티저 미디어
|
||||
schedules # 일정/활동
|
||||
schedule_categories # 일정 카테고리
|
||||
schedule_members # 일정-멤버 매핑
|
||||
schedule_images # 일정 이미지
|
||||
bots # 자동화 봇 설정
|
||||
bot_youtube_config # YouTube 봇 설정
|
||||
bot_x_config # X 봇 설정
|
||||
```
|
||||
|
||||
### 주요 테이블 상세
|
||||
|
||||
#### `members` - 멤버 프로필
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| ---------- | ------------ | ------------- |
|
||||
| id | int | PK |
|
||||
| name | varchar(50) | 이름 |
|
||||
| birth_date | date | 생년월일 |
|
||||
| position | varchar(100) | 포지션 |
|
||||
| image_url | varchar(500) | 프로필 이미지 |
|
||||
| instagram | varchar(200) | 인스타그램 |
|
||||
| is_former | tinyint | 전 멤버 여부 |
|
||||
|
||||
#### `albums` - 앨범 정보
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| ------------------ | -------------------------- | -------------------- |
|
||||
| id | int | PK |
|
||||
| title | varchar(200) | 앨범명 |
|
||||
| album_type | varchar(100) | 전체 타입명 |
|
||||
| album_type_short | enum('정규','미니','싱글') | 축약 타입 |
|
||||
| release_date | date | 발매일 |
|
||||
| cover_original_url | varchar(500) | 원본 커버 (lossless) |
|
||||
| cover_medium_url | varchar(500) | 중간 커버 (800px) |
|
||||
| cover_thumb_url | varchar(500) | 썸네일 (400px) |
|
||||
| folder_name | varchar(200) | S3 폴더명 |
|
||||
| description | text | 앨범 설명 |
|
||||
|
||||
#### `schedules` - 일정
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| ------------------ | ------------ | ---------------------------- |
|
||||
| id | int | PK |
|
||||
| title | varchar(500) | 일정 제목 |
|
||||
| category_id | int | FK → schedule_categories |
|
||||
| date | date | 날짜 |
|
||||
| time | time | 시간 |
|
||||
| end_date, end_time | date, time | 종료 시간 |
|
||||
| description | text | 상세 설명 |
|
||||
| location\_\* | various | 위치 정보 (이름, 주소, 좌표) |
|
||||
| source_url | varchar(500) | 출처 URL |
|
||||
| source_name | varchar(100) | 출처명 |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 라우트 구조
|
||||
|
||||
### 공개 API (`/api/*`)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
| --------------------------- | ------ | ------------------------ |
|
||||
| `/api/health` | GET | 헬스체크 |
|
||||
| `/api/members` | GET | 멤버 목록 |
|
||||
| `/api/albums` | GET | 앨범 목록 (트랙 포함) |
|
||||
| `/api/albums/by-name/:name` | GET | 앨범명으로 상세 조회 |
|
||||
| `/api/albums/:id` | GET | ID로 앨범 상세 조회 |
|
||||
| `/api/schedules` | GET | 일정 목록 (검색, 필터링) |
|
||||
| `/api/schedules/categories` | GET | 카테고리 목록 |
|
||||
| `/api/schedules/:id` | GET | 개별 일정 조회 |
|
||||
| `/api/stats` | GET | 사이트 통계 |
|
||||
|
||||
### 관리자 API (`/api/admin/*`)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
| ----------------------------------- | --------------- | ----------------- |
|
||||
| `/api/admin/login` | POST | 로그인 (JWT 발급) |
|
||||
| `/api/admin/verify` | GET | 토큰 검증 |
|
||||
| `/api/admin/albums` | POST/PUT/DELETE | 앨범 CRUD |
|
||||
| `/api/admin/albums/:albumId/photos` | POST/DELETE | 컨셉 포토 관리 |
|
||||
| `/api/admin/schedules` | POST/PUT/DELETE | 일정 CRUD |
|
||||
| `/api/admin/bots` | GET/POST/PUT | 봇 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 라우팅 (PC/Mobile 분기)
|
||||
|
||||
```jsx
|
||||
// App.jsx - react-device-detect 사용
|
||||
<BrowserView> {/* PC 환경 */}
|
||||
<PCLayout>
|
||||
<Route path="/" element={<PCHome />} />
|
||||
<Route path="/members" element={<PCMembers />} />
|
||||
<Route path="/album" element={<PCAlbum />} />
|
||||
<Route path="/album/:name" element={<PCAlbumDetail />} />
|
||||
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
||||
<Route path="/schedule" element={<PCSchedule />} />
|
||||
</PCLayout>
|
||||
</BrowserView>
|
||||
|
||||
<MobileView> {/* Mobile 환경 */}
|
||||
<MobileLayout>
|
||||
<!-- 동일한 라우트, 다른 컴포넌트 -->
|
||||
</MobileLayout>
|
||||
</MobileView>
|
||||
```
|
||||
|
||||
### 관리자 페이지 (/admin/\*)
|
||||
|
||||
- `/admin` - 로그인
|
||||
- `/admin/dashboard` - 대시보드
|
||||
- `/admin/members` - 멤버 관리
|
||||
- `/admin/albums` - 앨범 관리
|
||||
- `/admin/schedule` - 일정 관리
|
||||
- `/admin/schedule/bots` - 봇 관리
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동화 봇 시스템
|
||||
|
||||
### 봇 유형 및 동작
|
||||
|
||||
| 봇 타입 | 수집 대상 | 동작 방식 |
|
||||
| --------------- | ------------------ | ---------------------------------------------- |
|
||||
| **YouTube** | 채널 영상 | YouTube API로 최근 영상 수집, Shorts 자동 판별 |
|
||||
| **X** | @realfromis_9 트윗 | Nitter 브릿지 → RSS 파싱 |
|
||||
| **Meilisearch** | 일정 데이터 | 1시간 주기 전체 동기화 |
|
||||
|
||||
### 스케줄러 동작 흐름
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Server as server.js
|
||||
participant Scheduler as youtube-scheduler.js
|
||||
participant Bot as youtube-bot.js / x-bot.js
|
||||
participant DB as MariaDB
|
||||
participant Meili as Meilisearch
|
||||
|
||||
Server->>Scheduler: initScheduler()
|
||||
Scheduler->>DB: SELECT * FROM bots WHERE status='running'
|
||||
Scheduler->>Scheduler: node-cron 등록
|
||||
|
||||
loop 매 N분 (cron_expression)
|
||||
Scheduler->>Bot: syncNewVideos() / syncNewTweets()
|
||||
Bot->>DB: 중복 체크 (source_url)
|
||||
Bot->>DB: INSERT INTO schedules
|
||||
Bot->>Meili: addOrUpdateSchedule()
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 이미지 처리 파이프라인
|
||||
|
||||
### Sharp 3단계 변환
|
||||
|
||||
모든 업로드 이미지는 자동으로 3가지 해상도로 변환:
|
||||
|
||||
```javascript
|
||||
// admin.js 에서 처리
|
||||
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
||||
sharp(buffer).webp({ lossless: true }).toBuffer(), // original/
|
||||
sharp(buffer).resize(800, null).webp({ quality: 80 }), // medium_800/
|
||||
sharp(buffer).resize(400, null).webp({ quality: 80 }), // thumb_400/
|
||||
]);
|
||||
```
|
||||
|
||||
### RustFS 저장 구조
|
||||
|
||||
```
|
||||
s3.caadiq.co.kr/fromis-9/
|
||||
├── albums/{folder_name}/
|
||||
│ ├── original/cover.webp
|
||||
│ ├── medium_800/cover.webp
|
||||
│ └── thumb_400/cover.webp
|
||||
└── photos/{album_id}/
|
||||
├── original/{filename}.webp
|
||||
├── medium_800/{filename}.webp
|
||||
└── thumb_400/{filename}.webp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 검색 시스템 (Meilisearch)
|
||||
|
||||
### 검색 특징
|
||||
|
||||
- **영한 자판 변환**: Inko 라이브러리로 영문 자판 입력 → 한글 변환
|
||||
- **유사도 임계값**: 0.5 미만 결과 필터링
|
||||
- **검색 대상 필드**: title, member_names, description, source_name, category_name
|
||||
|
||||
### 인덱스 설정
|
||||
|
||||
```javascript
|
||||
// meilisearch.js
|
||||
await index.updateSearchableAttributes([
|
||||
"title",
|
||||
"member_names",
|
||||
"description",
|
||||
"source_name",
|
||||
"category_name",
|
||||
]);
|
||||
|
||||
await index.updateRankingRules([
|
||||
"words",
|
||||
"typo",
|
||||
"proximity",
|
||||
"attribute",
|
||||
"exactness",
|
||||
"date:desc", // 최신 날짜 우선
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 네트워크 설정 (Caddy)
|
||||
|
||||
```caddyfile
|
||||
# /docker/caddy/Caddyfile
|
||||
|
||||
fromis9.caadiq.co.kr {
|
||||
import custom_errors
|
||||
|
||||
# 대용량 업로드 허용 (500MB) - 컨셉 포토 일괄 업로드용
|
||||
request_body {
|
||||
max_size 500MB
|
||||
}
|
||||
|
||||
reverse_proxy fromis9-frontend:80
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 환경 변수 (.env)
|
||||
|
||||
| 변수 | 용도 |
|
||||
| ------------------------ | -------------------- |
|
||||
| `DB_HOST=mariadb` | MariaDB 컨테이너 |
|
||||
| `DB_NAME=fromis9` | 데이터베이스명 |
|
||||
| `DB_USER/PASSWORD` | DB 접속 정보 |
|
||||
| `RUSTFS_ENDPOINT` | RustFS S3 엔드포인트 |
|
||||
| `RUSTFS_PUBLIC_URL` | 공개 S3 URL |
|
||||
| `RUSTFS_BUCKET=fromis-9` | S3 버킷명 |
|
||||
| `YOUTUBE_API_KEY` | YouTube Data API |
|
||||
| `KAKAO_REST_KEY` | 카카오 API (지도) |
|
||||
| `MEILI_MASTER_KEY` | Meilisearch 인증 |
|
||||
| `JWT_SECRET` | 관리자 JWT 서명 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 개발 환경 시작
|
||||
|
||||
```bash
|
||||
# 개발 모드 (HMR 활성화)
|
||||
cd /docker/fromis_9
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# 프론트엔드: fromis9-frontend (Vite dev server)
|
||||
# 백엔드: fromis9-backend (Express)
|
||||
# 검색: fromis9-meilisearch
|
||||
```
|
||||
|
||||
> **참고**: Vite HMR이 활성화되어 있으므로 파일 수정 시 자동 반영됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 주요 파일 크기 참고
|
||||
|
||||
| 파일 | 크기 | 비고 |
|
||||
| ----------------------------------------------- | -------------- | -------------------- |
|
||||
| `backend/routes/admin.js` | 60KB (1,986줄) | 핵심 CRUD 로직 |
|
||||
| `frontend/src/pages/pc/public/Schedule.jsx` | 62KB | 일정 페이지 (가상화) |
|
||||
| `frontend/src/pages/mobile/public/Schedule.jsx` | 52KB | 모바일 일정 |
|
||||
| `backend/services/youtube-bot.js` | 17KB | YouTube 수집 |
|
||||
| `backend/services/x-bot.js` | 16KB | X 수집 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 모바일 앨범 갤러리 UI
|
||||
|
||||
### 주요 컴포넌트
|
||||
|
||||
| 파일 | 설명 |
|
||||
| ------------------------------------------------------ | ----------------------------- |
|
||||
| `frontend/src/pages/mobile/public/AlbumGallery.jsx` | 모바일 앨범 갤러리 (전체보기) |
|
||||
| `frontend/src/pages/mobile/public/AlbumDetail.jsx` | 모바일 앨범 상세 |
|
||||
| `frontend/src/components/common/LightboxIndicator.jsx` | 공통 슬라이딩 점 인디케이터 |
|
||||
|
||||
### Swiper ViewPager 스타일 라이트박스
|
||||
|
||||
```jsx
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Virtual } from "swiper/modules";
|
||||
|
||||
<Swiper
|
||||
modules={[Virtual]}
|
||||
virtual
|
||||
initialSlide={selectedIndex}
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onSlideChange={(swiper) => setSelectedIndex(swiper.activeIndex)}
|
||||
slidesPerView={1}
|
||||
resistance={true}
|
||||
resistanceRatio={0.5}
|
||||
>
|
||||
{photos.map((photo, index) => (
|
||||
<SwiperSlide key={index} virtualIndex={index}>
|
||||
<img src={photo.medium_url} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>;
|
||||
```
|
||||
|
||||
### LightboxIndicator 사용법
|
||||
|
||||
```jsx
|
||||
import LightboxIndicator from '../../../components/common/LightboxIndicator';
|
||||
|
||||
// PC (기본 width 200px)
|
||||
<LightboxIndicator
|
||||
count={photos.length}
|
||||
currentIndex={selectedIndex}
|
||||
goToIndex={(i) => swiperRef.current?.slideTo(i)}
|
||||
/>
|
||||
|
||||
// 모바일 (width 120px로 축소)
|
||||
<LightboxIndicator
|
||||
count={photos.length}
|
||||
currentIndex={selectedIndex}
|
||||
goToIndex={(i) => swiperRef.current?.slideTo(i)}
|
||||
width={120}
|
||||
/>
|
||||
```
|
||||
|
||||
### 2열 지그재그 Masonry 그리드
|
||||
|
||||
```jsx
|
||||
// 1,3,5번 → 왼쪽 열 / 2,4,6번 → 오른쪽 열
|
||||
const distributePhotos = () => {
|
||||
const leftColumn = [];
|
||||
const rightColumn = [];
|
||||
photos.forEach((photo, index) => {
|
||||
if (index % 2 === 0) leftColumn.push({ ...photo, originalIndex: index });
|
||||
else rightColumn.push({ ...photo, originalIndex: index });
|
||||
});
|
||||
return { leftColumn, rightColumn };
|
||||
};
|
||||
```
|
||||
|
||||
### 뒤로가기 처리 패턴
|
||||
|
||||
```jsx
|
||||
// 모달/라이트박스 열 때 히스토리 추가
|
||||
const openLightbox = useCallback((images, index, options = {}) => {
|
||||
setLightbox({ open: true, images, index, ...options });
|
||||
window.history.pushState({ lightbox: true }, "");
|
||||
}, []);
|
||||
|
||||
// popstate 이벤트로 닫기
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (showModal) setShowModal(false);
|
||||
else if (lightbox.open) setLightbox((prev) => ({ ...prev, open: false }));
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [showModal, lightbox.open]);
|
||||
|
||||
// X 버튼도 history.back() 호출
|
||||
<button onClick={() => window.history.back()}>
|
||||
<X size={24} />
|
||||
</button>;
|
||||
```
|
||||
|
||||
### 바텀시트 (정보 표시)
|
||||
|
||||
```jsx
|
||||
<motion.div
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 300) {
|
||||
window.history.back();
|
||||
}
|
||||
}}
|
||||
className="bg-zinc-900 rounded-t-3xl"
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 bg-zinc-600 rounded-full" />
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Redis 기반 Bi-gram 추천 검색어 시스템
|
||||
|
||||
### 아키텍처 개요
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
User[사용자 검색] --> API[/api/schedules/suggestions]
|
||||
API --> Redis[(Redis)]
|
||||
Redis --> |bi-gram 매칭| Results[추천 검색어]
|
||||
|
||||
Admin[관리자 일정 CRUD] --> Extract[키워드 추출]
|
||||
Extract --> Redis
|
||||
```
|
||||
|
||||
### 주요 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
| ----------------------------------------------- | ---------------------------------------------- |
|
||||
| `backend/routes/schedules.js` | 추천 검색어 API (`/api/schedules/suggestions`) |
|
||||
| `backend/scripts/extract-keywords.js` | 기존 일정에서 키워드 일괄 추출 스크립트 |
|
||||
| `frontend/src/pages/pc/admin/Schedule.jsx` | 관리자 검색창 드롭다운 |
|
||||
| `frontend/src/pages/pc/public/Schedule.jsx` | PC 검색 추천 UI |
|
||||
| `frontend/src/pages/mobile/public/Schedule.jsx` | 모바일 유튜브 스타일 추천 리스트 |
|
||||
|
||||
### Redis 데이터 구조
|
||||
|
||||
```
|
||||
fromis9:search:suggestions (Sorted Set)
|
||||
├── "쇼케이스" → score: 15
|
||||
├── "팬미팅" → score: 12
|
||||
├── "라디오" → score: 8
|
||||
└── ...
|
||||
|
||||
fromis9:search:bigrams (Hash)
|
||||
├── "쇼케" → "쇼케이스,쇼케이스투어"
|
||||
├── "케이" → "쇼케이스,케이팝"
|
||||
└── ...
|
||||
```
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
```javascript
|
||||
// GET /api/schedules/suggestions?q=쇼케
|
||||
// Response: ["쇼케이스", "쇼케이스 투어", ...]
|
||||
|
||||
router.get("/suggestions", async (req, res) => {
|
||||
const query = req.query.q?.trim();
|
||||
if (!query || query.length < 2) return res.json([]);
|
||||
|
||||
// bi-gram 매칭
|
||||
const bigram = query.slice(0, 2);
|
||||
const cached = await redis.hget("fromis9:search:bigrams", bigram);
|
||||
|
||||
if (cached) {
|
||||
const keywords = cached
|
||||
.split(",")
|
||||
.filter((k) => k.toLowerCase().includes(query.toLowerCase()))
|
||||
.slice(0, 10);
|
||||
return res.json(keywords);
|
||||
}
|
||||
|
||||
res.json([]);
|
||||
});
|
||||
```
|
||||
|
||||
### 키워드 추출 로직 (일정 저장 시)
|
||||
|
||||
```javascript
|
||||
// admin.js - 일정 저장 시 키워드 추출
|
||||
const extractKeywords = (title) => {
|
||||
// 특수문자 제거, 공백으로 분리
|
||||
const words = title.replace(/[^\w\s가-힣]/g, " ").split(/\s+/);
|
||||
return words.filter((w) => w.length >= 2);
|
||||
};
|
||||
|
||||
// Redis에 저장
|
||||
for (const keyword of keywords) {
|
||||
await redis.zincrby("fromis9:search:suggestions", 1, keyword);
|
||||
|
||||
// bi-gram 인덱스
|
||||
for (let i = 0; i < keyword.length - 1; i++) {
|
||||
const bigram = keyword.slice(i, i + 2);
|
||||
const existing = await redis.hget("fromis9:search:bigrams", bigram);
|
||||
const set = new Set(existing ? existing.split(",") : []);
|
||||
set.add(keyword);
|
||||
await redis.hset("fromis9:search:bigrams", bigram, [...set].join(","));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 프론트엔드 UI
|
||||
|
||||
#### PC 관리자/공개 페이지 - 드롭다운
|
||||
|
||||
```jsx
|
||||
// 검색창 아래 드롭다운
|
||||
{
|
||||
suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white border rounded-lg shadow-lg z-50">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSuggestionClick(s)}
|
||||
className="w-full px-4 py-2 text-left hover:bg-gray-100"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 모바일 - 유튜브 스타일 리스트
|
||||
|
||||
```jsx
|
||||
// 검색창 아래 전체 화면 리스트
|
||||
{
|
||||
showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute inset-x-0 top-12 bottom-0 bg-white z-50">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSuggestionClick(s)}
|
||||
className="w-full px-4 py-3 flex items-center gap-3 border-b"
|
||||
>
|
||||
<Search size={16} className="text-gray-400" />
|
||||
<span>{s}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 키워드 일괄 추출 스크립트
|
||||
|
||||
```bash
|
||||
# 기존 일정에서 키워드 추출하여 Redis에 저장
|
||||
cd /docker/fromis_9/backend
|
||||
node scripts/extract-keywords.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 모바일 앱 (`/app`)
|
||||
|
||||
### 기술 스택
|
||||
|
||||
| 계층 | 기술 |
|
||||
| -------------- | ------------------------------------------------------------------------ |
|
||||
| **프레임워크** | Expo (React Native) |
|
||||
| **언어** | TypeScript |
|
||||
| **네비게이션** | React Navigation (Tab + Stack) |
|
||||
| **UI 효과** | expo-blur, expo-linear-gradient, react-native-color-matrix-image-filters |
|
||||
| **미디어** | expo-file-system, expo-media-library, react-native-pager-view |
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
app/src/
|
||||
├── api/ # API 호출 함수
|
||||
│ ├── albums.ts # 앨범 API (AlbumPhoto에 width/height 포함)
|
||||
│ ├── members.ts # 멤버 API
|
||||
│ └── schedules.ts # 일정 API
|
||||
├── components/ # 공통 컴포넌트
|
||||
│ └── common/
|
||||
│ └── Header.tsx # 공통 헤더 (title, showBack, rightElement)
|
||||
├── constants/
|
||||
│ └── colors.ts # 테마 색상 (primary: #FF4D8D)
|
||||
├── navigation/
|
||||
│ └── AppNavigator.tsx # 탭 + 스택 네비게이션
|
||||
└── screens/
|
||||
├── HomeScreen.tsx # 홈 (멤버, 앨범, 일정 요약)
|
||||
├── MembersScreen.tsx # 멤버 목록 + 바텀시트 상세
|
||||
├── AlbumScreen.tsx # 앨범 목록 (2열 그리드)
|
||||
├── AlbumDetailScreen.tsx # 앨범 상세 (트랙, 티저, 포토)
|
||||
├── AlbumGalleryScreen.tsx # 컨셉포토 갤러리 (라이트박스)
|
||||
└── ScheduleScreen.tsx # 일정 목록
|
||||
```
|
||||
|
||||
### 네비게이션 구조
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
TabNav[TabNavigator 하단 탭]
|
||||
TabNav --> HomeTab[홈]
|
||||
TabNav --> MembersTab[멤버]
|
||||
TabNav --> AlbumTab[앨범]
|
||||
TabNav --> ScheduleTab[일정]
|
||||
|
||||
AlbumTab --> AlbumStack[AlbumStackNavigator]
|
||||
AlbumStack --> AlbumList[AlbumScreen]
|
||||
AlbumStack --> AlbumDetail[AlbumDetailScreen]
|
||||
AlbumStack --> AlbumGallery[AlbumGalleryScreen]
|
||||
```
|
||||
|
||||
### 주요 기능
|
||||
|
||||
#### 탭 전환 시 앨범 스택 리셋
|
||||
|
||||
```tsx
|
||||
// AppNavigator.tsx
|
||||
<Tab.Screen
|
||||
name="AlbumTab"
|
||||
component={AlbumStackNavigator}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
// 앨범 탭 클릭 시 스택을 루트(목록)으로 리셋
|
||||
navigation.navigate("AlbumTab", { screen: "AlbumList" });
|
||||
},
|
||||
})}
|
||||
/>
|
||||
```
|
||||
|
||||
#### AlbumGalleryScreen (컨셉포토 라이트박스)
|
||||
|
||||
- **PagerView**: 스와이프로 이미지 탐색
|
||||
- **페이지 인디케이터**: `n / total` 형식
|
||||
- **다운로드 기능**: `expo-file-system` + `expo-media-library`
|
||||
- 웹 버전과 1:1 동일한 UI
|
||||
|
||||
#### MembersScreen (멤버 상세)
|
||||
|
||||
- **바텀시트 모달**: PanResponder 드래그로 닫기
|
||||
- **전 멤버 흑백 처리**: `Grayscale` 필터
|
||||
- **글래스모피즘**: `BlurView` (intensity 30, dimezisBlurView)
|
||||
|
||||
### 개발 명령어
|
||||
|
||||
```bash
|
||||
# 개발 서버 실행
|
||||
cd /docker/fromis_9/app
|
||||
npx expo start --lan
|
||||
|
||||
# Android APK 빌드
|
||||
npx expo run:android --variant release
|
||||
|
||||
# 로컬 네이티브 빌드 (android/ 폴더에서)
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
### 웹-앱 동기화 체크리스트
|
||||
|
||||
| 화면 | 웹 경로 | 앱 파일 |
|
||||
| ----------- | -------------------------------- | ------------------------ |
|
||||
| 홈 | `mobile/public/Home.jsx` | `HomeScreen.tsx` |
|
||||
| 멤버 | `mobile/public/Members.jsx` | `MembersScreen.tsx` |
|
||||
| 앨범 목록 | `mobile/public/Album.jsx` | `AlbumScreen.tsx` |
|
||||
| 앨범 상세 | `mobile/public/AlbumDetail.jsx` | `AlbumDetailScreen.tsx` |
|
||||
| 앨범 갤러리 | `mobile/public/AlbumGallery.jsx` | `AlbumGalleryScreen.tsx` |
|
||||
| 일정 | `mobile/public/Schedule.jsx` | `ScheduleScreen.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 16. 오늘 작업 요약 (2026-01-11 ~ 2026-01-12)
|
||||
|
||||
### 최근 커밋 히스토리
|
||||
|
||||
| 커밋 | 설명 |
|
||||
| --------- | ---------------------------------------------------------- |
|
||||
| `7e570d3` | 모바일 곡 상세: 뒤로가기 헤더 제거 |
|
||||
| `db6949d` | 모바일 곡 상세: YouTube 전체화면 시 자동 가로 회전 시도 |
|
||||
| `5f2c86b` | 모바일 곡 상세: 가사 더보기/접기 기능 추가, 하단 여백 조정 |
|
||||
| `e5d4036` | 모바일: 곡 상세 화면 구현 (TrackDetail 페이지) |
|
||||
| `67cd681` | PC 곡 상세: TITLE 배지를 노래 제목 옆으로 이동 |
|
||||
| `0232edc` | PC 곡 상세: 수록곡 섹션 디자인 개선 |
|
||||
| `4e52f79` | 백엔드: 트랙 상세 API 라우트 순서 수정 |
|
||||
| `b18183a` | 웹: PC 곡 상세 화면 구현 (TrackDetail 페이지) |
|
||||
| `dc65858` | 웹: AlbumDetail, AlbumGallery 페이지 useQuery로 리팩토링 |
|
||||
|
||||
### 주요 변경 사항
|
||||
|
||||
1. **곡 상세 화면 (PC/Mobile)**: 트랙 정보, 크레딧, 가사, 수록곡 목록, 뮤직비디오 임베드
|
||||
2. **트랙 상세 API**: `/api/albums/by-name/:albumName/track/:trackTitle` 엔드포인트 추가
|
||||
3. **useQuery 리팩토링**: AlbumDetail, AlbumGallery 페이지에 @tanstack/react-query 적용
|
||||
4. **모바일 UX 개선**: 가사 더보기/접기, YouTube 전체화면 시 자동 가로 회전
|
||||
|
||||
---
|
||||
|
||||
## 17. 곡 상세 화면 (TrackDetail)
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
| -------------------------------------------------- | ------ | --------------------- |
|
||||
| `/api/albums/by-name/:albumName/track/:trackTitle` | GET | 트랙 상세 + 앨범 정보 |
|
||||
|
||||
### 반환 데이터
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "LIKE YOU BETTER",
|
||||
"track_number": 1,
|
||||
"is_title_track": 1,
|
||||
"duration": "3:05",
|
||||
"lyrics": "...",
|
||||
"lyricist": "Tomy, HANIHAS(XYXX), ...",
|
||||
"composer": "HONEY NOISE, ...",
|
||||
"arranger": "...",
|
||||
"music_video_url": "https://youtube.com/...",
|
||||
"album": {
|
||||
"id": 7,
|
||||
"title": "From Our 20's",
|
||||
"album_type": "미니 6집",
|
||||
"cover_medium_url": "..."
|
||||
},
|
||||
"otherTracks": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 플랫폼 | 파일 경로 | 주요 기능 |
|
||||
| ------ | -------------------------------------------------- | ------------------------------- |
|
||||
| PC | `frontend/src/pages/pc/public/TrackDetail.jsx` | 크레딧, 가사, 수록곡, MV 임베드 |
|
||||
| Mobile | `frontend/src/pages/mobile/public/TrackDetail.jsx` | 가사 더보기/접기, 자동 회전 |
|
||||
|
||||
### 주요 기능
|
||||
|
||||
- **뮤직비디오 임베드**: is_title_track인 경우 YouTube 영상 표시
|
||||
- **크레딧 줄바꿈**: 쉼표 기준으로 각 항목 분리
|
||||
- **가사 더보기 (모바일)**: 기본 일부만 표시, 버튼으로 전체 펼침
|
||||
- **수록곡 목록 (PC)**: 현재 곡 강조, 재생 시간 표시
|
||||
- **자동 가로 회전 (모바일)**: YouTube 전체화면 시 `screen.orientation.lock('landscape')`
|
||||
140
docs/api.md
140
docs/api.md
|
|
@ -1,140 +0,0 @@
|
|||
# API 명세
|
||||
|
||||
Base URL: `/api`
|
||||
|
||||
## 인증
|
||||
|
||||
### POST /auth/login
|
||||
로그인 (JWT 토큰 발급)
|
||||
|
||||
### GET /auth/me
|
||||
현재 사용자 정보 (인증 필요)
|
||||
|
||||
---
|
||||
|
||||
## 멤버
|
||||
|
||||
### GET /members
|
||||
멤버 목록 조회
|
||||
|
||||
### GET /members/:id
|
||||
멤버 상세 조회
|
||||
|
||||
---
|
||||
|
||||
## 앨범
|
||||
|
||||
### GET /albums
|
||||
앨범 목록 조회
|
||||
|
||||
### GET /albums/:id
|
||||
앨범 상세 조회
|
||||
|
||||
---
|
||||
|
||||
## 일정
|
||||
|
||||
### GET /schedules
|
||||
일정 조회
|
||||
|
||||
**Query Parameters:**
|
||||
- `year`, `month` - 월별 조회 (필수, search 없을 때)
|
||||
- `search` - 검색어 (Meilisearch 사용)
|
||||
- `offset`, `limit` - 페이징
|
||||
|
||||
**월별 조회 응답:**
|
||||
```json
|
||||
{
|
||||
"2026-01-18": {
|
||||
"categories": [
|
||||
{ "id": 2, "name": "유튜브", "color": "#ff0033", "count": 3 }
|
||||
],
|
||||
"schedules": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "...",
|
||||
"time": "19:00:00",
|
||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||
"source": {
|
||||
"name": "fromis_9",
|
||||
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**source 객체 (카테고리별):**
|
||||
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
|
||||
- X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }`
|
||||
- 기타 카테고리: source 없음
|
||||
|
||||
**검색 응답:**
|
||||
```json
|
||||
{
|
||||
"schedules": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "...",
|
||||
"datetime": "2026-01-18T19:00:00",
|
||||
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
|
||||
"source": { "name": "fromis_9", "url": "https://..." },
|
||||
"members": ["송하영"],
|
||||
"_rankingScore": 0.95
|
||||
}
|
||||
],
|
||||
"total": 100,
|
||||
"offset": 0,
|
||||
"limit": 20,
|
||||
"hasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
### GET /schedules/:id
|
||||
일정 상세 조회
|
||||
|
||||
### POST /schedules/sync-search
|
||||
Meilisearch 전체 동기화 (인증 필요)
|
||||
|
||||
---
|
||||
|
||||
## 추천 검색어
|
||||
|
||||
### GET /schedules/suggestions
|
||||
추천 검색어 조회
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` - 검색어 (2자 이상)
|
||||
- `limit` - 결과 개수 (기본 10)
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"suggestions": ["송하영", "송하영 직캠", "하영"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 봇 상태
|
||||
|
||||
### GET /bots
|
||||
봇 상태 조회
|
||||
|
||||
---
|
||||
|
||||
## 헬스 체크
|
||||
|
||||
### GET /health
|
||||
서버 상태 확인
|
||||
|
||||
---
|
||||
|
||||
## API 문서
|
||||
|
||||
### GET /docs
|
||||
Scalar API Reference UI
|
||||
|
||||
### GET /docs/json
|
||||
OpenAPI JSON 스펙
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# 프로젝트 구조
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
fromis_9/
|
||||
├── backend/ # Fastify 백엔드 (현재 사용)
|
||||
│ ├── src/
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── index.js # 환경변수 통합 관리
|
||||
│ │ │ └── bots.js # 봇 설정 (YouTube, X)
|
||||
│ │ ├── plugins/ # Fastify 플러그인
|
||||
│ │ │ ├── db.js # MariaDB 연결
|
||||
│ │ │ ├── redis.js # Redis 연결
|
||||
│ │ │ ├── auth.js # JWT 인증
|
||||
│ │ │ ├── meilisearch.js # 검색 엔진
|
||||
│ │ │ └── scheduler.js # 봇 스케줄러
|
||||
│ │ ├── routes/ # API 라우트
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ ├── members/
|
||||
│ │ │ ├── albums/
|
||||
│ │ │ ├── schedules/
|
||||
│ │ │ │ ├── index.js # 일정 조회/검색
|
||||
│ │ │ │ └── suggestions.js
|
||||
│ │ │ └── index.js # 라우트 등록
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ │ ├── youtube/ # YouTube 봇
|
||||
│ │ │ ├── x/ # X(Twitter) 봇
|
||||
│ │ │ ├── meilisearch/ # 검색 서비스
|
||||
│ │ │ └── suggestions/ # 추천 검색어
|
||||
│ │ ├── app.js # Fastify 앱 설정
|
||||
│ │ └── server.js # 진입점
|
||||
│ └── package.json
|
||||
│
|
||||
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
||||
│
|
||||
├── frontend/ # React 프론트엔드
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 클라이언트
|
||||
│ │ │ ├── index.js # fetchApi 유틸
|
||||
│ │ │ ├── public/ # 공개 API
|
||||
│ │ │ └── admin/ # 어드민 API
|
||||
│ │ ├── components/ # 공통 컴포넌트
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── pc/ # PC 페이지
|
||||
│ │ │ └── mobile/ # 모바일 페이지
|
||||
│ │ ├── stores/ # Zustand 스토어
|
||||
│ │ └── App.jsx
|
||||
│ ├── vite.config.js
|
||||
│ └── package.json
|
||||
│
|
||||
├── Dockerfile # 개발/배포 통합 (주석 전환)
|
||||
├── docker-compose.yml
|
||||
└── .env
|
||||
```
|
||||
|
||||
## 서비스 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Caddy │
|
||||
│ (리버스 프록시) │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ fromis9-frontend (Docker) │
|
||||
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ Vite (:80) │───▶│ Fastify (:3000) │ │
|
||||
│ │ 프론트엔드 │ │ 백엔드 API │ │
|
||||
│ └─────────────────┘ └──────────┬──────────────────┘ │
|
||||
└─────────────────────────────────────┼───────────────────┘
|
||||
│
|
||||
┌────────────────────────────┼────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ MariaDB │ │ Meilisearch │ │ Redis │
|
||||
│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### 테이블 목록 (25개)
|
||||
|
||||
#### 사용자/인증
|
||||
- `admin_users` - 관리자 계정
|
||||
|
||||
#### 멤버
|
||||
- `members` - 멤버 정보 (이름, 생년월일, 인스타그램 등)
|
||||
- `member_nicknames` - 멤버 별명 (검색용)
|
||||
|
||||
#### 앨범
|
||||
- `albums` - 앨범 정보 (제목, 발매일, 커버 이미지 등)
|
||||
- `album_tracks` - 앨범 트랙 (곡명, 작사/작곡, 가사 등)
|
||||
- `album_photos` - 앨범 컨셉 포토
|
||||
- `album_photo_members` - 컨셉 포토-멤버 연결
|
||||
- `album_teasers` - 앨범 티저 이미지/영상
|
||||
|
||||
#### 일정
|
||||
- `schedules` - 일정 (제목, 날짜, 시간 등)
|
||||
- `schedule_categories` - 일정 카테고리 (유튜브, X, 콘서트 등)
|
||||
- `schedule_members` - 일정-멤버 연결
|
||||
- `schedule_images` - 일정 첨부 이미지
|
||||
- `schedule_youtube` - YouTube 영상 연결 정보
|
||||
- `schedule_x` - X(Twitter) 게시물 연결 정보
|
||||
- `schedule_concert` - 콘서트 일정 추가 정보
|
||||
|
||||
#### 콘서트
|
||||
- `concert_venues` - 콘서트 장소 정보
|
||||
- `concert_series` - 콘서트 시리즈 (투어 등)
|
||||
- `concert_series_md` - 콘서트 MD 상품
|
||||
- `concert_setlists` - 콘서트 셋리스트
|
||||
- `concert_setlist_members` - 셋리스트-멤버 연결
|
||||
|
||||
#### X(Twitter) 프로필
|
||||
- `x_profiles` - X 프로필 캐시 (프로필 이미지, 이름 등)
|
||||
|
||||
#### 이미지
|
||||
- `images` - 이미지 메타데이터 (3개 해상도 URL)
|
||||
|
||||
#### 추천 검색어
|
||||
- `suggestion_queries` - 검색 쿼리 로그
|
||||
- `suggestion_word_pairs` - 단어 bi-gram 빈도
|
||||
- `suggestion_chosung` - 초성 검색 매핑
|
||||
|
||||
### 검색 인덱스 (Meilisearch)
|
||||
- `schedules` - 일정 검색용 인덱스
|
||||
- 검색 필드: title, member_names, description, source_name, category_name
|
||||
- 필터: category_id, date
|
||||
- 정렬: date, time
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
# 개발/배포 가이드
|
||||
|
||||
## 개발 모드
|
||||
|
||||
### 실행
|
||||
```bash
|
||||
cd /docker/fromis_9
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 구성
|
||||
- **Vite** (포트 80): 프론트엔드 개발 서버, HMR 지원
|
||||
- **Fastify** (포트 3000): 백엔드 API, --watch 모드
|
||||
- Vite가 `/api`, `/docs` 요청을 localhost:3000으로 프록시
|
||||
|
||||
### 로그 확인
|
||||
```bash
|
||||
docker compose logs -f fromis9-frontend
|
||||
```
|
||||
|
||||
### 코드 수정
|
||||
- `frontend/`, `backend/` 폴더가 볼륨 마운트됨
|
||||
- 코드 수정 시 자동 반영 (HMR, watch)
|
||||
|
||||
---
|
||||
|
||||
## 배포 모드 전환
|
||||
|
||||
### 1. Dockerfile 수정
|
||||
```dockerfile
|
||||
# 개발 모드 주석처리
|
||||
# FROM node:20-alpine
|
||||
# WORKDIR /app
|
||||
# ...
|
||||
|
||||
# 배포 모드 주석해제
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
...
|
||||
```
|
||||
|
||||
### 2. docker-compose.yml 수정
|
||||
```yaml
|
||||
# volumes 주석처리
|
||||
# volumes:
|
||||
# - ./backend:/app/backend
|
||||
# - ./frontend:/app/frontend
|
||||
# - backend_modules:/app/backend/node_modules
|
||||
# - frontend_modules:/app/frontend/node_modules
|
||||
```
|
||||
|
||||
### 3. 빌드 및 실행
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 (.env)
|
||||
|
||||
```env
|
||||
# 서버
|
||||
PORT=80
|
||||
|
||||
# 데이터베이스
|
||||
DB_HOST=mariadb
|
||||
DB_PORT=3306
|
||||
DB_USER=...
|
||||
DB_PASSWORD=...
|
||||
DB_NAME=fromis9
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=fromis9-redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Meilisearch
|
||||
MEILI_HOST=http://fromis9-meilisearch:7700
|
||||
MEILI_MASTER_KEY=...
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=...
|
||||
|
||||
# AWS S3
|
||||
AWS_ACCESS_KEY_ID=...
|
||||
AWS_SECRET_ACCESS_KEY=...
|
||||
AWS_REGION=...
|
||||
S3_BUCKET=...
|
||||
|
||||
# YouTube API
|
||||
YOUTUBE_API_KEY=...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caddy 설정
|
||||
|
||||
위치: `/docker/caddy/Caddyfile`
|
||||
|
||||
### fromis_9 사이트 설정
|
||||
```caddyfile
|
||||
fromis9.caadiq.co.kr {
|
||||
import custom_errors
|
||||
reverse_proxy fromis9-frontend:80
|
||||
}
|
||||
```
|
||||
|
||||
### 설정 설명
|
||||
- `import custom_errors`: 공통 에러 페이지 (403, 404, 500, 502, 503)
|
||||
- `reverse_proxy fromis9-frontend:80`: Docker 네트워크로 프론트엔드 컨테이너에 연결
|
||||
- 업로드 크기 제한 없음 (Caddy 기본값)
|
||||
|
||||
### Caddy 재시작
|
||||
```bash
|
||||
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
### 네트워크 구조
|
||||
```
|
||||
인터넷 → Caddy (:443) → fromis9-frontend (:80) → Fastify (:3000)
|
||||
↓
|
||||
MariaDB, Redis, Meilisearch (내부 네트워크)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유용한 명령어
|
||||
|
||||
```bash
|
||||
# 컨테이너 재시작
|
||||
docker compose restart fromis9-frontend
|
||||
|
||||
# 볼륨 포함 완전 재시작
|
||||
docker compose down -v && docker compose up -d --build
|
||||
|
||||
# Meilisearch 동기화
|
||||
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Redis 확인
|
||||
docker exec fromis9-redis redis-cli KEYS "*"
|
||||
```
|
||||
163
docs/handover.md
Normal file
163
docs/handover.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# fromis_9 프로젝트 인수인계서
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
fromis_9 K-pop 아이돌 팬사이트 - 웹 프론트엔드, 백엔드 API, 모바일 앱으로 구성
|
||||
|
||||
---
|
||||
|
||||
## 1. 디렉토리 구조
|
||||
|
||||
```
|
||||
/docker/fromis_9/
|
||||
├── frontend/ # React 웹 프론트엔드 (Vite)
|
||||
├── backend/ # Express.js 백엔드 API
|
||||
├── app/ # React Native 모바일 앱 (Expo)
|
||||
└── .env # 환경변수 (DB 접속정보 등)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 웹 프론트엔드 (`/frontend`)
|
||||
|
||||
### 기술 스택
|
||||
|
||||
- React + Vite
|
||||
- TailwindCSS
|
||||
- framer-motion (애니메이션)
|
||||
|
||||
### 주요 경로
|
||||
|
||||
- `src/pages/` - 페이지 컴포넌트
|
||||
- `pc/public/` - PC 공개 페이지 (Home, Members, Album, AlbumDetail, TrackDetail 등)
|
||||
- `mobile/public/` - 모바일 전용 페이지
|
||||
- `pc/admin/` - 관리자 페이지
|
||||
- `src/api/` - API 호출 함수
|
||||
- `src/components/` - 재사용 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 (`/backend`)
|
||||
|
||||
### 기술 스택
|
||||
|
||||
- Express.js
|
||||
- MariaDB (mysql2)
|
||||
- RustFS (파일 스토리지)
|
||||
|
||||
### 주요 경로
|
||||
|
||||
- `routes/` - API 라우트
|
||||
- `public/` - 공개 API
|
||||
- `admin/` - 관리자 API
|
||||
- `lib/` - 유틸리티 (DB, 파일 업로드 등)
|
||||
|
||||
---
|
||||
|
||||
## 4. 모바일 앱 (`/app`)
|
||||
|
||||
### 기술 스택
|
||||
|
||||
- **Expo** (React Native)
|
||||
- **TypeScript**
|
||||
- React Navigation (탭 + 스택 네비게이션)
|
||||
- expo-blur, expo-linear-gradient (UI 효과)
|
||||
|
||||
### 주요 경로
|
||||
|
||||
```
|
||||
app/src/
|
||||
├── api/ # API 호출 함수
|
||||
│ ├── albums.ts # 앨범 API
|
||||
│ ├── members.ts # 멤버 API
|
||||
│ └── schedules.ts # 일정 API
|
||||
├── components/ # 공통 컴포넌트
|
||||
│ └── common/
|
||||
│ └── Header.tsx # 공통 헤더 (뒤로가기, 타이틀, rightElement)
|
||||
├── navigation/ # 네비게이션 설정
|
||||
│ └── AppNavigator.tsx
|
||||
├── screens/ # 화면 컴포넌트
|
||||
│ ├── HomeScreen.tsx
|
||||
│ ├── MembersScreen.tsx
|
||||
│ ├── AlbumScreen.tsx
|
||||
│ ├── AlbumDetailScreen.tsx
|
||||
│ ├── AlbumGalleryScreen.tsx # 컨셉포토 갤러리 (라이트박스)
|
||||
│ └── ScheduleScreen.tsx
|
||||
└── constants/ # 상수 (colors 등)
|
||||
```
|
||||
|
||||
### 네비게이션 구조
|
||||
|
||||
```
|
||||
TabNavigator (하단 탭)
|
||||
├── HomeTab → HomeScreen
|
||||
├── MembersTab → MembersScreen
|
||||
├── AlbumTab → AlbumStackNavigator
|
||||
│ ├── AlbumList → AlbumScreen
|
||||
│ ├── AlbumDetail → AlbumDetailScreen
|
||||
│ └── AlbumGallery → AlbumGalleryScreen
|
||||
└── ScheduleTab → ScheduleScreen
|
||||
```
|
||||
|
||||
### 주요 기능
|
||||
|
||||
- **탭 전환 시 앨범 스택 리셋**: 다른 탭 갔다가 앨범 탭 클릭 시 목록으로 돌아감
|
||||
- **AlbumGalleryScreen**: 웹과 1:1 동일한 컨셉포토 갤러리 (PagerView 라이트박스, 다운로드)
|
||||
- **MembersScreen**: 바텀시트 모달, 전 멤버 흑백 처리
|
||||
|
||||
### 개발 서버 실행
|
||||
|
||||
```bash
|
||||
cd /docker/fromis_9/app
|
||||
npx expo start --lan
|
||||
```
|
||||
|
||||
### APK 빌드
|
||||
|
||||
```bash
|
||||
npx expo run:android --variant release
|
||||
# 또는 로컬 빌드
|
||||
./gradlew assembleDebug # android/ 폴더에서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 분석 절차
|
||||
|
||||
### 5.1 코드 전수 조사
|
||||
|
||||
```bash
|
||||
# 프로젝트 구조 확인
|
||||
find /docker/fromis_9 -type f -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" | head -50
|
||||
```
|
||||
|
||||
### 5.2 DB 구조 파악
|
||||
|
||||
```bash
|
||||
# .env에서 DB 정보 확인
|
||||
cat /docker/fromis_9/.env
|
||||
|
||||
# MariaDB 접속 (컨테이너명: mariadb)
|
||||
docker exec -it mariadb mysql -u [USER] -p[PASSWORD] fromis9
|
||||
|
||||
# 테이블 목록
|
||||
SHOW TABLES;
|
||||
|
||||
# 테이블 스키마
|
||||
DESCRIBE [table_name];
|
||||
```
|
||||
|
||||
### 5.3 Caddy 설정 확인
|
||||
|
||||
```bash
|
||||
cat /docker/caddy/Caddyfile | grep -A 20 "fromis9"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 주의사항
|
||||
|
||||
- **앱 HMR**: Vite처럼 자동 반영, 빌드 불필요
|
||||
- **앱 테스트**: 흔들어서 → Reload로 확인
|
||||
- **DB 접속**: `.env` 파일의 실제 자격증명 사용
|
||||
- **웹/앱 1:1 동기화**: 기능 추가 시 웹과 앱 모두 구현 필요
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
# Express → Fastify 마이그레이션
|
||||
|
||||
## 개요
|
||||
|
||||
`backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 진행 중
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
### 서버 기반
|
||||
- [x] Fastify 앱 구조 (`src/app.js`, `src/server.js`)
|
||||
- [x] 플러그인 시스템 (`src/plugins/`)
|
||||
- db.js (MariaDB)
|
||||
- redis.js
|
||||
- auth.js (JWT)
|
||||
- meilisearch.js
|
||||
- scheduler.js (봇 스케줄러)
|
||||
|
||||
### API 라우트 (`src/routes/`)
|
||||
- [x] 인증 (`/api/auth`)
|
||||
- POST /login - 로그인
|
||||
- GET /me - 현재 사용자 정보
|
||||
- [x] 멤버 (`/api/members`)
|
||||
- GET / - 목록 조회
|
||||
- GET /:name - 상세 조회
|
||||
- PUT /:name - 수정 (이미지 업로드 포함)
|
||||
- [x] 앨범 (`/api/albums`)
|
||||
- GET / - 목록 조회
|
||||
- GET /:id - ID로 조회
|
||||
- GET /by-name/:name - 이름으로 조회
|
||||
- GET /by-name/:albumName/track/:trackTitle - 트랙 조회
|
||||
- POST / - 생성
|
||||
- PUT /:id - 수정
|
||||
- DELETE /:id - 삭제
|
||||
- 사진 관리 (`/api/albums/:id/photos`)
|
||||
- GET / - 목록
|
||||
- POST / - 업로드
|
||||
- PUT /:photoId - 수정
|
||||
- DELETE /:photoId - 삭제
|
||||
- 티저 관리 (`/api/albums/:id/teasers`)
|
||||
- GET / - 목록
|
||||
- POST / - 업로드
|
||||
- DELETE /:teaserId - 삭제
|
||||
- [x] 일정 (`/api/schedules`) - 조회만
|
||||
- GET / - 월별 조회 (생일 포함)
|
||||
- GET /?search= - Meilisearch 검색
|
||||
- GET /:id - 상세 조회
|
||||
- POST /sync-search - Meilisearch 동기화
|
||||
- [x] 추천 검색어 (`/api/schedules/suggestions`)
|
||||
- GET / - 추천 검색어 조회
|
||||
- kiwi-nlp 형태소 분석
|
||||
- bi-gram 자동완성
|
||||
- 초성 검색
|
||||
- [x] 통계 (`/api/stats`)
|
||||
- GET / - 대시보드 통계
|
||||
|
||||
### 서비스 (`src/services/`)
|
||||
- [x] YouTube 봇 (`services/youtube/`)
|
||||
- 영상 자동 수집
|
||||
- 채널별 필터링 (제목 필터, 멤버 추출)
|
||||
- [x] X(Twitter) 봇 (`services/x/`)
|
||||
- Nitter 스크래핑
|
||||
- 이미지 URL 추출
|
||||
- [x] Meilisearch 검색 (`services/meilisearch/`)
|
||||
- 일정 검색
|
||||
- 전체 동기화
|
||||
- [x] 추천 검색어 (`services/suggestions/`)
|
||||
- 형태소 분석
|
||||
- bi-gram 빈도
|
||||
- [x] 이미지 업로드 (`services/image.js`)
|
||||
- 앨범 커버
|
||||
- 멤버 이미지
|
||||
- 앨범 사진/티저
|
||||
|
||||
## 남은 작업
|
||||
|
||||
### 관리자 API (admin.js에서 마이그레이션 필요)
|
||||
- [ ] 일정 CRUD
|
||||
- POST /api/schedules - 생성
|
||||
- PUT /api/schedules/:id - 수정
|
||||
- DELETE /api/schedules/:id - 삭제
|
||||
- [ ] 일정 카테고리 CRUD
|
||||
- GET /api/schedule-categories - 목록
|
||||
- POST /api/schedule-categories - 생성
|
||||
- PUT /api/schedule-categories/:id - 수정
|
||||
- DELETE /api/schedule-categories/:id - 삭제
|
||||
- PUT /api/schedule-categories-order - 순서 변경
|
||||
- [ ] 봇 관리 API
|
||||
- GET /api/bots - 봇 목록
|
||||
- POST /api/bots/:id/start - 봇 시작
|
||||
- POST /api/bots/:id/stop - 봇 정지
|
||||
- POST /api/bots/:id/sync-all - 전체 동기화
|
||||
- [ ] 카카오 장소 검색 프록시
|
||||
- GET /api/kakao/places - 장소 검색
|
||||
- [ ] YouTube 할당량 관리
|
||||
- POST /api/quota-alert - Webhook 수신
|
||||
- GET /api/quota-warning - 경고 상태 조회
|
||||
- DELETE /api/quota-warning - 경고 해제
|
||||
|
||||
### 기타 기능
|
||||
- [ ] X 프로필 조회 (`/api/schedules/x-profile/:username`)
|
||||
- [ ] 어드민 사전 관리 (형태소 분석용 사전)
|
||||
|
||||
## 파일 비교표
|
||||
|
||||
| Express (backend-backup) | Fastify (backend) | 상태 |
|
||||
|--------------------------|-------------------|------|
|
||||
| routes/admin.js (로그인) | routes/auth.js | 완료 |
|
||||
| routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 |
|
||||
| routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 |
|
||||
| routes/admin.js (멤버 수정) | routes/members/index.js | 완료 |
|
||||
| routes/admin.js (일정 CRUD) | - | 미완료 |
|
||||
| routes/admin.js (카테고리) | - | 미완료 |
|
||||
| routes/admin.js (봇 관리) | - | 미완료 |
|
||||
| routes/admin.js (카카오) | - | 미완료 |
|
||||
| routes/admin.js (할당량) | - | 미완료 |
|
||||
| routes/albums.js | routes/albums/index.js | 완료 |
|
||||
| routes/members.js | routes/members/index.js | 완료 |
|
||||
| routes/schedules.js | routes/schedules/index.js | 부분 완료 |
|
||||
| routes/stats.js | routes/stats/index.js | 완료 |
|
||||
| services/youtube-bot.js | services/youtube/ | 완료 |
|
||||
| services/youtube-scheduler.js | plugins/scheduler.js | 완료 |
|
||||
| services/x-bot.js | services/x/ | 완료 |
|
||||
| services/meilisearch.js | services/meilisearch/ | 완료 |
|
||||
| services/meilisearch-bot.js | services/meilisearch/ | 완료 |
|
||||
| services/suggestions.js | services/suggestions/ | 완료 |
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- 기존 Express 코드는 `backend-backup/` 폴더에 보존
|
||||
- 마이그레이션 시 기존 코드 참조하여 동일 기능 구현
|
||||
- DB 스키마 변경 사항:
|
||||
- `tracks` → `album_tracks` (이름 변경)
|
||||
- `venues` → `concert_venues` (이름 변경)
|
||||
53
download_photos.sh
Executable file
53
download_photos.sh
Executable file
|
|
@ -0,0 +1,53 @@
|
|||
#!/bin/bash
|
||||
|
||||
# fromis_9 Photos 테이블에서 이미지 다운로드 스크립트
|
||||
# 앨범별로 폴더 분류하여 저장
|
||||
|
||||
OUTPUT_DIR="/docker/fromis_9/downloaded_photos"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# MariaDB에서 데이터 가져오기
|
||||
docker exec mariadb mariadb -u admin -p'auddnek0403!' fromis_9 -N -e "SELECT photo_id, album_name, photo FROM Photos;" | while IFS=$'\t' read -r photo_id album_name photo_url; do
|
||||
# 앨범명에서 특수문자 제거하여 폴더명 생성
|
||||
folder_name=$(echo "$album_name" | sed 's/[^a-zA-Z0-9가-힣 ]/_/g' | sed 's/ */_/g')
|
||||
|
||||
# 폴더 생성
|
||||
mkdir -p "$OUTPUT_DIR/$folder_name"
|
||||
|
||||
# 파일명 생성 (photo_id 기반)
|
||||
filename="${photo_id}.jpg"
|
||||
filepath="$OUTPUT_DIR/$folder_name/$filename"
|
||||
|
||||
# 이미 다운로드된 파일은 건너뛰기
|
||||
if [ -f "$filepath" ]; then
|
||||
echo "Skip: $filepath (already exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 다운로드
|
||||
echo "Downloading: $album_name/$filename"
|
||||
curl -s -L -o "$filepath" "$photo_url"
|
||||
|
||||
# 다운로드 실패 시 삭제
|
||||
if [ ! -s "$filepath" ]; then
|
||||
rm -f "$filepath"
|
||||
echo "Failed: $filepath"
|
||||
fi
|
||||
|
||||
# Rate limiting (0.2초 대기)
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
echo "Download complete!"
|
||||
echo "Saved to: $OUTPUT_DIR"
|
||||
|
||||
# 결과 요약
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
for dir in "$OUTPUT_DIR"/*/; do
|
||||
if [ -d "$dir" ]; then
|
||||
count=$(ls -1 "$dir" 2>/dev/null | wc -l)
|
||||
dirname=$(basename "$dir")
|
||||
echo "$dirname: $count files"
|
||||
fi
|
||||
done
|
||||
|
|
@ -1 +1,2 @@
|
|||
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da
|
||||
VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb
|
||||
VITE_KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea
|
||||
|
|
|
|||
442
frontend/package-lock.json
generated
442
frontend/package-lock.json
generated
|
|
@ -10,7 +10,6 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^11.0.8",
|
||||
"lucide-react": "^0.344.0",
|
||||
|
|
@ -52,13 +51,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
|
|
@ -67,9 +66,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
|
||||
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
|
||||
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -77,21 +76,21 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-module-transforms": "^7.28.3",
|
||||
"@babel/helpers": "^7.28.4",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/traverse": "^7.28.5",
|
||||
"@babel/types": "^7.28.5",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
|
|
@ -108,14 +107,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
|
||||
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
|
||||
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/types": "^7.28.5",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
|
|
@ -125,13 +124,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
|
||||
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/compat-data": "^7.27.2",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
|
|
@ -152,29 +151,29 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
||||
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/traverse": "^7.28.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -184,9 +183,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
|
||||
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -224,27 +223,27 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
|
||||
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
||||
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
|
|
@ -286,33 +285,33 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
|
||||
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
|
||||
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.5",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -320,9 +319,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
||||
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -813,9 +812,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"version": "1.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
||||
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
|
|
@ -829,9 +828,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -843,9 +842,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -857,9 +856,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -871,9 +870,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -885,9 +884,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -899,9 +898,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -913,9 +912,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -927,9 +926,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -941,9 +940,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -955,9 +954,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -969,23 +968,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -997,23 +982,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1025,9 +996,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1039,9 +1010,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1053,9 +1024,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1067,9 +1038,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1081,9 +1052,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1094,24 +1065,10 @@
|
|||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1123,9 +1080,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1137,9 +1094,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -1151,9 +1108,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1165,9 +1122,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1179,9 +1136,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.19",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz",
|
||||
"integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==",
|
||||
"version": "5.90.16",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
|
||||
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -1189,12 +1146,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.19",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz",
|
||||
"integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==",
|
||||
"version": "5.90.16",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
|
||||
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.19"
|
||||
"@tanstack/query-core": "5.90.16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -1407,9 +1364,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
|
||||
"integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==",
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -1487,9 +1444,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001764",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
||||
"integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
|
||||
"version": "1.0.30001762",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
||||
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1507,16 +1464,6 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||
"license": "ISC",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
|
|
@ -2485,9 +2432,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-intersection-observer": {
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.2.tgz",
|
||||
"integrity": "sha512-lAMzxVWrBko6SLd1jx6l84fVrzJu91hpxHlvD2as2Wec9mDCjdYXwc5xNOFBchpeBir0Y7AGBW+C/AYMa7CSFg==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.0.tgz",
|
||||
"integrity": "sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
|
|
@ -2563,12 +2510,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
|
||||
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
"@remix-run/router": "1.23.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
|
|
@ -2578,13 +2525,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
|
||||
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
"@remix-run/router": "1.23.1",
|
||||
"react-router": "6.30.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
|
|
@ -2595,9 +2542,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.5.tgz",
|
||||
"integrity": "sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.3.tgz",
|
||||
"integrity": "sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
|
|
@ -2660,9 +2607,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2676,31 +2623,28 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
||||
"@rollup/rollup-android-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-x64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
||||
"@rollup/rollup-android-arm64": "4.54.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -3097,9 +3041,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
|
||||
"integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^11.0.8",
|
||||
"lucide-react": "^0.344.0",
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@ 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';
|
||||
|
||||
// 모바일 페이지
|
||||
import MobileHome from './pages/mobile/public/Home';
|
||||
|
|
@ -25,8 +22,6 @@ 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';
|
||||
|
||||
// 관리자 페이지
|
||||
import AdminLogin from './pages/pc/admin/AdminLogin';
|
||||
|
|
@ -40,7 +35,6 @@ import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
|||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||
|
||||
// 레이아웃
|
||||
import PCLayout from './components/pc/Layout';
|
||||
|
|
@ -76,7 +70,6 @@ function App() {
|
|||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||
|
||||
{/* 일반 페이지 (레이아웃 포함) */}
|
||||
<Route path="/*" element={
|
||||
|
|
@ -89,9 +82,6 @@ function App() {
|
|||
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
||||
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
|
||||
<Route path="/schedule" element={<PCSchedule />} />
|
||||
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
||||
<Route path="*" element={<PCNotFound />} />
|
||||
</Routes>
|
||||
</PCLayout>
|
||||
} />
|
||||
|
|
@ -107,8 +97,6 @@ function App() {
|
|||
<Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} />
|
||||
<Route path="/album/:name/track/:trackTitle" element={<MobileLayout pageTitle="앨범"><MobileTrackDetail /></MobileLayout>} />
|
||||
<Route path="/schedule" element={<MobileLayout useCustomLayout><MobileSchedule /></MobileLayout>} />
|
||||
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
||||
<Route path="*" element={<MobileNotFound />} />
|
||||
</Routes>
|
||||
</MobileView>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -5,38 +5,38 @@ import { fetchAdminApi, fetchAdminFormData } from "../index";
|
|||
|
||||
// 앨범 목록 조회
|
||||
export async function getAlbums() {
|
||||
return fetchAdminApi("/api/albums");
|
||||
return fetchAdminApi("/api/admin/albums");
|
||||
}
|
||||
|
||||
// 앨범 상세 조회
|
||||
export async function getAlbum(id) {
|
||||
return fetchAdminApi(`/api/albums/${id}`);
|
||||
return fetchAdminApi(`/api/admin/albums/${id}`);
|
||||
}
|
||||
|
||||
// 앨범 생성
|
||||
export async function createAlbum(formData) {
|
||||
return fetchAdminFormData("/api/albums", formData, "POST");
|
||||
return fetchAdminFormData("/api/admin/albums", formData, "POST");
|
||||
}
|
||||
|
||||
// 앨범 수정
|
||||
export async function updateAlbum(id, formData) {
|
||||
return fetchAdminFormData(`/api/albums/${id}`, formData, "PUT");
|
||||
return fetchAdminFormData(`/api/admin/albums/${id}`, formData, "PUT");
|
||||
}
|
||||
|
||||
// 앨범 삭제
|
||||
export async function deleteAlbum(id) {
|
||||
return fetchAdminApi(`/api/albums/${id}`, { method: "DELETE" });
|
||||
return fetchAdminApi(`/api/admin/albums/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// 앨범 사진 목록 조회
|
||||
export async function getAlbumPhotos(albumId) {
|
||||
return fetchAdminApi(`/api/albums/${albumId}/photos`);
|
||||
return fetchAdminApi(`/api/admin/albums/${albumId}/photos`);
|
||||
}
|
||||
|
||||
// 앨범 사진 업로드
|
||||
export async function uploadAlbumPhotos(albumId, formData) {
|
||||
return fetchAdminFormData(
|
||||
`/api/albums/${albumId}/photos`,
|
||||
`/api/admin/albums/${albumId}/photos`,
|
||||
formData,
|
||||
"POST"
|
||||
);
|
||||
|
|
@ -44,19 +44,19 @@ export async function uploadAlbumPhotos(albumId, formData) {
|
|||
|
||||
// 앨범 사진 삭제
|
||||
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||
return fetchAdminApi(`/api/albums/${albumId}/photos/${photoId}`, {
|
||||
return fetchAdminApi(`/api/admin/albums/${albumId}/photos/${photoId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// 앨범 티저 목록 조회
|
||||
export async function getAlbumTeasers(albumId) {
|
||||
return fetchAdminApi(`/api/albums/${albumId}/teasers`);
|
||||
return fetchAdminApi(`/api/admin/albums/${albumId}/teasers`);
|
||||
}
|
||||
|
||||
// 앨범 티저 삭제
|
||||
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||
return fetchAdminApi(`/api/albums/${albumId}/teasers/${teaserId}`, {
|
||||
return fetchAdminApi(`/api/admin/albums/${albumId}/teasers/${teaserId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import { fetchAdminApi } from "../index";
|
|||
|
||||
// 토큰 검증
|
||||
export async function verifyToken() {
|
||||
return fetchAdminApi("/api/auth/verify");
|
||||
return fetchAdminApi("/api/admin/verify");
|
||||
}
|
||||
|
||||
// 로그인
|
||||
export async function login(username, password) {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
const response = await fetch("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { fetchAdminApi } from "../index";
|
|||
|
||||
// 카테고리 목록 조회
|
||||
export async function getCategories() {
|
||||
return fetchAdminApi("/api/schedules/categories");
|
||||
return fetchAdminApi("/api/admin/schedule-categories");
|
||||
}
|
||||
|
||||
// 카테고리 생성
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ import { fetchAdminApi, fetchAdminFormData } from "../index";
|
|||
|
||||
// 멤버 목록 조회
|
||||
export async function getMembers() {
|
||||
return fetchAdminApi("/api/members");
|
||||
return fetchAdminApi("/api/admin/members");
|
||||
}
|
||||
|
||||
// 멤버 상세 조회
|
||||
export async function getMember(id) {
|
||||
return fetchAdminApi(`/api/members/${id}`);
|
||||
return fetchAdminApi(`/api/admin/members/${id}`);
|
||||
}
|
||||
|
||||
// 멤버 수정
|
||||
export async function updateMember(id, formData) {
|
||||
return fetchAdminFormData(`/api/members/${id}`, formData, "PUT");
|
||||
return fetchAdminFormData(`/api/admin/members/${id}`, formData, "PUT");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,23 +5,7 @@ import { fetchAdminApi, fetchAdminFormData } from "../index";
|
|||
|
||||
// 일정 목록 조회 (월별)
|
||||
export async function getSchedules(year, month) {
|
||||
const data = await fetchAdminApi(`/api/schedules?year=${year}&month=${month}`);
|
||||
|
||||
// 날짜별 그룹화된 응답을 플랫 배열로 변환
|
||||
const schedules = [];
|
||||
for (const [date, dayData] of Object.entries(data)) {
|
||||
for (const schedule of dayData.schedules) {
|
||||
const category = schedule.category || {};
|
||||
schedules.push({
|
||||
...schedule,
|
||||
date,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
return schedules;
|
||||
return fetchAdminApi(`/api/admin/schedules?year=${year}&month=${month}`);
|
||||
}
|
||||
|
||||
// 일정 검색 (Meilisearch)
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* 어드민 통계 API
|
||||
*/
|
||||
import { fetchAdminApi } from "../index";
|
||||
|
||||
// 대시보드 통계 조회
|
||||
export async function getStats() {
|
||||
return fetchAdminApi("/api/stats");
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* 어드민 추천 검색어 API
|
||||
*/
|
||||
import { fetchAdminApi } from "../index";
|
||||
|
||||
// 사전 내용 조회
|
||||
export async function getDict() {
|
||||
return fetchAdminApi("/api/schedules/suggestions/dict");
|
||||
}
|
||||
|
||||
// 사전 저장
|
||||
export async function saveDict(content) {
|
||||
return fetchAdminApi("/api/schedules/suggestions/dict", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
|
@ -6,23 +6,7 @@ import { getTodayKST } from "../../utils/date";
|
|||
|
||||
// 일정 목록 조회 (월별)
|
||||
export async function getSchedules(year, month) {
|
||||
const data = await fetchApi(`/api/schedules?year=${year}&month=${month}`);
|
||||
|
||||
// 날짜별 그룹화된 응답을 플랫 배열로 변환
|
||||
const schedules = [];
|
||||
for (const [date, dayData] of Object.entries(data)) {
|
||||
for (const schedule of dayData.schedules) {
|
||||
const category = schedule.category || {};
|
||||
schedules.push({
|
||||
...schedule,
|
||||
date,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
return schedules;
|
||||
return fetchApi(`/api/schedules?year=${year}&month=${month}`);
|
||||
}
|
||||
|
||||
// 다가오는 일정 조회 (오늘 이후)
|
||||
|
|
@ -45,7 +29,7 @@ export async function getSchedule(id) {
|
|||
return fetchApi(`/api/schedules/${id}`);
|
||||
}
|
||||
|
||||
// X 프로필 정보 조회
|
||||
export async function getXProfile(username) {
|
||||
return fetchApi(`/api/schedules/x-profile/${encodeURIComponent(username)}`);
|
||||
// 카테고리 목록 조회
|
||||
export async function getCategories() {
|
||||
return fetchApi("/api/schedule-categories");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,8 @@ function Layout({ children }) {
|
|||
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>
|
||||
<main className={`flex-1 min-h-0 ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
|
||||
{children}
|
||||
{!hideFooter && <Footer />}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchAdminApi } from '../api';
|
||||
|
||||
/**
|
||||
* 어드민 인증 상태 관리 훅
|
||||
* - 토큰 유효성 검증
|
||||
* - 미인증 시 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
function useAdminAuth() {
|
||||
const navigate = useNavigate();
|
||||
const token = localStorage.getItem('adminToken');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin', 'auth'],
|
||||
queryFn: () => fetchAdminApi('/api/auth/verify'),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5분간 캐시
|
||||
});
|
||||
|
||||
// 토큰 없거나 검증 실패 시 로그인 페이지로 이동
|
||||
useEffect(() => {
|
||||
if (!token || isError) {
|
||||
localStorage.removeItem('adminToken');
|
||||
localStorage.removeItem('adminUser');
|
||||
navigate('/admin');
|
||||
}
|
||||
}, [token, isError, navigate]);
|
||||
|
||||
return {
|
||||
user: data?.user || null,
|
||||
isLoading: !token ? false : isLoading,
|
||||
isAuthenticated: !!data?.valid,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAdminAuth;
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Home, ArrowLeft } from "lucide-react";
|
||||
|
||||
function MobileNotFound() {
|
||||
return (
|
||||
<div className="h-[100dvh] flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 px-6">
|
||||
<div className="text-center">
|
||||
{/* 404 숫자 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, type: "spring", stiffness: 100 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<h1 className="text-[120px] font-bold leading-none bg-gradient-to-br from-primary to-primary-dark bg-clip-text text-transparent select-none">
|
||||
404
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* 메시지 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">
|
||||
페이지를 찾을 수 없습니다
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
요청하신 페이지가 존재하지 않거나
|
||||
<br />
|
||||
이동되었을 수 있습니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 장식 요소 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="flex justify-center gap-2 mb-8"
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary"
|
||||
animate={{
|
||||
y: [0, -6, 0],
|
||||
opacity: [0.3, 1, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-gradient-to-r from-primary to-primary-dark text-white rounded-full font-medium shadow-lg shadow-primary/20 active:scale-95 transition-transform"
|
||||
>
|
||||
<Home size={18} />
|
||||
홈으로 가기
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 border-2 border-primary text-primary rounded-full font-medium active:bg-primary/5 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
이전 페이지
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNotFound;
|
||||
|
|
@ -1,70 +1,10 @@
|
|||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
|
||||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { getTodayKST } from '../../../utils/date';
|
||||
import { getSchedules, searchSchedules } from '../../../api/public/schedules';
|
||||
import useScheduleStore from '../../../stores/useScheduleStore';
|
||||
|
||||
// 폭죽 애니메이션 함수
|
||||
const fireBirthdayConfetti = () => {
|
||||
const duration = 3000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const colors = ['#ff69b4', '#ff1493', '#da70d6', '#ba55d3', '#9370db', '#8a2be2', '#ffd700', '#ff6347'];
|
||||
|
||||
const randomInRange = (min, max) => Math.random() * (max - min) + min;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
|
||||
// 왼쪽에서 발사
|
||||
confetti({
|
||||
particleCount: Math.floor(particleCount),
|
||||
startVelocity: 30,
|
||||
spread: 60,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
colors: colors,
|
||||
shapes: ['circle', 'square'],
|
||||
gravity: 1.2,
|
||||
scalar: randomInRange(0.8, 1.2),
|
||||
drift: randomInRange(-0.5, 0.5),
|
||||
});
|
||||
|
||||
// 오른쪽에서 발사
|
||||
confetti({
|
||||
particleCount: Math.floor(particleCount),
|
||||
startVelocity: 30,
|
||||
spread: 60,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
colors: colors,
|
||||
shapes: ['circle', 'square'],
|
||||
gravity: 1.2,
|
||||
scalar: randomInRange(0.8, 1.2),
|
||||
drift: randomInRange(-0.5, 0.5),
|
||||
});
|
||||
}, 250);
|
||||
|
||||
// 초기 대형 폭죽
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 100,
|
||||
origin: { x: 0.5, y: 0.6 },
|
||||
colors: colors,
|
||||
shapes: ['circle', 'square'],
|
||||
startVelocity: 45,
|
||||
});
|
||||
};
|
||||
import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules';
|
||||
|
||||
// HTML 엔티티 디코딩 함수
|
||||
const decodeHtmlEntities = (text) => {
|
||||
|
|
@ -74,65 +14,9 @@ const decodeHtmlEntities = (text) => {
|
|||
return textarea.value;
|
||||
};
|
||||
|
||||
// 모바일 생일 카드 컴포넌트
|
||||
function MobileBirthdayCard({ schedule, onClick, delay = 0 }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, type: "spring", stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-xl shadow-lg">
|
||||
{/* 배경 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-3 -right-3 w-16 h-16 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-4 -left-4 w-20 h-20 bg-white/10 rounded-full" />
|
||||
<div className="absolute bottom-3 left-8 text-sm animate-pulse">🎉</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center p-4 gap-3">
|
||||
{/* 멤버 사진 */}
|
||||
{schedule.member_image && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full border-2 border-white/50 shadow-md overflow-hidden bg-white">
|
||||
<img
|
||||
src={schedule.member_image}
|
||||
alt={schedule.member_names}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 text-white flex items-center gap-2 min-w-0">
|
||||
<span className="text-2xl flex-shrink-0">🎂</span>
|
||||
<h3 className="font-bold text-base tracking-wide truncate">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일 일정 페이지
|
||||
function MobileSchedule() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// zustand store에서 상태 가져오기
|
||||
const {
|
||||
selectedDate: storedSelectedDate,
|
||||
setSelectedDate: setStoredSelectedDate,
|
||||
} = useScheduleStore();
|
||||
|
||||
// 선택된 날짜 (store에 없으면 오늘 날짜)
|
||||
const selectedDate = storedSelectedDate || new Date();
|
||||
const setSelectedDate = (date) => setStoredSelectedDate(date);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState(''); // 입력값
|
||||
const [searchTerm, setSearchTerm] = useState(''); // 실제 검색어
|
||||
|
|
@ -142,15 +26,13 @@ function MobileSchedule() {
|
|||
const contentRef = useRef(null); // 스크롤 초기화용
|
||||
const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
|
||||
const searchInputRef = useRef(null); // 검색 입력 필드 (키패드 닫기용)
|
||||
|
||||
|
||||
// 검색 추천 관련 상태
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 필터링용 원본 쿼리
|
||||
const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
const [lastSearchTerm, setLastSearchTerm] = useState(''); // 마지막 검색어 (복원용)
|
||||
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false); // 추천 검색어 화면 표시 여부
|
||||
|
||||
// 검색 모드 진입 함수 (history 상태 추가)
|
||||
const enterSearchMode = () => {
|
||||
|
|
@ -164,37 +46,22 @@ function MobileSchedule() {
|
|||
setSearchInput('');
|
||||
setOriginalSearchQuery('');
|
||||
setSearchTerm('');
|
||||
setLastSearchTerm('');
|
||||
setShowSuggestions(false);
|
||||
setShowSuggestionsScreen(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
// 추천 검색어 화면 숨기고 검색 결과로 돌아가기
|
||||
const hideSuggestionsScreen = () => {
|
||||
setShowSuggestionsScreen(false);
|
||||
setSearchInput(lastSearchTerm); // 검색어 복원
|
||||
setOriginalSearchQuery(lastSearchTerm);
|
||||
};
|
||||
|
||||
// 뒤로가기 버튼 처리
|
||||
useEffect(() => {
|
||||
const handlePopState = (e) => {
|
||||
if (isSearchMode) {
|
||||
// 추천 검색어 화면이고 검색 결과가 있으면 → 검색 결과로 돌아가기
|
||||
if (showSuggestionsScreen && searchTerm) {
|
||||
hideSuggestionsScreen();
|
||||
window.history.pushState({ searchMode: true }, '');
|
||||
} else {
|
||||
// 그 외에는 검색 모드 종료
|
||||
exitSearchMode();
|
||||
}
|
||||
// 검색 모드에서 뒤로가기 시 검색 모드 종료
|
||||
exitSearchMode();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isSearchMode, showSuggestionsScreen, searchTerm, lastSearchTerm]);
|
||||
}, [isSearchMode]);
|
||||
|
||||
// 달력 월 변경 함수
|
||||
const changeCalendarMonth = (delta) => {
|
||||
|
|
@ -244,16 +111,15 @@ function MobileSchedule() {
|
|||
|
||||
// 검색어 변경 시 스크롤 위치 초기화
|
||||
useEffect(() => {
|
||||
if (searchTerm && !showSuggestionsScreen) {
|
||||
// 약간의 지연 후 스크롤 초기화 (렌더링 완료 후)
|
||||
requestAnimationFrame(() => {
|
||||
virtualizer.scrollToOffset(0);
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
if (searchTerm) {
|
||||
// virtualizer 스크롤 초기화
|
||||
virtualizer.scrollToOffset(0);
|
||||
// DOM 스크롤도 초기화 (fallback)
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}, [searchTerm, showSuggestionsScreen]);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
|
||||
|
|
@ -265,64 +131,18 @@ function MobileSchedule() {
|
|||
const viewYear = selectedDate.getFullYear();
|
||||
const viewMonth = selectedDate.getMonth() + 1;
|
||||
|
||||
// 카테고리 데이터 로드
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['scheduleCategories'],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
|
||||
// 월별 일정 데이터 로드
|
||||
const { data: schedules = [], isLoading: loading } = useQuery({
|
||||
queryKey: ['schedules', viewYear, viewMonth],
|
||||
queryFn: () => getSchedules(viewYear, viewMonth),
|
||||
});
|
||||
|
||||
// 카테고리는 일정 데이터에서 추출
|
||||
const categories = useMemo(() => {
|
||||
const categoryMap = new Map();
|
||||
schedules.forEach(s => {
|
||||
if (s.category_id && !categoryMap.has(s.category_id)) {
|
||||
categoryMap.set(s.category_id, {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(categoryMap.values());
|
||||
}, [schedules]);
|
||||
|
||||
// 달력 표시용 일정 데이터 (calendarViewDate 기준)
|
||||
const calendarYear = calendarViewDate.getFullYear();
|
||||
const calendarMonth = calendarViewDate.getMonth() + 1;
|
||||
const isSameMonth = viewYear === calendarYear && viewMonth === calendarMonth;
|
||||
|
||||
const { data: calendarSchedules = [] } = useQuery({
|
||||
queryKey: ['schedules', calendarYear, calendarMonth],
|
||||
queryFn: () => getSchedules(calendarYear, calendarMonth),
|
||||
enabled: !isSameMonth, // 같은 월이면 중복 조회 안함
|
||||
});
|
||||
|
||||
// 생일 폭죽 효과 (하루에 한 번만)
|
||||
useEffect(() => {
|
||||
if (loading || schedules.length === 0) return;
|
||||
|
||||
const today = getTodayKST();
|
||||
const confettiKey = `birthday-confetti-${today}`;
|
||||
|
||||
// 이미 오늘 폭죽을 봤으면 스킵
|
||||
if (localStorage.getItem(confettiKey)) return;
|
||||
|
||||
const hasBirthdayToday = schedules.some(s => {
|
||||
if (!s.is_birthday) return false;
|
||||
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
||||
return scheduleDate === today;
|
||||
});
|
||||
|
||||
if (hasBirthdayToday) {
|
||||
// 약간의 딜레이 후 폭죽 발사 (페이지 렌더링 완료 후)
|
||||
const timer = setTimeout(() => {
|
||||
fireBirthdayConfetti();
|
||||
localStorage.setItem(confettiKey, 'true');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [schedules, loading]);
|
||||
|
||||
// 월 변경
|
||||
const changeMonth = (delta) => {
|
||||
const newDate = new Date(selectedDate);
|
||||
|
|
@ -473,20 +293,19 @@ function MobileSchedule() {
|
|||
// 날짜 선택 컨테이너 ref
|
||||
const dateScrollRef = useRef(null);
|
||||
|
||||
// 선택된 날짜로 자동 스크롤
|
||||
// 선택된 날짜로 자동 스크롤 + 페이지 스크롤 초기화
|
||||
useEffect(() => {
|
||||
// 검색 모드가 아닐 때만 스크롤 조정
|
||||
if (!isSearchMode && dateScrollRef.current) {
|
||||
// 페이지 스크롤을 맨 위로 즉시 이동
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
if (dateScrollRef.current) {
|
||||
const selectedDay = selectedDate.getDate();
|
||||
const buttons = dateScrollRef.current.querySelectorAll('button');
|
||||
if (buttons[selectedDay - 1]) {
|
||||
// 약간의 지연 후 스크롤 (DOM 렌더링 완료 후)
|
||||
setTimeout(() => {
|
||||
buttons[selectedDay - 1].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}, 50);
|
||||
buttons[selectedDay - 1].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedDate, isSearchMode]);
|
||||
}, [selectedDate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -507,18 +326,14 @@ function MobileSchedule() {
|
|||
setSearchInput(e.target.value);
|
||||
setOriginalSearchQuery(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
setShowSuggestionsScreen(true);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setShowSuggestions(true);
|
||||
setShowSuggestionsScreen(true);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const newIndex = selectedSuggestionIndex < suggestions.length - 1
|
||||
? selectedSuggestionIndex + 1
|
||||
const newIndex = selectedSuggestionIndex < suggestions.length - 1
|
||||
? selectedSuggestionIndex + 1
|
||||
: 0;
|
||||
setSelectedSuggestionIndex(newIndex);
|
||||
if (suggestions[newIndex]) {
|
||||
|
|
@ -526,8 +341,8 @@ function MobileSchedule() {
|
|||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const newIndex = selectedSuggestionIndex > 0
|
||||
? selectedSuggestionIndex - 1
|
||||
const newIndex = selectedSuggestionIndex > 0
|
||||
? selectedSuggestionIndex - 1
|
||||
: suggestions.length - 1;
|
||||
setSelectedSuggestionIndex(newIndex);
|
||||
if (suggestions[newIndex]) {
|
||||
|
|
@ -535,14 +350,11 @@ function MobileSchedule() {
|
|||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const term = selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]
|
||||
? suggestions[selectedSuggestionIndex]
|
||||
: searchInput.trim();
|
||||
if (term) {
|
||||
setSearchInput(term);
|
||||
setSearchTerm(term);
|
||||
setLastSearchTerm(term); // 검색어 저장
|
||||
setShowSuggestionsScreen(false);
|
||||
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
|
||||
setSearchInput(suggestions[selectedSuggestionIndex]);
|
||||
setSearchTerm(suggestions[selectedSuggestionIndex]);
|
||||
} else if (searchInput.trim()) {
|
||||
setSearchTerm(searchInput);
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
|
|
@ -554,15 +366,14 @@ function MobileSchedule() {
|
|||
autoFocus={!searchTerm}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setOriginalSearchQuery('');
|
||||
setShowSuggestions(true);
|
||||
setShowSuggestionsScreen(true);
|
||||
setSearchTerm('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
// searchTerm과 lastSearchTerm은 유지하여 뒤로가기 시 복원 가능
|
||||
}}
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<X size={18} className="text-gray-400" />
|
||||
|
|
@ -745,9 +556,9 @@ function MobileSchedule() {
|
|||
className="fixed left-0 right-0 bg-white shadow-lg z-50 border-b overflow-hidden"
|
||||
style={{ top: '56px' }}
|
||||
>
|
||||
<CalendarPicker
|
||||
selectedDate={selectedDate}
|
||||
schedules={isSameMonth ? schedules : calendarSchedules}
|
||||
<CalendarPicker
|
||||
selectedDate={selectedDate}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
hideHeader={true}
|
||||
externalViewDate={calendarViewDate}
|
||||
|
|
@ -780,12 +591,12 @@ function MobileSchedule() {
|
|||
</AnimatePresence>
|
||||
|
||||
{/* 컨텐츠 영역 */}
|
||||
<div className="mobile-content" ref={isSearchMode && searchTerm && !showSuggestionsScreen ? scrollContainerRef : contentRef}>
|
||||
<div className={`px-4 pb-4 ${isSearchMode && showSuggestionsScreen ? 'pt-0' : 'pt-4'}`}>
|
||||
<div className="mobile-content" ref={isSearchMode && searchTerm ? scrollContainerRef : contentRef}>
|
||||
<div className={`px-4 pb-4 ${isSearchMode && !searchTerm ? 'pt-0' : 'pt-4'}`}>
|
||||
{isSearchMode ? (
|
||||
// 검색 모드
|
||||
showSuggestionsScreen ? (
|
||||
// 추천 검색어 화면 (유튜브 스타일)
|
||||
!searchTerm ? (
|
||||
// 검색어 입력 전 - 추천 검색어 리스트 표시 (유튜브 스타일)
|
||||
<div className="space-y-0">
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
|
|
@ -798,14 +609,12 @@ function MobileSchedule() {
|
|||
onClick={() => {
|
||||
setSearchInput(suggestion);
|
||||
setSearchTerm(suggestion);
|
||||
setLastSearchTerm(suggestion); // 검색어 저장
|
||||
setShowSuggestions(false);
|
||||
setShowSuggestionsScreen(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}}
|
||||
className={`w-full px-0 py-3.5 text-left flex items-center gap-4 border-b border-gray-100 active:bg-gray-50 ${
|
||||
index === selectedSuggestionIndex
|
||||
? 'bg-primary/5'
|
||||
index === selectedSuggestionIndex
|
||||
? 'bg-primary/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
|
|
@ -815,11 +624,6 @@ function MobileSchedule() {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
) : !searchTerm ? (
|
||||
// 검색어 없음 (첫 진입)
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
검색어를 입력하세요
|
||||
</div>
|
||||
) : searchLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
|
|
@ -855,11 +659,10 @@ function MobileSchedule() {
|
|||
}}
|
||||
>
|
||||
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
|
||||
<ScheduleCard
|
||||
<ScheduleCard
|
||||
schedule={schedule}
|
||||
categoryColor={getCategoryColor(schedule.category_id)}
|
||||
categories={categories}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -887,35 +690,15 @@ function MobileSchedule() {
|
|||
) : (
|
||||
// 선택된 날짜의 일정
|
||||
<div className="space-y-3">
|
||||
{selectedDateSchedules.map((schedule, index) => {
|
||||
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
||||
|
||||
if (isBirthday) {
|
||||
return (
|
||||
<MobileBirthdayCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
delay={index * 0.05}
|
||||
onClick={() => {
|
||||
const scheduleYear = new Date(schedule.date).getFullYear();
|
||||
const memberName = schedule.member_names;
|
||||
navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineScheduleCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
categoryColor={getCategoryColor(schedule.category_id)}
|
||||
categories={categories}
|
||||
delay={index * 0.05}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{selectedDateSchedules.map((schedule, index) => (
|
||||
<TimelineScheduleCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
categoryColor={getCategoryColor(schedule.category_id)}
|
||||
categories={categories}
|
||||
delay={index * 0.05}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -925,11 +708,11 @@ function MobileSchedule() {
|
|||
}
|
||||
|
||||
// 일정 카드 컴포넌트 (검색용) - 날짜 포함 모던 디자인
|
||||
function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
|
||||
function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) {
|
||||
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
|
||||
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
||||
const memberList = memberNames.split(',').filter(name => name.trim());
|
||||
|
||||
|
||||
// 날짜 파싱
|
||||
const parseDate = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
|
|
@ -943,7 +726,7 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
|
|||
const isSunday = date.getDay() === 0;
|
||||
return { year, month, day, weekday, isWeekend, isSunday };
|
||||
};
|
||||
|
||||
|
||||
const dateInfo = parseDate(schedule.date);
|
||||
|
||||
return (
|
||||
|
|
@ -951,11 +734,9 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay, type: "spring", stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* 카드 본체 */}
|
||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* 왼쪽 날짜 영역 */}
|
||||
{dateInfo && (
|
||||
|
|
@ -1001,10 +782,10 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
|
|||
</h3>
|
||||
|
||||
{/* 출처 */}
|
||||
{schedule.source?.name && (
|
||||
{schedule.source_name && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
||||
<Link2 size={11} />
|
||||
<span>{schedule.source?.name}</span>
|
||||
<span>{schedule.source_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1035,7 +816,7 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
|
|||
}
|
||||
|
||||
// 타임라인용 일정 카드 컴포넌트 - 모던 디자인
|
||||
function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
|
||||
function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }) {
|
||||
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
|
||||
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
||||
const memberList = memberNames.split(',').filter(name => name.trim());
|
||||
|
|
@ -1045,34 +826,32 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0,
|
|||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, type: "spring", stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* 카드 본체 */}
|
||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden">
|
||||
<div className="p-4">
|
||||
|
||||
{/* 시간 및 카테고리 뱃지 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{schedule.time && (
|
||||
<div
|
||||
{/* 시간 뱃지 */}
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{schedule.time.slice(0, 5)}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}15`,
|
||||
color: categoryColor
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}15`,
|
||||
color: categoryColor
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-bold text-[15px] text-gray-800 leading-snug">
|
||||
|
|
@ -1080,10 +859,10 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0,
|
|||
</h3>
|
||||
|
||||
{/* 출처 */}
|
||||
{schedule.source?.name && (
|
||||
{schedule.source_name && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
||||
<Link2 size={11} />
|
||||
<span>{schedule.source?.name}</span>
|
||||
<span>{schedule.source_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1383,56 +1162,45 @@ function CalendarPicker({
|
|||
{/* 년도 선택 */}
|
||||
<div className="text-center text-xs text-gray-400 mb-2">년도</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{yearRange.map(y => {
|
||||
const isCurrentYear = y === new Date().getFullYear();
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => {
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setFullYear(y);
|
||||
setViewDate(newDate);
|
||||
}}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
y === year
|
||||
? 'bg-primary text-white'
|
||||
: isCurrentYear
|
||||
? 'text-primary font-semibold hover:bg-gray-100'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{yearRange.map(y => (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => {
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setFullYear(y);
|
||||
setViewDate(newDate);
|
||||
}}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
y === year
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 월 선택 */}
|
||||
<div className="text-center text-xs text-gray-400 mb-2">월</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map(m => {
|
||||
const today = new Date();
|
||||
const isCurrentMonth = year === today.getFullYear() && m === today.getMonth() + 1;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
const newDate = new Date(year, m - 1, 1);
|
||||
setViewDate(newDate);
|
||||
setShowYearMonth(false);
|
||||
}}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
m === month + 1
|
||||
? 'bg-primary text-white'
|
||||
: isCurrentMonth
|
||||
? 'text-primary font-semibold hover:bg-gray-100'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{m}월
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
const newDate = new Date(year, m - 1, 1);
|
||||
setViewDate(newDate);
|
||||
setShowYearMonth(false);
|
||||
}}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
m === month + 1
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{m}월
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,917 +0,0 @@
|
|||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
|
||||
import { getSchedule, getXProfile } from '../../../api/public/schedules';
|
||||
import '../../../mobile.css';
|
||||
|
||||
// 카카오맵 SDK 키
|
||||
const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
|
||||
|
||||
// 카카오맵 컴포넌트
|
||||
function KakaoMap({ lat, lng, name }) {
|
||||
const mapRef = useRef(null);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [mapError, setMapError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!KAKAO_MAP_KEY) {
|
||||
setMapError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.kakao?.maps) {
|
||||
const script = document.createElement('script');
|
||||
script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_MAP_KEY}&autoload=false`;
|
||||
script.onload = () => {
|
||||
window.kakao.maps.load(() => setMapLoaded(true));
|
||||
};
|
||||
script.onerror = () => setMapError(true);
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
setMapLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapLoaded || !mapRef.current || mapError) return;
|
||||
|
||||
try {
|
||||
const position = new window.kakao.maps.LatLng(lat, lng);
|
||||
const map = new window.kakao.maps.Map(mapRef.current, {
|
||||
center: position,
|
||||
level: 3,
|
||||
});
|
||||
|
||||
const marker = new window.kakao.maps.Marker({
|
||||
position,
|
||||
map,
|
||||
});
|
||||
|
||||
if (name) {
|
||||
const infowindow = new window.kakao.maps.InfoWindow({
|
||||
content: `<div style="padding:6px 10px;font-size:12px;font-weight:500;">${name}</div>`,
|
||||
});
|
||||
infowindow.open(map, marker);
|
||||
}
|
||||
} catch (e) {
|
||||
setMapError(true);
|
||||
}
|
||||
}, [mapLoaded, lat, lng, name, mapError]);
|
||||
|
||||
if (mapError) {
|
||||
return (
|
||||
<a
|
||||
href={`https://map.kakao.com/link/map/${encodeURIComponent(name)},${lat},${lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-40 rounded-xl bg-gray-100 flex items-center justify-center active:bg-gray-200 transition-colors"
|
||||
>
|
||||
<div className="text-center">
|
||||
<Navigation size={24} className="mx-auto text-gray-400 mb-1" />
|
||||
<p className="text-xs text-gray-500">지도에서 보기</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full h-40 rounded-xl overflow-hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
||||
function useFullscreenOrientation(isShorts) {
|
||||
useEffect(() => {
|
||||
// 숏츠는 세로 유지
|
||||
if (isShorts) return;
|
||||
|
||||
const handleFullscreenChange = async () => {
|
||||
const isFullscreen = !!document.fullscreenElement;
|
||||
|
||||
if (isFullscreen) {
|
||||
// 전체화면 진입 시 가로 모드로 전환 시도
|
||||
try {
|
||||
if (screen.orientation && screen.orientation.lock) {
|
||||
await screen.orientation.lock('landscape');
|
||||
}
|
||||
} catch (e) {
|
||||
// 지원하지 않는 브라우저이거나 권한이 없는 경우 무시
|
||||
}
|
||||
} else {
|
||||
// 전체화면 종료 시 세로 모드로 복귀
|
||||
try {
|
||||
if (screen.orientation && screen.orientation.unlock) {
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (e) {
|
||||
// 무시
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [isShorts]);
|
||||
}
|
||||
|
||||
// 카테고리 ID 상수
|
||||
const CATEGORY_ID = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
ALBUM: 4,
|
||||
FANSIGN: 5,
|
||||
CONCERT: 6,
|
||||
TICKET: 7,
|
||||
};
|
||||
|
||||
// HTML 엔티티 디코딩 함수
|
||||
const decodeHtmlEntities = (text) => {
|
||||
if (!text) return '';
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
};
|
||||
|
||||
// 유튜브 비디오 ID 추출
|
||||
const extractYoutubeVideoId = (url) => {
|
||||
if (!url) return null;
|
||||
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
|
||||
if (shortMatch) return shortMatch[1];
|
||||
const watchMatch = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/);
|
||||
if (watchMatch) return watchMatch[1];
|
||||
const shortsMatch = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/);
|
||||
if (shortsMatch) return shortsMatch[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
// 날짜 포맷팅
|
||||
const formatFullDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}. (${dayNames[date.getDay()]})`;
|
||||
};
|
||||
|
||||
// 시간 포맷팅
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return null;
|
||||
return timeStr.slice(0, 5);
|
||||
};
|
||||
|
||||
// X URL에서 username 추출
|
||||
const extractXUsername = (url) => {
|
||||
if (!url) return null;
|
||||
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
|
||||
const formatXDateTime = (dateStr, timeStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
let result = `${year}년 ${month}월 ${day}일`;
|
||||
|
||||
if (timeStr) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
const period = hours < 12 ? '오전' : '오후';
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
result = `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${result}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 유튜브 섹션 컴포넌트
|
||||
function YoutubeSection({ schedule }) {
|
||||
const videoId = extractYoutubeVideoId(schedule.source?.url);
|
||||
const isShorts = schedule.source?.url?.includes('/shorts/');
|
||||
|
||||
// 전체화면 시 가로 회전 (숏츠 제외)
|
||||
useFullscreenOrientation(isShorts);
|
||||
const members = schedule.members || [];
|
||||
const isFullGroup = members.length === 5;
|
||||
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 영상 임베드 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div
|
||||
className={`relative bg-gray-900 rounded-xl overflow-hidden shadow-lg ${
|
||||
isShorts ? 'aspect-[9/16] max-w-[280px] mx-auto' : 'aspect-video'
|
||||
}`}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={schedule.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 영상 정보 카드 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
{/* 제목 */}
|
||||
<h1 className="font-bold text-gray-900 text-base leading-relaxed mb-3">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatFullDate(schedule.date)}</span>
|
||||
</div>
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
<span>{formatTime(schedule.time)}</span>
|
||||
</div>
|
||||
)}
|
||||
{schedule.source?.name && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link2 size={12} />
|
||||
<span>{schedule.source?.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 멤버 목록 */}
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{isFullGroup ? (
|
||||
<span className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<span
|
||||
key={member.id}
|
||||
className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
||||
>
|
||||
{member.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 유튜브에서 보기 버튼 */}
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
||||
</svg>
|
||||
YouTube에서 보기
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// X(트위터) 섹션 컴포넌트
|
||||
function XSection({ schedule }) {
|
||||
const username = extractXUsername(schedule.source?.url);
|
||||
|
||||
// 프로필 정보 조회
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['x-profile', username],
|
||||
queryFn: () => getXProfile(username),
|
||||
enabled: !!username,
|
||||
staleTime: 1000 * 60 * 60, // 1시간
|
||||
});
|
||||
|
||||
const displayName = profile?.displayName || schedule.source?.name || username || 'Unknown';
|
||||
const avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 프로필 이미지 */}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center">
|
||||
<span className="text-white font-bold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-gray-900 text-sm truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{username && (
|
||||
<span className="text-xs text-gray-500">@{username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-4">
|
||||
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
{decodeHtmlEntities(schedule.description || schedule.title)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
{schedule.image_url && (
|
||||
<div className="px-4 pb-3">
|
||||
<img
|
||||
src={schedule.image_url}
|
||||
alt=""
|
||||
className="w-full rounded-xl border border-gray-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜/시간 */}
|
||||
<div className="px-4 py-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{formatXDateTime(schedule.date, schedule.time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
X에서 보기
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// 콘서트 섹션 컴포넌트
|
||||
function ConcertSection({ schedule }) {
|
||||
// 현재 선택된 회차 ID (내부 state로 관리 - URL 변경 없음)
|
||||
const [selectedDateId, setSelectedDateId] = useState(schedule.id);
|
||||
// 다이얼로그 열림 상태
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
// 다이얼로그 목록 ref (자동 스크롤용)
|
||||
const listRef = useRef(null);
|
||||
const selectedItemRef = useRef(null);
|
||||
|
||||
// 표시할 데이터 state (변경된 부분만 업데이트)
|
||||
const [displayData, setDisplayData] = useState({
|
||||
posterUrl: schedule.images?.[0] || null,
|
||||
title: schedule.title,
|
||||
date: schedule.date,
|
||||
time: schedule.time,
|
||||
locationName: schedule.location_name,
|
||||
locationAddress: schedule.location_address,
|
||||
locationLat: schedule.location_lat,
|
||||
locationLng: schedule.location_lng,
|
||||
description: schedule.description,
|
||||
sourceUrl: schedule.source?.url,
|
||||
});
|
||||
|
||||
// 선택된 회차 데이터 조회
|
||||
const { data: selectedSchedule } = useQuery({
|
||||
queryKey: ['schedule', selectedDateId],
|
||||
queryFn: () => getSchedule(selectedDateId),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: selectedDateId !== schedule.id,
|
||||
});
|
||||
|
||||
// 데이터 비교 후 변경된 부분만 업데이트
|
||||
useEffect(() => {
|
||||
const newData = selectedDateId === schedule.id ? schedule : selectedSchedule;
|
||||
if (!newData) return;
|
||||
|
||||
setDisplayData(prev => {
|
||||
const updates = {};
|
||||
const newPosterUrl = newData.images?.[0] || null;
|
||||
|
||||
if (prev.posterUrl !== newPosterUrl) updates.posterUrl = newPosterUrl;
|
||||
if (prev.title !== newData.title) updates.title = newData.title;
|
||||
if (prev.date !== newData.date) updates.date = newData.date;
|
||||
if (prev.time !== newData.time) updates.time = newData.time;
|
||||
if (prev.locationName !== newData.location_name) updates.locationName = newData.location_name;
|
||||
if (prev.locationAddress !== newData.location_address) updates.locationAddress = newData.location_address;
|
||||
if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
|
||||
if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
|
||||
if (prev.description !== newData.description) updates.description = newData.description;
|
||||
if (prev.sourceUrl !== newData.source?.url) updates.sourceUrl = newData.source?.url;
|
||||
|
||||
// 변경된 것이 있을 때만 업데이트
|
||||
if (Object.keys(updates).length > 0) {
|
||||
return { ...prev, ...updates };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [selectedDateId, schedule, selectedSchedule]);
|
||||
|
||||
// 다이얼로그 열릴 때 선택된 항목으로 스크롤
|
||||
useEffect(() => {
|
||||
if (isDialogOpen && selectedItemRef.current) {
|
||||
setTimeout(() => {
|
||||
selectedItemRef.current?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}, 50);
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
const relatedDates = schedule.related_dates || [];
|
||||
const hasMultipleDates = relatedDates.length > 1;
|
||||
const hasLocation = displayData.locationLat && displayData.locationLng;
|
||||
|
||||
// 현재 선택된 회차 인덱스
|
||||
const selectedIndex = relatedDates.findIndex(d => d.id === selectedDateId);
|
||||
|
||||
// 회차 선택 핸들러
|
||||
const handleSelectDate = (id) => {
|
||||
setSelectedDateId(id);
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
// 개별 날짜 포맷팅
|
||||
const formatSingleDate = (dateStr, timeStr) => {
|
||||
const date = new Date(dateStr);
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const weekday = dayNames[date.getDay()];
|
||||
|
||||
let result = `${month}월 ${day}일 (${weekday})`;
|
||||
if (timeStr) {
|
||||
result += ` ${timeStr.slice(0, 5)}`;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="-mx-4 -mt-4">
|
||||
{/* 히어로 헤더 */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* 배경 블러 이미지 */}
|
||||
{displayData.posterUrl ? (
|
||||
<div className="absolute inset-0 scale-110 overflow-hidden">
|
||||
<img
|
||||
src={displayData.posterUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-[24px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary/90 to-primary/70" />
|
||||
)}
|
||||
{/* 오버레이 그라디언트 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/50 to-black/70" />
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="relative px-5 pt-6 pb-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* 포스터 */}
|
||||
{displayData.posterUrl && (
|
||||
<div className="mb-4 rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/20">
|
||||
<img
|
||||
src={displayData.posterUrl}
|
||||
alt={displayData.title}
|
||||
className="w-32 h-44 object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 제목 */}
|
||||
<h1 className="text-white font-bold text-lg leading-snug drop-shadow-lg max-w-xs">
|
||||
{decodeHtmlEntities(displayData.title)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 섹션 */}
|
||||
<div className="px-4 pt-4 space-y-4">
|
||||
{/* 공연 일정 카드 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-3">
|
||||
<Calendar size={14} />
|
||||
<span>공연 일정</span>
|
||||
</div>
|
||||
{/* 현재 회차 표시 */}
|
||||
<div className="px-4 py-3 bg-primary/10 rounded-lg">
|
||||
<p className="text-primary font-medium text-sm">
|
||||
{hasMultipleDates && <span className="mr-1">{selectedIndex + 1}회차 ·</span>}
|
||||
{formatSingleDate(displayData.date, displayData.time)}
|
||||
</p>
|
||||
</div>
|
||||
{/* 다른 회차 선택 버튼 */}
|
||||
{hasMultipleDates && (
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="w-full mt-2 py-2.5 text-sm text-gray-500 font-medium active:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
다른 회차 선택
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 장소 카드 */}
|
||||
{displayData.locationName && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<MapPin size={14} />
|
||||
<span>장소</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{displayData.locationName}</p>
|
||||
{displayData.locationAddress && (
|
||||
<p className="text-gray-500 text-sm mt-0.5">{displayData.locationAddress}</p>
|
||||
)}
|
||||
|
||||
{/* 지도 - 좌표가 있으면 카카오맵, 없으면 구글맵 */}
|
||||
{hasLocation ? (
|
||||
<div className="mt-3 rounded-xl overflow-hidden">
|
||||
<KakaoMap
|
||||
lat={parseFloat(displayData.locationLat)}
|
||||
lng={parseFloat(displayData.locationLng)}
|
||||
name={displayData.locationName}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-xl overflow-hidden">
|
||||
<iframe
|
||||
src={`https://maps.google.com/maps?q=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}&output=embed&hl=ko`}
|
||||
className="w-full h-40 border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title="Google Maps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 설명 */}
|
||||
{displayData.description && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{decodeHtmlEntities(displayData.description)}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{displayData.locationName && (
|
||||
<a
|
||||
href={hasLocation
|
||||
? `https://map.kakao.com/link/to/${encodeURIComponent(displayData.locationName)},${displayData.locationLat},${displayData.locationLng}`
|
||||
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center gap-2 w-full py-3.5 text-white rounded-xl font-medium transition-colors ${
|
||||
hasLocation
|
||||
? 'bg-blue-500 active:bg-blue-600'
|
||||
: 'bg-[#4285F4] active:bg-[#3367D6]'
|
||||
}`}
|
||||
>
|
||||
<Navigation size={18} />
|
||||
길찾기
|
||||
</a>
|
||||
)}
|
||||
{displayData.sourceUrl && (
|
||||
<a
|
||||
href={displayData.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3.5 bg-gray-100 active:bg-gray-200 text-gray-900 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
상세 정보
|
||||
</a>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 회차 선택 다이얼로그 */}
|
||||
<AnimatePresence>
|
||||
{isDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* 백드롭 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
{/* 다이얼로그 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-xl"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<h3 className="text-base font-bold text-gray-900">회차 선택</h3>
|
||||
</div>
|
||||
{/* 회차 목록 */}
|
||||
<div ref={listRef} className="max-h-72 overflow-y-auto">
|
||||
{relatedDates.map((item, index) => {
|
||||
const isSelected = item.id === selectedDateId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
onClick={() => handleSelectDate(item.id)}
|
||||
className={`w-full flex items-center justify-between px-5 py-3.5 text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10'
|
||||
: 'active:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className={isSelected ? 'text-primary font-medium' : 'text-gray-700'}>
|
||||
{index + 1}회차 · {formatSingleDate(item.date, item.time)}
|
||||
</span>
|
||||
{isSelected && <Check size={18} className="text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 닫기 버튼 */}
|
||||
<div className="px-5 py-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="w-full py-3 bg-gray-100 active:bg-gray-200 text-gray-700 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 섹션 컴포넌트 (다른 카테고리용 - 임시)
|
||||
function DefaultSection({ schedule }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<h1 className="font-bold text-gray-900 text-base mb-3">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatFullDate(schedule.date)}</span>
|
||||
</div>
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
<span>{formatTime(schedule.time)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{schedule.description && (
|
||||
<p className="mt-3 text-sm text-gray-600 whitespace-pre-wrap">
|
||||
{decodeHtmlEntities(schedule.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileScheduleDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
// 모바일 레이아웃 활성화
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('mobile-layout');
|
||||
return () => {
|
||||
document.documentElement.classList.remove('mobile-layout');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: schedule, isLoading, error } = useQuery({
|
||||
queryKey: ['schedule', id],
|
||||
queryFn: () => getSchedule(id),
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 이전 데이터가 없을 때만 로딩 스피너 표시
|
||||
if (isLoading && !schedule) {
|
||||
return (
|
||||
<div className="mobile-layout-container bg-gray-50">
|
||||
<div className="mobile-content flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !schedule) {
|
||||
return (
|
||||
<div className="mobile-layout-container bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<div className="mobile-content flex items-center justify-center px-6">
|
||||
<div className="text-center">
|
||||
{/* 아이콘 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, type: "spring", stiffness: 100 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-primary/10 to-primary/5 rounded-2xl flex items-center justify-center">
|
||||
<Calendar size={48} className="text-primary/40" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 메시지 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">
|
||||
일정을 찾을 수 없습니다
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
요청하신 일정이 존재하지 않거나
|
||||
<br />
|
||||
삭제되었을 수 있습니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 장식 요소 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="flex justify-center gap-1.5 mb-8"
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary"
|
||||
animate={{
|
||||
y: [0, -6, 0],
|
||||
opacity: [0.3, 1, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center justify-center gap-2 w-full px-6 py-3 border-2 border-primary text-primary rounded-full font-medium active:bg-primary active:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
이전 페이지
|
||||
</button>
|
||||
<Link
|
||||
to="/schedule"
|
||||
className="flex items-center justify-center gap-2 w-full px-6 py-3 bg-gradient-to-r from-primary to-primary-dark text-white rounded-full font-medium active:opacity-90 transition-opacity"
|
||||
>
|
||||
<Calendar size={18} />
|
||||
일정 목록
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리별 섹션 렌더링
|
||||
const renderCategorySection = () => {
|
||||
switch (schedule.category_id) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
return <XSection schedule={schedule} />;
|
||||
case CATEGORY_ID.CONCERT:
|
||||
return <ConcertSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-layout-container bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-100">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="p-2 -ml-2 rounded-lg active:bg-gray-100"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: schedule.category_color }}
|
||||
>
|
||||
{schedule.category_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="mobile-content p-4">
|
||||
{renderCategorySection()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileScheduleDetail;
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
import {
|
||||
Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import * as albumsApi from '../../../api/admin/albums';
|
||||
|
||||
// 커스텀 드롭다운 컴포넌트
|
||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||
|
|
@ -79,17 +76,17 @@ function CustomSelect({ value, onChange, options, placeholder }) {
|
|||
|
||||
function AdminAlbumForm() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useParams();
|
||||
const isEditMode = !!id;
|
||||
const coverInputRef = useRef(null);
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [coverPreview, setCoverPreview] = useState(null);
|
||||
const [coverFile, setCoverFile] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
album_type: '',
|
||||
|
|
@ -104,42 +101,45 @@ function AdminAlbumForm() {
|
|||
|
||||
const [tracks, setTracks] = useState([]);
|
||||
|
||||
// 수정 모드일 때 앨범 데이터 로드 (useQuery 사용)
|
||||
const { data: albumData, isLoading: loading, error: albumError } = useQuery({
|
||||
queryKey: ['admin', 'album', id],
|
||||
queryFn: () => albumsApi.getAlbum(id),
|
||||
enabled: isAuthenticated && isEditMode && !!id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 앨범 데이터 로드 시 폼에 반영
|
||||
useEffect(() => {
|
||||
if (albumData) {
|
||||
setFormData({
|
||||
title: albumData.title || '',
|
||||
album_type: albumData.album_type || '',
|
||||
album_type_short: albumData.album_type_short || '',
|
||||
release_date: albumData.release_date ? albumData.release_date.split('T')[0] : '',
|
||||
cover_original_url: albumData.cover_original_url || '',
|
||||
cover_medium_url: albumData.cover_medium_url || '',
|
||||
cover_thumb_url: albumData.cover_thumb_url || '',
|
||||
folder_name: albumData.folder_name || '',
|
||||
description: albumData.description || '',
|
||||
});
|
||||
if (albumData.cover_medium_url || albumData.cover_original_url) {
|
||||
setCoverPreview(albumData.cover_medium_url || albumData.cover_original_url);
|
||||
}
|
||||
setTracks(albumData.tracks || []);
|
||||
}
|
||||
}, [albumData]);
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const userData = localStorage.getItem('adminUser');
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (albumError) {
|
||||
console.error('앨범 로드 오류:', albumError);
|
||||
setToast({ message: '앨범 로드 중 오류가 발생했습니다.', type: 'error' });
|
||||
if (!token || !userData) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
}, [albumError, setToast]);
|
||||
|
||||
setUser(JSON.parse(userData));
|
||||
|
||||
if (isEditMode) {
|
||||
setLoading(true);
|
||||
fetch(`/api/albums/${id}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setFormData({
|
||||
title: data.title || '',
|
||||
album_type: data.album_type || '',
|
||||
album_type_short: data.album_type_short || '',
|
||||
release_date: data.release_date ? data.release_date.split('T')[0] : '',
|
||||
cover_original_url: data.cover_original_url || '',
|
||||
cover_medium_url: data.cover_medium_url || '',
|
||||
cover_thumb_url: data.cover_thumb_url || '',
|
||||
folder_name: data.folder_name || '',
|
||||
description: data.description || '',
|
||||
});
|
||||
if (data.cover_medium_url || data.cover_original_url) {
|
||||
setCoverPreview(data.cover_medium_url || data.cover_original_url);
|
||||
}
|
||||
setTracks(data.tracks || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [id, isEditMode, navigate]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
|
@ -227,7 +227,7 @@ function AdminAlbumForm() {
|
|||
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const url = isEditMode ? `/api/albums/${id}` : '/api/albums';
|
||||
const url = isEditMode ? `/api/admin/albums/${id}` : '/api/admin/albums';
|
||||
const method = isEditMode ? 'PUT' : 'POST';
|
||||
|
||||
const submitData = new FormData();
|
||||
|
|
@ -248,8 +248,6 @@ function AdminAlbumForm() {
|
|||
throw new Error('저장 실패');
|
||||
}
|
||||
|
||||
// 앨범 목록 캐시 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
|
||||
navigate('/admin/albums');
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, Link, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||
import {
|
||||
import {
|
||||
Upload, Trash2, Image, X, Check, Plus,
|
||||
Home, ChevronRight, ArrowLeft, Grid, List,
|
||||
ZoomIn, GripVertical, Users, User, Users2,
|
||||
|
|
@ -11,10 +10,11 @@ import {
|
|||
import Toast from '../../../components/Toast';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import * as authApi from '../../../api/admin/auth';
|
||||
import { getAlbum } from '../../../api/public/albums';
|
||||
import { getMembers } from '../../../api/public/members';
|
||||
import * as albumsApi from '../../../api/admin/albums';
|
||||
import * as membersApi from '../../../api/admin/members';
|
||||
|
||||
function AdminAlbumPhotos() {
|
||||
const { albumId } = useParams();
|
||||
|
|
@ -22,7 +22,11 @@ function AdminAlbumPhotos() {
|
|||
const fileInputRef = useRef(null);
|
||||
const photoListRef = useRef(null); // 사진 목록 영역 ref
|
||||
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const [album, setAlbum] = useState(null);
|
||||
const [photos, setPhotos] = useState([]);
|
||||
const [teasers, setTeasers] = useState([]); // 티저 이미지
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
const [selectedPhotos, setSelectedPhotos] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
|
@ -31,6 +35,7 @@ function AdminAlbumPhotos() {
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
const [previewPhoto, setPreviewPhoto] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [members, setMembers] = useState([]); // DB에서 로드
|
||||
|
||||
// 업로드 대기 중인 파일들
|
||||
const [pendingFiles, setPendingFiles] = useState([]);
|
||||
|
|
@ -137,52 +142,55 @@ function AdminAlbumPhotos() {
|
|||
}));
|
||||
};
|
||||
|
||||
// 앨범 정보 로드 (useQuery)
|
||||
const { data: album, isLoading: albumLoading, error: albumError, refetch: refetchAlbum } = useQuery({
|
||||
queryKey: ['admin', 'album', albumId],
|
||||
queryFn: () => albumsApi.getAlbum(albumId),
|
||||
enabled: isAuthenticated && !!albumId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 멤버 목록 로드 (useQuery)
|
||||
const { data: members = [] } = useQuery({
|
||||
queryKey: ['admin', 'members'],
|
||||
queryFn: () => membersApi.getMembers(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000, // 5분 캐시
|
||||
});
|
||||
|
||||
// 컨셉 포토 목록 로드 (useQuery)
|
||||
const { data: photos = [], refetch: refetchPhotos } = useQuery({
|
||||
queryKey: ['admin', 'album', albumId, 'photos'],
|
||||
queryFn: () => albumsApi.getAlbumPhotos(albumId),
|
||||
enabled: isAuthenticated && !!albumId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 티저 이미지 목록 로드 (useQuery)
|
||||
const { data: teasers = [], refetch: refetchTeasers } = useQuery({
|
||||
queryKey: ['admin', 'album', albumId, 'teasers'],
|
||||
queryFn: () => albumsApi.getAlbumTeasers(albumId),
|
||||
enabled: isAuthenticated && !!albumId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 로딩 상태 통합
|
||||
const loading = albumLoading;
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (albumError) {
|
||||
console.error('앨범 로드 오류:', albumError);
|
||||
setToast({ message: albumError.message || '앨범 로드 중 오류가 발생했습니다.', type: 'error' });
|
||||
// 로그인 확인
|
||||
if (!authApi.hasToken()) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
}, [albumError, setToast]);
|
||||
|
||||
// 데이터 새로고침 함수
|
||||
setUser(authApi.getCurrentUser());
|
||||
fetchAlbumData();
|
||||
}, [navigate, albumId]);
|
||||
|
||||
const fetchAlbumData = async () => {
|
||||
await Promise.all([refetchAlbum(), refetchPhotos(), refetchTeasers()]);
|
||||
try {
|
||||
// 앨범 정보 로드
|
||||
const albumData = await getAlbum(albumId);
|
||||
setAlbum(albumData);
|
||||
|
||||
// 멤버 목록 로드
|
||||
try {
|
||||
const membersData = await getMembers();
|
||||
setMembers(membersData);
|
||||
} catch (e) { /* 무시 */ }
|
||||
|
||||
// 기존 컨셉 포토 목록 로드
|
||||
let photosData = [];
|
||||
try {
|
||||
photosData = await albumsApi.getAlbumPhotos(albumId);
|
||||
setPhotos(photosData);
|
||||
} catch (e) { /* 무시 */ }
|
||||
|
||||
// 티저 이미지 목록 로드
|
||||
let teasersData = [];
|
||||
try {
|
||||
teasersData = await albumsApi.getAlbumTeasers(albumId);
|
||||
setTeasers(teasersData);
|
||||
} catch (e) { /* 무시 */ }
|
||||
|
||||
// 시작 번호 자동 설정
|
||||
const maxPhotoOrder = photosData.length > 0
|
||||
? Math.max(...photosData.map(p => p.sort_order || 0))
|
||||
: 0;
|
||||
|
||||
setStartNumber(maxPhotoOrder + 1);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
setToast({ message: error.message, type: 'error' });
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 타입 변경 시 시작 번호 자동 업데이트
|
||||
|
|
@ -402,7 +410,7 @@ function AdminAlbumPhotos() {
|
|||
formData.append('photoType', photoType);
|
||||
|
||||
// 업로드 진행률 + SSE로 서버 처리 진행률
|
||||
const response = await fetch(`/api/albums/${albumId}/photos`, {
|
||||
const response = await fetch(`/api/admin/albums/${albumId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
|
|
@ -487,11 +495,11 @@ function AdminAlbumPhotos() {
|
|||
await albumsApi.deleteAlbumTeaser(albumId, teaserId);
|
||||
}
|
||||
|
||||
// 데이터 새로고침
|
||||
if (photoIds.length > 0) await refetchPhotos();
|
||||
if (teaserIds.length > 0) await refetchTeasers();
|
||||
// UI 상태 업데이트
|
||||
setPhotos(prev => prev.filter(p => !photoIds.includes(p.id)));
|
||||
setTeasers(prev => prev.filter(t => !teaserIds.includes(t.id)));
|
||||
setSelectedPhotos([]);
|
||||
|
||||
|
||||
const totalDeleted = photoIds.length + teaserIds.length;
|
||||
setToast({ message: `${totalDeleted}개 항목이 삭제되었습니다.`, type: 'success' });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,43 +1,61 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Plus, Search, Edit2, Trash2, Image, Music, Home, ChevronRight, Calendar } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Plus, Search, Edit2, Trash2, Image, Music,
|
||||
Home, ChevronRight, Calendar, X
|
||||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import Tooltip from '../../../components/Tooltip';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import * as authApi from '../../../api/admin/auth';
|
||||
import { getAlbums } from '../../../api/public/albums';
|
||||
import * as albumsApi from '../../../api/admin/albums';
|
||||
|
||||
|
||||
function AdminAlbums() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
const [albums, setAlbums] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
const [deleteDialog, setDeleteDialog] = useState({ show: false, album: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// 앨범 목록 조회 (useQuery)
|
||||
const { data: albums = [], isLoading: loading } = useQuery({
|
||||
queryKey: ['admin', 'albums'],
|
||||
queryFn: () => albumsApi.getAlbums(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
// 로그인 확인
|
||||
if (!authApi.hasToken()) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(authApi.getCurrentUser());
|
||||
fetchAlbums();
|
||||
}, [navigate]);
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
try {
|
||||
const data = await getAlbums();
|
||||
setAlbums(data);
|
||||
} catch (error) {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteDialog.album) return;
|
||||
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await albumsApi.deleteAlbum(deleteDialog.album.id);
|
||||
setToast({ message: `"${deleteDialog.album.title}" 앨범이 삭제되었습니다.`, type: 'success' });
|
||||
setDeleteDialog({ show: false, album: null });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
|
||||
fetchAlbums();
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
setToast({ message: '앨범 삭제 중 오류가 발생했습니다.', type: 'error' });
|
||||
|
|
|
|||
|
|
@ -1,77 +1,113 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Disc3, Calendar, Users,
|
||||
Home, ChevronRight
|
||||
} from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import { getStats } from '../../../api/admin/stats';
|
||||
import * as authApi from '../../../api/admin/auth';
|
||||
import { getMembers } from '../../../api/public/members';
|
||||
import { getAlbums, getAlbum } from '../../../api/public/albums';
|
||||
import { getSchedules } from '../../../api/public/schedules';
|
||||
|
||||
// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로, 3자리 쉼표 포함)
|
||||
// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로)
|
||||
function AnimatedNumber({ value }) {
|
||||
// 3자리마다 쉼표가 들어갈 위치 계산
|
||||
const formatted = value.toLocaleString();
|
||||
const chars = formatted.split('');
|
||||
|
||||
const digits = String(value).split('');
|
||||
|
||||
return (
|
||||
<span className="inline-flex overflow-hidden">
|
||||
{chars.map((char, i) => {
|
||||
// 쉼표도 애니메이션으로 표시
|
||||
if (char === ',') {
|
||||
return (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="h-[1.2em] flex items-center"
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.8,
|
||||
delay: i * 0.15
|
||||
}}
|
||||
>
|
||||
,
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(char) * 10}%` }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.8,
|
||||
delay: i * 0.15
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
|
||||
<span key={n} className="h-[1.2em] flex items-center justify-center">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{digits.map((digit, i) => (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(digit) * 10}%` }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.8,
|
||||
delay: i * 0.2
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
|
||||
<span key={n} className="h-[1.2em] flex items-center justify-center">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 통계 조회 (useQuery)
|
||||
const { data: stats = { members: 0, albums: 0, photos: 0, schedules: 0 } } = useQuery({
|
||||
queryKey: ['admin', 'stats'],
|
||||
queryFn: getStats,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30 * 1000, // 30초 캐시
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [stats, setStats] = useState({
|
||||
albums: 0,
|
||||
photos: 0,
|
||||
schedules: 0,
|
||||
members: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인 상태 확인
|
||||
if (!authApi.hasToken()) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(authApi.getCurrentUser());
|
||||
|
||||
// 토큰 유효성 검증
|
||||
authApi.verifyToken()
|
||||
.catch(() => {
|
||||
authApi.logout();
|
||||
navigate('/admin');
|
||||
});
|
||||
|
||||
// 통계 데이터 가져오기
|
||||
fetchStats();
|
||||
}, [navigate]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
// 각 통계를 개별적으로 가져와서 하나가 실패해도 다른 것은 표시
|
||||
try {
|
||||
const members = await getMembers();
|
||||
setStats(prev => ({ ...prev, members: members.filter(m => !m.is_former).length }));
|
||||
} catch (e) { console.error('멤버 통계 오류:', e); }
|
||||
|
||||
try {
|
||||
const albums = await getAlbums();
|
||||
setStats(prev => ({ ...prev, albums: albums.length }));
|
||||
|
||||
// 사진 수 계산
|
||||
let totalPhotos = 0;
|
||||
for (const album of albums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
if (detail.conceptPhotos) {
|
||||
Object.values(detail.conceptPhotos).forEach(photos => {
|
||||
totalPhotos += photos.length;
|
||||
});
|
||||
}
|
||||
if (detail.teasers) {
|
||||
totalPhotos += detail.teasers.length;
|
||||
}
|
||||
} catch (e) { /* 개별 앨범 오류 무시 */ }
|
||||
}
|
||||
setStats(prev => ({ ...prev, photos: totalPhotos }));
|
||||
} catch (e) { console.error('앨범 통계 오류:', e); }
|
||||
|
||||
try {
|
||||
const today = new Date();
|
||||
const schedules = await getSchedules(today.getFullYear(), today.getMonth() + 1);
|
||||
setStats(prev => ({ ...prev, schedules: Array.isArray(schedules) ? schedules.length : 0 }));
|
||||
} catch (e) { console.error('일정 통계 오류:', e); }
|
||||
};
|
||||
|
||||
// 메뉴 아이템
|
||||
const menuItems = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,66 +1,64 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Save, Upload, X, Home, ChevronRight, User, Instagram, Calendar, Tag } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Save, Upload,
|
||||
Home, ChevronRight, User, Instagram, Calendar, Briefcase
|
||||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import { fetchAdminApi, fetchAdminFormData } from '../../../api';
|
||||
import * as authApi from '../../../api/admin/auth';
|
||||
import * as membersApi from '../../../api/admin/members';
|
||||
|
||||
function AdminMemberEdit() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { name } = useParams();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast, setToast } = useToast();
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
name_en: '',
|
||||
birth_date: '',
|
||||
position: '',
|
||||
instagram: '',
|
||||
is_former: false,
|
||||
nicknames: []
|
||||
is_former: false
|
||||
});
|
||||
|
||||
// 멤버 상세 조회
|
||||
const { data: memberData, isLoading: loading, isError } = useQuery({
|
||||
queryKey: ['admin', 'member', name],
|
||||
queryFn: () => fetchAdminApi(`/api/members/${encodeURIComponent(name)}`),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 데이터 로드 시 폼에 반영
|
||||
useEffect(() => {
|
||||
if (memberData) {
|
||||
const birthDate = memberData.birth_date
|
||||
? memberData.birth_date.split('T')[0]
|
||||
: '';
|
||||
// 로그인 확인
|
||||
if (!authApi.hasToken()) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(authApi.getCurrentUser());
|
||||
fetchMember();
|
||||
}, [navigate, name]);
|
||||
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
const data = await membersApi.getMember(encodeURIComponent(name));
|
||||
setFormData({
|
||||
name: memberData.name || '',
|
||||
name_en: memberData.name_en || '',
|
||||
birth_date: birthDate,
|
||||
instagram: memberData.instagram || '',
|
||||
is_former: !!memberData.is_former,
|
||||
nicknames: memberData.nicknames || []
|
||||
name: data.name || '',
|
||||
birth_date: data.birth_date ? data.birth_date.split('T')[0] : '',
|
||||
position: data.position || '',
|
||||
instagram: data.instagram || '',
|
||||
is_former: !!data.is_former
|
||||
});
|
||||
setImagePreview(memberData.image_url);
|
||||
setImagePreview(data.image_url);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('멤버 로드 오류:', error);
|
||||
setToast({ message: '멤버 정보를 불러올 수 없습니다.', type: 'error' });
|
||||
setLoading(false);
|
||||
}
|
||||
}, [memberData]);
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setToast({ message: '멤버 정보를 불러오는데 실패했습니다.', type: 'error' });
|
||||
}
|
||||
}, [isError, setToast]);
|
||||
};
|
||||
|
||||
const handleImageChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
|
|
@ -72,62 +70,29 @@ function AdminMemberEdit() {
|
|||
}
|
||||
};
|
||||
|
||||
// 별명 추가
|
||||
const handleAddNickname = () => {
|
||||
const trimmed = nicknameInput.trim();
|
||||
if (trimmed && !formData.nicknames.includes(trimmed)) {
|
||||
setFormData({
|
||||
...formData,
|
||||
nicknames: [...formData.nicknames, trimmed]
|
||||
});
|
||||
setNicknameInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 별명 삭제
|
||||
const handleRemoveNickname = (nickname) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
nicknames: formData.nicknames.filter(n => n !== nickname)
|
||||
});
|
||||
};
|
||||
|
||||
// Enter 키로 별명 추가
|
||||
const handleNicknameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddNickname();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('name', formData.name);
|
||||
form.append('name_en', formData.name_en);
|
||||
form.append('birth_date', formData.birth_date);
|
||||
form.append('instagram', formData.instagram);
|
||||
form.append('is_former', formData.is_former ? '1' : '0');
|
||||
form.append('nicknames', JSON.stringify(formData.nicknames));
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
|
||||
formDataToSend.append('name', formData.name);
|
||||
formDataToSend.append('birth_date', formData.birth_date);
|
||||
formDataToSend.append('position', formData.position);
|
||||
formDataToSend.append('instagram', formData.instagram);
|
||||
formDataToSend.append('is_former', formData.is_former);
|
||||
|
||||
if (imageFile) {
|
||||
form.append('image', imageFile);
|
||||
formDataToSend.append('image', imageFile);
|
||||
}
|
||||
|
||||
await fetchAdminFormData(`/api/members/${encodeURIComponent(name)}`, form, 'PUT');
|
||||
|
||||
// 목록 캐시 무효화 (목록 페이지에서 최신 데이터 표시)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
|
||||
// 목록 페이지로 이동하면서 토스트 메시지 전달
|
||||
navigate('/admin/members', {
|
||||
state: { toast: { message: '멤버 정보가 수정되었습니다.', type: 'success' } }
|
||||
});
|
||||
} catch (err) {
|
||||
setToast({ message: err.message || '멤버 수정에 실패했습니다.', type: 'error' });
|
||||
await membersApi.updateMember(encodeURIComponent(name), formDataToSend);
|
||||
setToast({ message: '멤버 정보가 수정되었습니다.', type: 'success' });
|
||||
setTimeout(() => navigate('/admin/members'), 1000);
|
||||
} catch (error) {
|
||||
console.error('수정 오류:', error);
|
||||
setToast({ message: '수정 중 오류가 발생했습니다.', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -176,15 +141,15 @@ function AdminMemberEdit() {
|
|||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
프로필 사진
|
||||
</label>
|
||||
<div
|
||||
<div
|
||||
className="aspect-[3/4] rounded-2xl border-2 border-dashed border-gray-200 overflow-hidden relative group cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => document.getElementById('imageInput').click()}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<>
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="프로필 미리보기"
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="프로필 미리보기"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
|
|
@ -213,33 +178,19 @@ function AdminMemberEdit() {
|
|||
{/* 입력 폼 영역 */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
{/* 이름 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<User size={16} className="inline mr-1" />
|
||||
이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="멤버 이름"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
영문 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name_en}
|
||||
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="ENGLISH NAME"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<User size={16} className="inline mr-1" />
|
||||
이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="멤버 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 생년월일 */}
|
||||
|
|
@ -254,6 +205,21 @@ function AdminMemberEdit() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 포지션 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Briefcase size={16} className="inline mr-1" />
|
||||
포지션
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.position}
|
||||
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="메인보컬, 리드댄서 등"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인스타그램 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
|
|
@ -265,53 +231,10 @@ function AdminMemberEdit() {
|
|||
value={formData.instagram}
|
||||
onChange={(e) => setFormData({ ...formData, instagram: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="https://www.instagram.com/username"
|
||||
placeholder="@username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 별명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Tag size={16} className="inline mr-1" />
|
||||
별명
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{/* 별명 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
onKeyDown={handleNicknameKeyDown}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="별명을 입력하고 Enter"
|
||||
/>
|
||||
|
||||
{/* 별명 태그 목록 */}
|
||||
{formData.nicknames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.nicknames.map((nickname, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary/10 text-primary rounded-full text-sm"
|
||||
>
|
||||
{nickname}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveNickname(nickname)}
|
||||
className="hover:bg-primary/20 rounded-full p-0.5 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
별명은 일정 검색 시 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 활동 상태 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
|
|
@ -322,8 +245,8 @@ function AdminMemberEdit() {
|
|||
type="button"
|
||||
onClick={() => setFormData({ ...formData, is_former: false })}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
!formData.is_former
|
||||
? 'bg-primary text-white'
|
||||
!formData.is_former
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -333,8 +256,8 @@ function AdminMemberEdit() {
|
|||
type="button"
|
||||
onClick={() => setFormData({ ...formData, is_former: true })}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.is_former
|
||||
? 'bg-gray-600 text-white'
|
||||
formData.is_former
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,71 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Edit2, Home, ChevronRight, Users, User } from 'lucide-react';
|
||||
import {
|
||||
Edit2,
|
||||
Home, ChevronRight, Users, User
|
||||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import { fetchAdminApi } from '../../../api';
|
||||
|
||||
// 멤버 카드 컴포넌트
|
||||
function MemberCard({ member, index, isFormer = false, onClick }) {
|
||||
return (
|
||||
function AdminMembers() {
|
||||
const navigate = useNavigate();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const userData = localStorage.getItem('adminUser');
|
||||
|
||||
if (!token || !userData) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(JSON.parse(userData));
|
||||
fetchMembers();
|
||||
}, [navigate]);
|
||||
|
||||
const fetchMembers = () => {
|
||||
fetch('/api/members')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setMembers(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('멤버 로드 오류:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 활동/탈퇴 멤버 분리 (is_former: 0=활동, 1=탈퇴)
|
||||
const activeMembers = members.filter(m => !m.is_former);
|
||||
const formerMembers = members.filter(m => m.is_former);
|
||||
|
||||
// 멤버 카드 컴포넌트
|
||||
const MemberCard = ({ member, index, isFormer = false }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.35,
|
||||
delay: index * 0.06
|
||||
}}
|
||||
className={`relative rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all group cursor-pointer ${isFormer ? 'opacity-60' : ''}`}
|
||||
onClick={onClick}
|
||||
onClick={() => navigate(`/admin/members/${encodeURIComponent(member.name)}/edit`)}
|
||||
>
|
||||
{/* 프로필 이미지 */}
|
||||
<div className={`aspect-[3/4] bg-gray-100 relative overflow-hidden ${isFormer ? 'grayscale' : ''}`}>
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
|
|
@ -36,10 +74,14 @@ function MemberCard({ member, index, isFormer = false, onClick }) {
|
|||
<User size={48} className="text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이름 오버레이 - 하단 그라데이션 */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<h3 className="text-lg font-bold text-white drop-shadow-lg">{member.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* 수정 버튼 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<div className="px-4 py-2 bg-white/90 backdrop-blur-sm text-gray-900 rounded-lg font-medium flex items-center gap-2 shadow-lg">
|
||||
<Edit2 size={16} />
|
||||
|
|
@ -49,43 +91,6 @@ function MemberCard({ member, index, isFormer = false, onClick }) {
|
|||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminMembers() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
// 다른 페이지에서 전달된 토스트 메시지 처리
|
||||
useEffect(() => {
|
||||
if (location.state?.toast) {
|
||||
setToast(location.state.toast);
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
}, [location.state, setToast]);
|
||||
|
||||
// 멤버 목록 조회
|
||||
const { data: members = [], isLoading: loading, isError } = useQuery({
|
||||
queryKey: ['admin', 'members'],
|
||||
queryFn: () => fetchAdminApi('/api/members'),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setToast({ message: '멤버 목록을 불러오는데 실패했습니다.', type: 'error' });
|
||||
}
|
||||
}, [isError, setToast]);
|
||||
|
||||
// 활동/탈퇴 멤버 분리 (is_former: 0=활동, 1=탈퇴)
|
||||
const activeMembers = members.filter(m => !m.is_former);
|
||||
const formerMembers = members.filter(m => m.is_former);
|
||||
|
||||
const handleMemberClick = (memberName) => {
|
||||
navigate(`/admin/members/${encodeURIComponent(memberName)}/edit`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout user={user}>
|
||||
|
|
@ -129,12 +134,7 @@ function AdminMembers() {
|
|||
{/* 5열 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
onClick={() => handleMemberClick(member.name)}
|
||||
/>
|
||||
<MemberCard key={member.id} member={member} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -150,16 +150,10 @@ function AdminMembers() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5열 그리드 */}
|
||||
{/* 5열 그리드 (탈퇴 멤버용 - 4명이면 4개만 표시) */}
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
{formerMembers.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
isFormer
|
||||
onClick={() => handleMemberClick(member.name)}
|
||||
/>
|
||||
<MemberCard key={member.id} member={member} index={index} isFormer />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect, useRef, useMemo, memo, useDeferredValue } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
import {
|
||||
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
||||
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
|
||||
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2
|
||||
} from 'lucide-react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
|
@ -14,10 +14,10 @@ import Tooltip from '../../../components/Tooltip';
|
|||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||
import useScheduleStore from '../../../stores/useScheduleStore';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import { getTodayKST, formatDate } from '../../../utils/date';
|
||||
import * as schedulesApi from '../../../api/admin/schedules';
|
||||
import * as categoriesApi from '../../../api/admin/categories';
|
||||
|
||||
// HTML 엔티티 디코딩 함수
|
||||
const decodeHtmlEntities = (text) => {
|
||||
|
|
@ -28,17 +28,16 @@ const decodeHtmlEntities = (text) => {
|
|||
};
|
||||
|
||||
// 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지
|
||||
const ScheduleItem = memo(function ScheduleItem({
|
||||
schedule,
|
||||
index,
|
||||
selectedDate,
|
||||
categories,
|
||||
getColorStyle,
|
||||
navigate,
|
||||
openDeleteDialog
|
||||
const ScheduleItem = memo(function ScheduleItem({
|
||||
schedule,
|
||||
index,
|
||||
selectedDate,
|
||||
categories,
|
||||
getColorStyle,
|
||||
navigate,
|
||||
openDeleteDialog
|
||||
}) {
|
||||
const scheduleDate = new Date(schedule.date);
|
||||
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
||||
const categoryColor = getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280';
|
||||
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
|
||||
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
|
||||
|
|
@ -62,7 +61,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
|
|
@ -80,10 +79,10 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
<Tag size={14} />
|
||||
{categoryName}
|
||||
</span>
|
||||
{schedule.source?.name && (
|
||||
{schedule.source_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{schedule.source?.name}
|
||||
{schedule.source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -104,34 +103,31 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 생일 일정은 수정/삭제 불가 */}
|
||||
{!isBirthday && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source?.url && (
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source_url && (
|
||||
<a
|
||||
href={schedule.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteDialog(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteDialog(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
@ -151,10 +147,9 @@ function AdminSchedule() {
|
|||
scrollPosition, setScrollPosition,
|
||||
} = useScheduleStore();
|
||||
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
const scrollContainerRef = useRef(null);
|
||||
const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
|
||||
|
|
@ -286,27 +281,14 @@ function AdminSchedule() {
|
|||
|
||||
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
|
||||
|
||||
// 카테고리 목록 (API에서 로드)
|
||||
const [categories, setCategories] = useState([
|
||||
{ id: 'all', name: '전체', color: 'gray' }
|
||||
]);
|
||||
|
||||
// 일정 목록 (API에서 로드)
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
|
||||
// 카테고리는 일정 데이터에서 추출
|
||||
const categories = useMemo(() => {
|
||||
const categoryMap = new Map();
|
||||
schedules.forEach(s => {
|
||||
if (s.category_id && !categoryMap.has(s.category_id)) {
|
||||
categoryMap.set(s.category_id, {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
});
|
||||
}
|
||||
});
|
||||
return [
|
||||
{ id: 'all', name: '전체', color: 'gray' },
|
||||
...Array.from(categoryMap.values())
|
||||
];
|
||||
}, [schedules]);
|
||||
|
||||
// 카테고리 색상 맵핑
|
||||
const colorMap = {
|
||||
blue: 'bg-blue-500',
|
||||
|
|
@ -377,15 +359,26 @@ function AdminSchedule() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const userData = localStorage.getItem('adminUser');
|
||||
|
||||
if (!token || !userData) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(JSON.parse(userData));
|
||||
|
||||
// 카테고리 로드
|
||||
fetchCategories();
|
||||
|
||||
// sessionStorage에서 토스트 메시지 확인 (일정 추가/수정 완료 시)
|
||||
const savedToast = sessionStorage.getItem('scheduleToast');
|
||||
if (savedToast) {
|
||||
setToast(JSON.parse(savedToast));
|
||||
sessionStorage.removeItem('scheduleToast');
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
// 월이 변경될 때마다 일정 로드
|
||||
|
|
@ -413,6 +406,19 @@ function AdminSchedule() {
|
|||
};
|
||||
|
||||
|
||||
// 카테고리 로드 함수
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await categoriesApi.getCategories();
|
||||
setCategories([
|
||||
{ id: 'all', name: '전체', color: 'gray' },
|
||||
...data
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 일정 로드 함수
|
||||
const fetchSchedules = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -657,13 +663,6 @@ function AdminSchedule() {
|
|||
<p className="text-gray-500">fromis_9의 일정을 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin/schedule/dict')}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
|
||||
>
|
||||
<Book size={20} />
|
||||
사전 관리
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/schedule/bots')}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
|
||||
|
|
@ -1250,7 +1249,7 @@ function AdminSchedule() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
|
||||
/>
|
||||
|
|
@ -1268,10 +1267,10 @@ function AdminSchedule() {
|
|||
<Tag size={14} />
|
||||
{categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
|
||||
</span>
|
||||
{schedule.source?.name && (
|
||||
{schedule.source_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{schedule.source?.name}
|
||||
{schedule.source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1293,9 +1292,9 @@ function AdminSchedule() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source?.url && (
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
{schedule.source_url && (
|
||||
<a
|
||||
href={schedule.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -1304,13 +1303,13 @@ function AdminSchedule() {
|
|||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={() => openDeleteDialog(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
import Toast from '../../../components/Toast';
|
||||
import Tooltip from '../../../components/Tooltip';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import * as botsApi from '../../../api/admin/bots';
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ const MeilisearchIcon = ({ size = 20 }) => (
|
|||
|
||||
function AdminScheduleBots() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
const [bots, setBots] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -53,11 +52,18 @@ function AdminScheduleBots() {
|
|||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchBots();
|
||||
fetchQuotaWarning();
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const userData = localStorage.getItem('adminUser');
|
||||
|
||||
if (!token || !userData) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
setUser(JSON.parse(userData));
|
||||
fetchBots();
|
||||
fetchQuotaWarning();
|
||||
}, [navigate]);
|
||||
|
||||
// 봇 목록 조회
|
||||
const fetchBots = async () => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { HexColorPicker } from 'react-colorful';
|
|||
import Toast from '../../../components/Toast';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import * as authApi from '../../../api/admin/auth';
|
||||
import * as categoriesApi from '../../../api/admin/categories';
|
||||
|
||||
// 기본 색상 (8개)
|
||||
|
|
@ -38,29 +38,41 @@ const getColorStyle = (colorValue) => {
|
|||
|
||||
function AdminScheduleCategory() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const [user, setUser] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, setToast, showSuccess, showError } = useToast();
|
||||
|
||||
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState(null);
|
||||
const [formData, setFormData] = useState({ name: '', color: 'blue' });
|
||||
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
|
||||
|
||||
// 카스텀 컴러 피커 팝업
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
|
||||
// 카테고리 로드
|
||||
// 사용자 인증 확인
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchCategories();
|
||||
if (!authApi.hasToken()) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
authApi.verifyToken()
|
||||
.then(data => {
|
||||
if (data.valid) {
|
||||
setUser(data.user);
|
||||
fetchCategories();
|
||||
} else {
|
||||
navigate('/admin');
|
||||
}
|
||||
})
|
||||
.catch(() => navigate('/admin'));
|
||||
}, [navigate]);
|
||||
|
||||
// 카테고리 목록 조회
|
||||
const fetchCategories = async () => {
|
||||
|
|
|
|||
|
|
@ -1,625 +0,0 @@
|
|||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import * as suggestionsApi from '../../../api/admin/suggestions';
|
||||
|
||||
// 품사 태그 옵션
|
||||
const POS_TAGS = [
|
||||
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
|
||||
{ value: 'NNG', label: '일반명사 (NNG)', description: '일반적인 명사', examples: '직캠, 팬미팅, 콘서트' },
|
||||
{ value: 'SL', label: '외국어 (SL)', description: '영어 등 외국어 단어', examples: 'fromis_9, YouTube, fromm' },
|
||||
];
|
||||
|
||||
// 단어 항목 컴포넌트
|
||||
function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editWord, setEditWord] = useState(word);
|
||||
const [editPos, setEditPos] = useState(pos);
|
||||
const [showPosDropdown, setShowPosDropdown] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setShowPosDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showPosDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showPosDropdown]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (editWord.trim() && (editWord.trim() !== word || editPos !== pos)) {
|
||||
onUpdate(id, editWord.trim(), editPos);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditWord(word);
|
||||
setEditPos(pos);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const posLabel = POS_TAGS.find(t => t.value === pos)?.label || pos;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="group hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 w-16">{index + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editWord}
|
||||
onChange={(e) => setEditWord(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
autoFocus
|
||||
className="w-full px-3 py-1.5 border border-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="cursor-pointer hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 w-48">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowPosDropdown(!showPosDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors w-full justify-between"
|
||||
>
|
||||
<span>{POS_TAGS.find(t => t.value === (isEditing ? editPos : pos))?.label.split(' ')[0] || pos}</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${showPosDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showPosDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
>
|
||||
{POS_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag.value}
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setEditPos(tag.value);
|
||||
} else {
|
||||
onUpdate(id, word, tag.value);
|
||||
}
|
||||
setShowPosDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||
(isEditing ? editPos : pos) === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{tag.label}</div>
|
||||
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-20">
|
||||
<button
|
||||
onClick={() => onDelete(index)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminScheduleDict() {
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterPos, setFilterPos] = useState('all');
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
||||
|
||||
// 새 단어 입력
|
||||
const [newWord, setNewWord] = useState('');
|
||||
const [newPos, setNewPos] = useState('NNP');
|
||||
const [showNewPosDropdown, setShowNewPosDropdown] = useState(false);
|
||||
|
||||
// 드롭다운 refs
|
||||
const newPosDropdownRef = useRef(null);
|
||||
const filterDropdownRef = useRef(null);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [wordToDelete, setWordToDelete] = useState(null); // { index, word, id }
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (newPosDropdownRef.current && !newPosDropdownRef.current.contains(event.target)) {
|
||||
setShowNewPosDropdown(false);
|
||||
}
|
||||
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target)) {
|
||||
setShowFilterDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showNewPosDropdown || showFilterDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showNewPosDropdown, showFilterDropdown]);
|
||||
|
||||
// 필터링된 항목
|
||||
const filteredEntries = useMemo(() => {
|
||||
return entries.filter((entry, index) => {
|
||||
if (entry.isComment) return true; // 주석은 항상 포함 (but 표시 안함)
|
||||
|
||||
const matchesSearch = !searchQuery ||
|
||||
entry.word.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesPos = filterPos === 'all' || entry.pos === filterPos;
|
||||
|
||||
return matchesSearch && matchesPos;
|
||||
});
|
||||
}, [entries, searchQuery, filterPos]);
|
||||
|
||||
// 실제 단어 항목만 (주석 제외)
|
||||
const wordEntries = useMemo(() => {
|
||||
return filteredEntries.filter(e => !e.isComment);
|
||||
}, [filteredEntries]);
|
||||
|
||||
// 품사별 통계
|
||||
const posStats = useMemo(() => {
|
||||
const stats = { total: 0 };
|
||||
entries.forEach(e => {
|
||||
if (!e.isComment) {
|
||||
stats.total++;
|
||||
stats[e.pos] = (stats[e.pos] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return stats;
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchDict();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// 고유 ID 생성
|
||||
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 사전 파일 파싱
|
||||
const parseDict = (content) => {
|
||||
const lines = content.split('\n');
|
||||
return lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
return { isComment: true, raw: line, id: generateId() };
|
||||
}
|
||||
const parts = trimmed.split('\t');
|
||||
return {
|
||||
word: parts[0] || '',
|
||||
pos: parts[1] || 'NNP',
|
||||
isComment: false,
|
||||
id: generateId(),
|
||||
};
|
||||
}).filter(e => e.isComment || e.word); // 빈 줄 제거하되 주석은 유지
|
||||
};
|
||||
|
||||
// 사전 파일 생성
|
||||
const serializeDict = (entries) => {
|
||||
return entries.map(e => {
|
||||
if (e.isComment) return e.raw;
|
||||
return `${e.word}\t${e.pos}`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
// 사전 내용 조회
|
||||
const fetchDict = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await suggestionsApi.getDict();
|
||||
const parsed = parseDict(data.content || '');
|
||||
setEntries(parsed);
|
||||
} catch (error) {
|
||||
console.error('사전 조회 오류:', error);
|
||||
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 사전 저장 (entries 배열을 받아서 저장)
|
||||
const saveDict = async (newEntries) => {
|
||||
try {
|
||||
const content = serializeDict(newEntries);
|
||||
await suggestionsApi.saveDict(content);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('사전 저장 오류:', error);
|
||||
setToast({ type: 'error', message: error.message || '저장 중 오류가 발생했습니다.' });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 단어 추가 다이얼로그 열기
|
||||
const openAddDialog = () => {
|
||||
if (!newWord.trim()) return;
|
||||
|
||||
// 중복 확인
|
||||
const isDuplicate = entries.some(e => !e.isComment && e.word.toLowerCase() === newWord.trim().toLowerCase());
|
||||
if (isDuplicate) {
|
||||
setToast({ type: 'error', message: '이미 존재하는 단어입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAddDialogOpen(true);
|
||||
};
|
||||
|
||||
// 단어 추가 확인
|
||||
const handleAddWord = async () => {
|
||||
setSaving(true);
|
||||
const wordToAdd = newWord.trim();
|
||||
const newEntry = { word: wordToAdd, pos: newPos, isComment: false, id: generateId() };
|
||||
const newEntries = [...entries, newEntry];
|
||||
|
||||
const success = await saveDict(newEntries);
|
||||
if (success) {
|
||||
setEntries(newEntries);
|
||||
setNewWord('');
|
||||
setToast({ type: 'success', message: `"${wordToAdd}" 단어가 추가되었습니다.` });
|
||||
}
|
||||
setAddDialogOpen(false);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// 단어 수정 (id 기반)
|
||||
const handleUpdateWord = async (id, word, pos) => {
|
||||
const entryIndex = entries.findIndex(e => e.id === id);
|
||||
if (entryIndex === -1) return;
|
||||
|
||||
const newEntries = [...entries];
|
||||
newEntries[entryIndex] = { ...newEntries[entryIndex], word, pos };
|
||||
|
||||
const success = await saveDict(newEntries);
|
||||
if (success) {
|
||||
setEntries(newEntries);
|
||||
}
|
||||
};
|
||||
|
||||
// 단어 삭제 다이얼로그 열기
|
||||
const openDeleteDialog = (id, word) => {
|
||||
setWordToDelete({ id, word });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 단어 삭제 확인
|
||||
const handleDeleteWord = async () => {
|
||||
if (!wordToDelete) return;
|
||||
|
||||
setSaving(true);
|
||||
const deletedWord = wordToDelete.word;
|
||||
const newEntries = entries.filter(e => e.id !== wordToDelete.id);
|
||||
|
||||
const success = await saveDict(newEntries);
|
||||
if (success) {
|
||||
setEntries(newEntries);
|
||||
setToast({ type: 'success', message: `"${deletedWord}" 단어가 삭제되었습니다.` });
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setWordToDelete(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// 엔터키로 추가 다이얼로그 열기
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
openAddDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout user={user}>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
{/* 단어 추가 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={addDialogOpen}
|
||||
onClose={() => !saving && setAddDialogOpen(false)}
|
||||
onConfirm={handleAddWord}
|
||||
title="단어 추가"
|
||||
message={
|
||||
<>
|
||||
<p className="text-gray-600 mb-2">다음 단어를 추가하시겠습니까?</p>
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<p className="font-medium text-gray-900">{newWord}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{POS_TAGS.find(t => t.value === newPos)?.label}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
confirmText="추가"
|
||||
loadingText="추가 중..."
|
||||
loading={saving}
|
||||
variant="primary"
|
||||
icon={Plus}
|
||||
/>
|
||||
|
||||
{/* 단어 삭제 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
onClose={() => {
|
||||
if (!saving) {
|
||||
setDeleteDialogOpen(false);
|
||||
setWordToDelete(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={handleDeleteWord}
|
||||
title="단어 삭제"
|
||||
message={
|
||||
<>
|
||||
<p className="text-gray-600 mb-2">다음 단어를 삭제하시겠습니까?</p>
|
||||
<p className="font-medium text-gray-900 p-3 bg-gray-50 rounded-lg">
|
||||
{wordToDelete?.word}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmText="삭제"
|
||||
loadingText="삭제 중..."
|
||||
loading={saving}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||
<Home size={16} />
|
||||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
|
||||
일정 관리
|
||||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-700">사전 관리</span>
|
||||
</div>
|
||||
|
||||
{/* 타이틀 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
||||
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
|
||||
<div className="text-sm text-gray-500">전체 단어</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
|
||||
<div className="text-sm text-gray-500">고유명사</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
|
||||
<div className="text-sm text-gray-500">일반명사</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
|
||||
<div className="text-sm text-gray-500">외국어</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단어 추가 영역 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newWord}
|
||||
onChange={(e) => setNewWord(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="추가할 단어 입력..."
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-48" ref={newPosDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowNewPosDropdown(!showNewPosDropdown)}
|
||||
className="flex items-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors w-full justify-between"
|
||||
>
|
||||
<span>{POS_TAGS.find(t => t.value === newPos)?.label.split(' ')[0]}</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${showNewPosDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showNewPosDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
>
|
||||
{POS_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag.value}
|
||||
onClick={() => {
|
||||
setNewPos(tag.value);
|
||||
setShowNewPosDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||
newPos === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{tag.label}</div>
|
||||
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddDialog}
|
||||
disabled={!newWord.trim()}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={20} />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단어 목록 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="단어 검색..."
|
||||
className="w-full pl-11 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative" ref={filterDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
<span>{filterPos === 'all' ? '전체 품사' : POS_TAGS.find(t => t.value === filterPos)?.label.split(' ')[0]}</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${showFilterDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showFilterDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full right-0 mt-1 w-48 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterPos('all');
|
||||
setShowFilterDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||
filterPos === 'all' ? 'bg-primary/5 text-primary' : ''
|
||||
}`}
|
||||
>
|
||||
전체 품사
|
||||
</button>
|
||||
{POS_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag.value}
|
||||
onClick={() => {
|
||||
setFilterPos(tag.value);
|
||||
setShowFilterDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||
filterPos === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.label.split(' ')[0]}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : wordEntries.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<Book size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>{searchQuery || filterPos !== 'all' ? '검색 결과가 없습니다' : '등록된 단어가 없습니다'}</p>
|
||||
<p className="text-sm mt-1">위의 입력창에서 단어를 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단어</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48">품사</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<AnimatePresence>
|
||||
{wordEntries.map((entry, index) => (
|
||||
<WordItem
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
word={entry.word}
|
||||
pos={entry.pos}
|
||||
index={index}
|
||||
onUpdate={handleUpdateWord}
|
||||
onDelete={() => openDeleteDialog(entry.id, entry.word)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 푸터 */}
|
||||
{wordEntries.length > 0 && (
|
||||
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100 text-sm text-gray-500">
|
||||
{searchQuery || filterPos !== 'all' ? (
|
||||
<span>{wordEntries.length}개 검색됨 (전체 {posStats.total}개)</span>
|
||||
) : (
|
||||
<span>총 {posStats.total}개 단어</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminScheduleDict;
|
||||
|
|
@ -28,8 +28,8 @@ import CustomDatePicker from "../../../components/admin/CustomDatePicker";
|
|||
import CustomTimePicker from "../../../components/admin/CustomTimePicker";
|
||||
import AdminLayout from "../../../components/admin/AdminLayout";
|
||||
import ConfirmDialog from "../../../components/admin/ConfirmDialog";
|
||||
import useAdminAuth from "../../../hooks/useAdminAuth";
|
||||
import useToast from "../../../hooks/useToast";
|
||||
import * as authApi from "../../../api/admin/auth";
|
||||
import * as categoriesApi from "../../../api/admin/categories";
|
||||
import * as schedulesApi from "../../../api/admin/schedules";
|
||||
import { getMembers } from "../../../api/public/members";
|
||||
|
|
@ -38,8 +38,8 @@ function AdminScheduleForm() {
|
|||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEditMode = !!id;
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [members, setMembers] = useState([]);
|
||||
|
|
@ -147,8 +147,12 @@ function AdminScheduleForm() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
if (!authApi.hasToken()) {
|
||||
navigate("/admin");
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(authApi.getCurrentUser());
|
||||
fetchMembers();
|
||||
fetchCategories();
|
||||
|
||||
|
|
@ -156,7 +160,7 @@ function AdminScheduleForm() {
|
|||
if (isEditMode && id) {
|
||||
fetchSchedule();
|
||||
}
|
||||
}, [isAuthenticated, isEditMode, id]);
|
||||
}, [navigate, isEditMode, id]);
|
||||
|
||||
// 기존 일정 데이터 로드 (수정 모드)
|
||||
const fetchSchedule = async () => {
|
||||
|
|
@ -174,8 +178,8 @@ function AdminScheduleForm() {
|
|||
isRange: !!data.end_date,
|
||||
category: data.category_id || "",
|
||||
description: data.description || "",
|
||||
url: data.source?.url || "",
|
||||
sourceName: data.source?.name || "",
|
||||
url: data.source_url || "",
|
||||
sourceName: data.source_name || "",
|
||||
members: data.members?.map((m) => m.id) || [],
|
||||
images: [],
|
||||
locationName: data.location_name || "",
|
||||
|
|
@ -198,8 +202,8 @@ function AdminScheduleForm() {
|
|||
endTime: data.end_time?.slice(0, 5) || "",
|
||||
category: data.category_id || 1,
|
||||
description: data.description || "",
|
||||
url: data.source?.url || "",
|
||||
sourceName: data.source?.name || "",
|
||||
url: data.source_url || "",
|
||||
sourceName: data.source_name || "",
|
||||
members: data.members?.map((m) => m.id) || [],
|
||||
images: data.images.map(img => ({ id: img.id, url: img.image_url })),
|
||||
locationName: data.location_name || "",
|
||||
|
|
|
|||
|
|
@ -1,207 +0,0 @@
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Calendar, MapPin, Clock } from 'lucide-react';
|
||||
import { fetchApi } from '../../../api';
|
||||
|
||||
// 한글 이름 → 영어 이름 매핑
|
||||
const memberEnglishName = {
|
||||
'송하영': 'HAYOUNG',
|
||||
'박지원': 'JIWON',
|
||||
'이채영': 'CHAEYOUNG',
|
||||
'이나경': 'NAKYUNG',
|
||||
'백지헌': 'JIHEON',
|
||||
'장규리': 'GYURI',
|
||||
'이새롬': 'SAEROM',
|
||||
'노지선': 'JISUN',
|
||||
'이서연': 'SEOYEON',
|
||||
};
|
||||
|
||||
function Birthday() {
|
||||
const { memberName, year } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// URL 디코딩
|
||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||
const englishName = memberEnglishName[decodedMemberName];
|
||||
|
||||
// 멤버 정보 조회
|
||||
const { data: member, isLoading: memberLoading, error } = useQuery({
|
||||
queryKey: ['member', decodedMemberName],
|
||||
queryFn: () => fetchApi(`/api/members/${encodeURIComponent(decodedMemberName)}`),
|
||||
enabled: !!decodedMemberName,
|
||||
});
|
||||
|
||||
// 해당 년도 생일카페 정보 조회 (나중에 구현)
|
||||
// const { data: cafes } = useQuery({
|
||||
// queryKey: ['birthdayCafes', decodedMemberName, year],
|
||||
// queryFn: () => fetchApi(`/api/birthday-cafes?member=${encodeURIComponent(decodedMemberName)}&year=${year}`),
|
||||
// });
|
||||
|
||||
if (!decodedMemberName || error) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">멤버를 찾을 수 없습니다</h1>
|
||||
<button
|
||||
onClick={() => navigate('/schedule')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
일정으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memberLoading) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 생일 계산
|
||||
const birthDate = member?.birth_date ? new Date(member.birth_date) : null;
|
||||
const birthdayThisYear = birthDate ? new Date(parseInt(year), birthDate.getMonth(), birthDate.getDate()) : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-gradient-to-b from-pink-50 to-purple-50">
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* 뒤로가기 */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>뒤로가기</span>
|
||||
</button>
|
||||
|
||||
{/* 헤더 카드 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-3xl shadow-xl mb-8"
|
||||
>
|
||||
{/* 배경 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-10 -left-10 w-48 h-48 bg-white/10 rounded-full" />
|
||||
<div className="absolute top-1/3 right-1/4 w-20 h-20 bg-white/5 rounded-full" />
|
||||
<div className="absolute top-6 right-12 text-4xl animate-pulse">✨</div>
|
||||
<div className="absolute bottom-6 left-16 text-3xl animate-pulse delay-300">🎉</div>
|
||||
<div className="absolute top-1/2 right-8 text-2xl animate-bounce">🎈</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center p-8 gap-8">
|
||||
{/* 멤버 사진 */}
|
||||
{member?.image_url && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-32 h-32 rounded-full border-4 border-white/50 shadow-xl overflow-hidden bg-white">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 text-white">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-5xl">🎂</span>
|
||||
<h1 className="font-bold text-4xl tracking-wide">
|
||||
HAPPY {englishName} DAY
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-white/80 text-lg mt-2">
|
||||
{year}년 {birthdayThisYear?.getMonth() + 1}월 {birthdayThisYear?.getDate()}일
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 년도 뱃지 */}
|
||||
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-2xl px-6 py-4 text-center">
|
||||
<div className="text-white/70 text-sm font-medium">YEAR</div>
|
||||
<div className="text-white text-4xl font-bold">{year}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 생일카페 섹션 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-2xl shadow-lg p-8"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<span>☕</span>
|
||||
생일카페
|
||||
</h2>
|
||||
|
||||
{/* 준비 중 메시지 */}
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🎁</div>
|
||||
<p className="text-gray-500 text-lg">
|
||||
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
생일카페 정보가 등록되면 이곳에 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 생일카페 목록 (나중에 구현) */}
|
||||
{/* {cafes?.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{cafes.map((cafe) => (
|
||||
<div key={cafe.id} className="border border-gray-200 rounded-xl p-6">
|
||||
<h3 className="font-bold text-lg mb-3">{cafe.name}</h3>
|
||||
<div className="space-y-2 text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
<span>{cafe.start_date} ~ {cafe.end_date}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} />
|
||||
<span>{cafe.open_time} - {cafe.close_time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={16} />
|
||||
<span>{cafe.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null} */}
|
||||
</motion.div>
|
||||
|
||||
{/* 다른 년도 보기 (나중에 구현) */}
|
||||
{/* <motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mt-6 flex justify-center gap-2"
|
||||
>
|
||||
{[2023, 2024, 2025, 2026].map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => navigate(`/birthday/${encodeURIComponent(decodedMemberName)}/${y}`)}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
parseInt(year) === y
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</motion.div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Birthday;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue