Compare commits

..

No commits in common. "cd70fb18c9a859424c259b2c8e78c01f2fc89336" and "02fe9314e42b604bf58849916652e81ffefd5781" have entirely different histories.

114 changed files with 10930 additions and 15494 deletions

2
.env
View file

@ -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
View file

@ -25,9 +25,3 @@ redis_data/
backend/scrape_*.cjs
backend/scrape_*.js
backend/scrape_*.txt
# Backup
backend-backup/
# Kiwi 모델 파일 (용량 큼, 별도 다운로드 필요)
backend/models/kiwi/models/

View file

@ -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) - 개발/배포 가이드

View file

@ -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"]

View file

@ -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,
);

View file

@ -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);
},
),
],
);

View file

@ -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;

View file

@ -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 [];

View file

@ -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

View file

@ -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),
),
);
},

View file

@ -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(),
),
),
],
],

View file

@ -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"))

View file

@ -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:

View file

@ -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
View 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
View 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
View 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;

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

File diff suppressed because it is too large Load diff

180
backend/routes/albums.js Normal file
View 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
View 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
View 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
View 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
View 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);
}
});

View 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,
};

View 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 };

View 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
View 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,
};

View 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,
};

View 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,
};

View file

@ -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;

View file

@ -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;
}

View file

@ -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,
},
];

View file

@ -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,
},
};

View file

@ -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',
});

View file

@ -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' });

View file

@ -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'],
});

View file

@ -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' });

View file

@ -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'],
});

View file

@ -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();
}
});
}

View file

@ -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();
}
});
}

View file

@ -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();
}
});
}

View file

@ -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 };
});
}

View file

@ -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' });
}

View file

@ -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 });
}
});
}

View file

@ -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;
}

View file

@ -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: '사전 저장 중 오류가 발생했습니다.',
});
}
});
}

View file

@ -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: '통계 조회 실패' });
}
});
}

View file

@ -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();

View file

@ -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}`);
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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'],
});

View file

@ -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;
}

View file

@ -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),
};
}

View file

@ -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'],
});

View file

@ -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
View 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

View file

@ -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
View 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')`

View file

@ -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 스펙

View file

@ -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

View file

@ -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
View 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 동기화**: 기능 추가 시 웹과 앱 모두 구현 필요

View file

@ -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
View 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

View file

@ -1 +1,2 @@
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da
VITE_KAKAO_JS_KEY=5a626e19fbafb33b1eea26f162038ccb
VITE_KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea

View file

@ -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"

View file

@ -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",

View file

@ -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>

View file

@ -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",
});
}

View file

@ -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 }),

View file

@ -5,7 +5,7 @@ import { fetchAdminApi } from "../index";
// 카테고리 목록 조회
export async function getCategories() {
return fetchAdminApi("/api/schedules/categories");
return fetchAdminApi("/api/admin/schedule-categories");
}
// 카테고리 생성

View file

@ -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");
}

View file

@ -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)

View file

@ -1,9 +0,0 @@
/**
* 어드민 통계 API
*/
import { fetchAdminApi } from "../index";
// 대시보드 통계 조회
export async function getStats() {
return fetchAdminApi("/api/stats");
}

View file

@ -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 }),
});
}

View file

@ -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");
}

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>
) : (

View file

@ -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;

View file

@ -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);

View file

@ -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) {

View file

@ -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' });

View file

@ -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 = [
{

View file

@ -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'
}`}
>

View file

@ -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>

View file

@ -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"
>

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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;

View file

@ -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 || "",

View file

@ -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