Compare commits
No commits in common. "aae606725fb01d0257923ed324bf9166b5d63797" and "812478bc37192c7d18e05bff42f9ccfe03493f4e" have entirely different histories.
aae606725f
...
812478bc37
34 changed files with 527 additions and 2441 deletions
|
|
@ -17,8 +17,7 @@
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize">
|
||||||
android:enableOnBackInvokedCallback="false">
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,6 @@ class TrackDetail {
|
||||||
final String? arranger;
|
final String? arranger;
|
||||||
final String? lyrics;
|
final String? lyrics;
|
||||||
final String? musicVideoUrl;
|
final String? musicVideoUrl;
|
||||||
final String? videoType;
|
|
||||||
final TrackAlbum? album;
|
final TrackAlbum? album;
|
||||||
|
|
||||||
TrackDetail({
|
TrackDetail({
|
||||||
|
|
@ -228,7 +227,6 @@ class TrackDetail {
|
||||||
this.arranger,
|
this.arranger,
|
||||||
this.lyrics,
|
this.lyrics,
|
||||||
this.musicVideoUrl,
|
this.musicVideoUrl,
|
||||||
this.videoType,
|
|
||||||
this.album,
|
this.album,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -243,8 +241,7 @@ class TrackDetail {
|
||||||
composer: json['composer'] as String?,
|
composer: json['composer'] as String?,
|
||||||
arranger: json['arranger'] as String?,
|
arranger: json['arranger'] as String?,
|
||||||
lyrics: json['lyrics'] as String?,
|
lyrics: json['lyrics'] as String?,
|
||||||
musicVideoUrl: json['video_url'] as String?,
|
musicVideoUrl: json['music_video_url'] as String?,
|
||||||
videoType: json['video_type'] as String?,
|
|
||||||
album: json['album'] != null ? TrackAlbum.fromJson(json['album']) : null,
|
album: json['album'] != null ? TrackAlbum.fromJson(json['album']) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,17 +45,6 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: AppColors.textPrimary,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(LucideIcons.arrowLeft),
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
title: const Text('앨범', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
body: FutureBuilder<Album>(
|
body: FutureBuilder<Album>(
|
||||||
future: _albumFuture,
|
future: _albumFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|
@ -133,8 +122,27 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
|
||||||
: album.tracks?.take(5).toList();
|
: album.tracks?.take(5).toList();
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
|
||||||
slivers: [
|
slivers: [
|
||||||
|
// 앱바
|
||||||
|
SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'앨범',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 히어로 섹션
|
// 히어로 섹션
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _HeroSection(album: album, formatDate: _formatDate),
|
child: _HeroSection(album: album, formatDate: _formatDate),
|
||||||
|
|
@ -682,7 +690,6 @@ class _TrackItem extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
// 재생 시간
|
// 재생 시간
|
||||||
Text(
|
Text(
|
||||||
track.duration ?? '-',
|
track.duration ?? '-',
|
||||||
|
|
|
||||||
|
|
@ -36,17 +36,6 @@ class _AlbumGalleryViewState extends State<AlbumGalleryView> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: AppColors.textPrimary,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(LucideIcons.arrowLeft),
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
title: const Text('컨셉 포토', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
body: FutureBuilder<Album>(
|
body: FutureBuilder<Album>(
|
||||||
future: _albumFuture,
|
future: _albumFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|
@ -114,8 +103,26 @@ class _AlbumGalleryViewState extends State<AlbumGalleryView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
|
||||||
slivers: [
|
slivers: [
|
||||||
|
// 앱바
|
||||||
|
SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'앨범',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 앨범 헤더 카드
|
// 앨범 헤더 카드
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:omni_video_player/omni_video_player.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
|
|
@ -62,18 +61,7 @@ class _TrackDetailViewState extends State<TrackDetailView> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: AppColors.textPrimary,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0.5,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(LucideIcons.arrowLeft),
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
title: const Text('앨범', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
body: FutureBuilder<TrackDetail>(
|
body: FutureBuilder<TrackDetail>(
|
||||||
future: _trackFuture,
|
future: _trackFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|
@ -105,20 +93,34 @@ class _TrackDetailViewState extends State<TrackDetailView> {
|
||||||
final youtubeVideoId = _getYoutubeVideoId(track.musicVideoUrl);
|
final youtubeVideoId = _getYoutubeVideoId(track.musicVideoUrl);
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
|
||||||
slivers: [
|
slivers: [
|
||||||
|
// 앱바
|
||||||
|
SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'트랙',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 트랙 정보 헤더
|
// 트랙 정보 헤더
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _TrackHeader(track: track),
|
child: _TrackHeader(track: track),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 뮤직비디오 / 스페셜 영상
|
// 뮤직비디오
|
||||||
if (youtubeVideoId != null)
|
if (youtubeVideoId != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _MusicVideoSection(
|
child: _MusicVideoSection(
|
||||||
videoId: youtubeVideoId,
|
videoId: youtubeVideoId,
|
||||||
trackTitle: track.title,
|
trackTitle: track.title,
|
||||||
videoType: track.videoType,
|
|
||||||
onTap: () => _openYoutube(youtubeVideoId),
|
onTap: () => _openYoutube(youtubeVideoId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -255,6 +257,8 @@ class _TrackHeader extends StatelessWidget {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// 재생 시간
|
// 재생 시간
|
||||||
|
|
@ -282,17 +286,15 @@ class _TrackHeader extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 뮤직비디오 / 스페셜 영상 섹션
|
/// 뮤직비디오 섹션
|
||||||
class _MusicVideoSection extends StatelessWidget {
|
class _MusicVideoSection extends StatelessWidget {
|
||||||
final String videoId;
|
final String videoId;
|
||||||
final String trackTitle;
|
final String trackTitle;
|
||||||
final String? videoType;
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _MusicVideoSection({
|
const _MusicVideoSection({
|
||||||
required this.videoId,
|
required this.videoId,
|
||||||
required this.trackTitle,
|
required this.trackTitle,
|
||||||
this.videoType,
|
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -315,9 +317,9 @@ class _MusicVideoSection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
const Text(
|
||||||
videoType == 'special' ? '스페셜 영상' : '뮤직비디오',
|
'뮤직비디오',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -325,36 +327,59 @@ class _MusicVideoSection extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// 영상 플레이어
|
// 썸네일
|
||||||
ClipRRect(
|
GestureDetector(
|
||||||
borderRadius: BorderRadius.circular(12),
|
onTap: onTap,
|
||||||
child: AspectRatio(
|
child: Container(
|
||||||
aspectRatio: 16 / 9,
|
width: double.infinity,
|
||||||
child: OmniVideoPlayer(
|
decoration: BoxDecoration(
|
||||||
configuration: VideoPlayerConfiguration(
|
borderRadius: BorderRadius.circular(12),
|
||||||
videoSourceConfiguration: VideoSourceConfiguration.youtube(
|
boxShadow: [
|
||||||
videoUrl: Uri.parse('https://www.youtube.com/watch?v=$videoId'),
|
BoxShadow(
|
||||||
preferredQualities: [OmniVideoQuality.high720],
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: 'https://img.youtube.com/vi/$videoId/maxresdefault.jpg',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(color: Colors.black),
|
||||||
|
errorWidget: (context, url, error) => CachedNetworkImage(
|
||||||
|
imageUrl: 'https://img.youtube.com/vi/$videoId/hqdefault.jpg',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 재생 버튼 오버레이
|
||||||
|
Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.play,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
callbacks: const VideoPlayerCallbacks(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
// YouTube에서 보기 버튼
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: onTap,
|
|
||||||
icon: const Icon(LucideIcons.youtube, size: 18),
|
|
||||||
label: const Text('YouTube에서 보기', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
elevation: 0,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -521,7 +546,7 @@ class _LyricsSection extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: itemSlide.value,
|
offset: itemSlide.value,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => context.push('/album/${album.folderName}'),
|
onTap: () => context.go('/album/${album.folderName}'),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|
|
||||||
|
|
@ -2,52 +2,23 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/constants.dart';
|
import '../core/constants.dart';
|
||||||
|
|
||||||
/// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠)
|
/// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠)
|
||||||
class MainShell extends StatefulWidget {
|
class MainShell extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const MainShell({super.key, required this.child});
|
const MainShell({super.key, required this.child});
|
||||||
|
|
||||||
@override
|
|
||||||
State<MainShell> createState() => _MainShellState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MainShellState extends State<MainShell> {
|
|
||||||
DateTime? _lastBackPressed;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final child = widget.child;
|
|
||||||
final location = GoRouterState.of(context).uri.path;
|
final location = GoRouterState.of(context).uri.path;
|
||||||
final isMembersPage = location == '/members';
|
final isMembersPage = location == '/members';
|
||||||
final isSchedulePage = location.startsWith('/schedule');
|
final isSchedulePage = location.startsWith('/schedule');
|
||||||
|
|
||||||
return PopScope(
|
return Scaffold(
|
||||||
canPop: false,
|
|
||||||
onPopInvokedWithResult: (bool didPop, dynamic result) {
|
|
||||||
if (didPop) return;
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (_lastBackPressed != null &&
|
|
||||||
now.difference(_lastBackPressed!) < const Duration(seconds: 2)) {
|
|
||||||
SystemNavigator.pop();
|
|
||||||
} else {
|
|
||||||
_lastBackPressed = now;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('한 번 더 누르면 앱이 종료됩니다'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
// 앱바 (툴바) - 일정 페이지는 자체 툴바 사용, 멤버 페이지는 그림자 제거
|
// 앱바 (툴바) - 일정 페이지는 자체 툴바 사용, 멤버 페이지는 그림자 제거
|
||||||
appBar: isSchedulePage
|
appBar: isSchedulePage
|
||||||
|
|
@ -89,7 +60,6 @@ class _MainShellState extends State<MainShell> {
|
||||||
body: child,
|
body: child,
|
||||||
// 바텀 네비게이션
|
// 바텀 네비게이션
|
||||||
bottomNavigationBar: const _BottomNavBar(),
|
bottomNavigationBar: const _BottomNavBar(),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- 예능 일정 상세 테이블
|
|
||||||
CREATE TABLE IF NOT EXISTS schedule_variety (
|
|
||||||
schedule_id INT NOT NULL,
|
|
||||||
broadcaster VARCHAR(100) NOT NULL COMMENT '방송사/플랫폼 (KBS, MBC, 유튜브, 티빙 등)',
|
|
||||||
replay_url VARCHAR(500) DEFAULT NULL COMMENT '다시보기 링크',
|
|
||||||
thumbnail_id INT DEFAULT NULL COMMENT '썸네일 이미지 ID (images 테이블 참조)',
|
|
||||||
PRIMARY KEY (schedule_id),
|
|
||||||
CONSTRAINT fk_variety_schedule FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예능 일정 상세';
|
|
||||||
|
|
||||||
-- 예능 카테고리 추가
|
|
||||||
-- INSERT INTO schedule_categories (name, color, sort_order) VALUES ('예능', '#22c55e', 5);
|
|
||||||
|
|
@ -3,7 +3,6 @@ export const CATEGORY_IDS = {
|
||||||
YOUTUBE: 2,
|
YOUTUBE: 2,
|
||||||
X: 3,
|
X: 3,
|
||||||
CONCERT: 6,
|
CONCERT: 6,
|
||||||
VARIETY: 10,
|
|
||||||
BIRTHDAY: 8,
|
BIRTHDAY: 8,
|
||||||
DEBUT: 9,
|
DEBUT: 9,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,344 +13,6 @@ const CONCERT_CATEGORY_ID = CATEGORY_IDS.CONCERT;
|
||||||
export default async function concertRoutes(fastify) {
|
export default async function concertRoutes(fastify) {
|
||||||
const { db, meilisearch } = fastify;
|
const { db, meilisearch } = fastify;
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/concert/schedule/:seriesId
|
|
||||||
* 콘서트 시리즈 상세 조회 (수정 폼용)
|
|
||||||
*/
|
|
||||||
fastify.get('/schedule/:seriesId', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { seriesId } = request.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 시리즈 기본 정보
|
|
||||||
const [seriesRows] = await db.query(`
|
|
||||||
SELECT cs.id, cs.title, cs.poster_id,
|
|
||||||
i.original_url as poster_original, i.medium_url as poster_medium, i.thumb_url as poster_thumb
|
|
||||||
FROM concert_series cs
|
|
||||||
LEFT JOIN images i ON cs.poster_id = i.id
|
|
||||||
WHERE cs.id = ?
|
|
||||||
`, [seriesId]);
|
|
||||||
|
|
||||||
if (seriesRows.length === 0) {
|
|
||||||
return reply.code(404).send({ error: '콘서트를 찾을 수 없습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const series = seriesRows[0];
|
|
||||||
|
|
||||||
// 회차 정보 (schedules + schedule_concert + venue)
|
|
||||||
const [roundRows] = await db.query(`
|
|
||||||
SELECT s.id as schedule_id, sc.id as concert_id, s.date, s.time,
|
|
||||||
cv.id as venue_id, cv.name as venue_name, cv.country as venue_country,
|
|
||||||
cv.address as venue_address, cv.lat as venue_lat, cv.lng as venue_lng
|
|
||||||
FROM schedule_concert sc
|
|
||||||
JOIN schedules s ON sc.schedule_id = s.id
|
|
||||||
LEFT JOIN concert_venues cv ON sc.venue_id = cv.id
|
|
||||||
WHERE sc.series_id = ?
|
|
||||||
ORDER BY s.date ASC, s.time ASC
|
|
||||||
`, [seriesId]);
|
|
||||||
|
|
||||||
// 멤버 (첫 회차 기준)
|
|
||||||
let memberIds = [];
|
|
||||||
if (roundRows.length > 0) {
|
|
||||||
const [memberRows] = await db.query(
|
|
||||||
'SELECT member_id FROM schedule_members WHERE schedule_id = ?',
|
|
||||||
[roundRows[0].schedule_id]
|
|
||||||
);
|
|
||||||
memberIds = memberRows.map(r => r.member_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회차별 세트리스트
|
|
||||||
const rounds = [];
|
|
||||||
const setlists = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < roundRows.length; i++) {
|
|
||||||
const r = roundRows[i];
|
|
||||||
const roundId = i + 1;
|
|
||||||
|
|
||||||
rounds.push({
|
|
||||||
id: roundId,
|
|
||||||
scheduleId: r.schedule_id,
|
|
||||||
concertId: r.concert_id,
|
|
||||||
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date?.split('T')[0] || '',
|
|
||||||
time: r.time ? r.time.substring(0, 5) : '',
|
|
||||||
venue: r.venue_id ? {
|
|
||||||
id: r.venue_id,
|
|
||||||
name: r.venue_name,
|
|
||||||
country: r.venue_country,
|
|
||||||
address: r.venue_address,
|
|
||||||
lat: r.venue_lat,
|
|
||||||
lng: r.venue_lng,
|
|
||||||
} : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 세트리스트
|
|
||||||
const [setlistRows] = await db.query(`
|
|
||||||
SELECT csl.id, csl.order_num, csl.song_name, csl.album_name
|
|
||||||
FROM concert_setlists csl
|
|
||||||
WHERE csl.concert_id = ?
|
|
||||||
ORDER BY csl.order_num ASC
|
|
||||||
`, [r.concert_id]);
|
|
||||||
|
|
||||||
const songs = [];
|
|
||||||
for (const song of setlistRows) {
|
|
||||||
const [songMembers] = await db.query(
|
|
||||||
'SELECT member_id FROM concert_setlist_members WHERE setlist_id = ?',
|
|
||||||
[song.id]
|
|
||||||
);
|
|
||||||
songs.push({
|
|
||||||
id: song.id,
|
|
||||||
songName: song.song_name,
|
|
||||||
albumName: song.album_name || '',
|
|
||||||
memberIds: songMembers.map(m => m.member_id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setlists[roundId] = songs.length > 0 ? songs : [{ id: 1, songName: '', albumName: '', memberIds: [] }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 굿즈 이미지
|
|
||||||
const [mdRows] = await db.query(`
|
|
||||||
SELECT csm.id, csm.sort_order, i.original_url, i.medium_url, i.thumb_url
|
|
||||||
FROM concert_series_md csm
|
|
||||||
JOIN images i ON csm.image_id = i.id
|
|
||||||
WHERE csm.series_id = ?
|
|
||||||
ORDER BY csm.sort_order ASC
|
|
||||||
`, [seriesId]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: series.id,
|
|
||||||
title: series.title,
|
|
||||||
posterUrl: series.poster_medium || series.poster_original || null,
|
|
||||||
memberIds,
|
|
||||||
rounds,
|
|
||||||
setlists,
|
|
||||||
merchandise: mdRows.map(m => ({
|
|
||||||
id: m.id,
|
|
||||||
originalUrl: m.original_url,
|
|
||||||
mediumUrl: m.medium_url,
|
|
||||||
thumbUrl: m.thumb_url,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`콘서트 조회 오류: ${err.message}`);
|
|
||||||
return serverError(reply, err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/admin/concert/schedule/:seriesId
|
|
||||||
* 콘서트 일정 수정 (multipart/form-data)
|
|
||||||
*/
|
|
||||||
fastify.put('/schedule/:seriesId', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { seriesId } = request.params;
|
|
||||||
const parts = request.parts();
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
let memberIds = [];
|
|
||||||
let rounds = [];
|
|
||||||
let setlists = [];
|
|
||||||
let keepMerchandiseIds = [];
|
|
||||||
let posterBuffer = null;
|
|
||||||
const merchandiseBuffers = [];
|
|
||||||
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.type === 'file') {
|
|
||||||
const buffer = await part.toBuffer();
|
|
||||||
if (part.fieldname === 'poster') {
|
|
||||||
posterBuffer = buffer;
|
|
||||||
} else if (part.fieldname === 'merchandise') {
|
|
||||||
merchandiseBuffers.push(buffer);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (part.fieldname === 'title') title = part.value;
|
|
||||||
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
|
|
||||||
else if (part.fieldname === 'rounds') rounds = JSON.parse(part.value);
|
|
||||||
else if (part.fieldname === 'setlists') setlists = JSON.parse(part.value);
|
|
||||||
else if (part.fieldname === 'setlist') setlists = [JSON.parse(part.value)];
|
|
||||||
else if (part.fieldname === 'keepMerchandiseIds') keepMerchandiseIds = JSON.parse(part.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title?.trim()) {
|
|
||||||
return badRequest(reply, '공연명은 필수입니다.');
|
|
||||||
}
|
|
||||||
if (!rounds || rounds.length === 0) {
|
|
||||||
return badRequest(reply, '최소 1개 이상의 공연 일정이 필요합니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await withTransaction(db, async (conn) => {
|
|
||||||
// 1. 시리즈 업데이트
|
|
||||||
await conn.query('UPDATE concert_series SET title = ? WHERE id = ?', [title.trim(), seriesId]);
|
|
||||||
|
|
||||||
// 2. 포스터 업데이트
|
|
||||||
if (posterBuffer) {
|
|
||||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertPoster(seriesId, posterBuffer);
|
|
||||||
const [existing] = await conn.query('SELECT poster_id FROM concert_series WHERE id = ?', [seriesId]);
|
|
||||||
if (existing[0]?.poster_id) {
|
|
||||||
await conn.query(
|
|
||||||
'UPDATE images SET original_url = ?, medium_url = ?, thumb_url = ? WHERE id = ?',
|
|
||||||
[originalUrl, mediumUrl, thumbUrl, existing[0].poster_id]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [imgResult] = await conn.query(
|
|
||||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
|
||||||
[originalUrl, mediumUrl, thumbUrl]
|
|
||||||
);
|
|
||||||
await conn.query('UPDATE concert_series SET poster_id = ? WHERE id = ?', [imgResult.insertId, seriesId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 기존 회차 관련 데이터 삭제
|
|
||||||
const [existingConcerts] = await conn.query(
|
|
||||||
'SELECT sc.id as concert_id, sc.schedule_id FROM schedule_concert sc WHERE sc.series_id = ?',
|
|
||||||
[seriesId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingConcerts.length > 0) {
|
|
||||||
const concertIds = existingConcerts.map(c => c.concert_id);
|
|
||||||
const scheduleIds = existingConcerts.map(c => c.schedule_id);
|
|
||||||
|
|
||||||
// 세트리스트 멤버 삭제
|
|
||||||
const [setlistRows] = await conn.query(
|
|
||||||
'SELECT id FROM concert_setlists WHERE concert_id IN (?)', [concertIds]
|
|
||||||
);
|
|
||||||
if (setlistRows.length > 0) {
|
|
||||||
await conn.query('DELETE FROM concert_setlist_members WHERE setlist_id IN (?)', [setlistRows.map(s => s.id)]);
|
|
||||||
}
|
|
||||||
await conn.query('DELETE FROM concert_setlists WHERE concert_id IN (?)', [concertIds]);
|
|
||||||
await conn.query('DELETE FROM schedule_members WHERE schedule_id IN (?)', [scheduleIds]);
|
|
||||||
await conn.query('DELETE FROM schedule_concert WHERE series_id = ?', [seriesId]);
|
|
||||||
await conn.query('DELETE FROM schedules WHERE id IN (?)', [scheduleIds]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 회차 재생성
|
|
||||||
const newScheduleIds = [];
|
|
||||||
const newConcertIds = [];
|
|
||||||
|
|
||||||
for (const round of rounds) {
|
|
||||||
let venueId = null;
|
|
||||||
if (round.venueId) {
|
|
||||||
venueId = round.venueId;
|
|
||||||
} else if (round.venueName) {
|
|
||||||
const [venueResult] = await conn.query(
|
|
||||||
'INSERT INTO concert_venues (name, country, address, lat, lng) VALUES (?, ?, ?, ?, ?)',
|
|
||||||
[round.venueName, round.venueCountry || null, round.venueAddress || null, round.venueLat || null, round.venueLng || null]
|
|
||||||
);
|
|
||||||
venueId = venueResult.insertId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [scheduleResult] = await conn.query(
|
|
||||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
||||||
[CONCERT_CATEGORY_ID, title.trim(), round.date, round.time || null]
|
|
||||||
);
|
|
||||||
const scheduleId = scheduleResult.insertId;
|
|
||||||
newScheduleIds.push(scheduleId);
|
|
||||||
|
|
||||||
const [concertResult] = await conn.query(
|
|
||||||
'INSERT INTO schedule_concert (schedule_id, series_id, venue_id) VALUES (?, ?, ?)',
|
|
||||||
[scheduleId, seriesId, venueId]
|
|
||||||
);
|
|
||||||
newConcertIds.push(concertResult.insertId);
|
|
||||||
|
|
||||||
if (memberIds.length > 0) {
|
|
||||||
const values = memberIds.map(memberId => [scheduleId, memberId]);
|
|
||||||
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 회차별 세트리스트 재생성
|
|
||||||
for (let roundIdx = 0; roundIdx < newConcertIds.length; roundIdx++) {
|
|
||||||
const concertId = newConcertIds[roundIdx];
|
|
||||||
const roundSetlist = setlists[roundIdx] || setlists[0] || [];
|
|
||||||
|
|
||||||
for (let i = 0; i < roundSetlist.length; i++) {
|
|
||||||
const song = roundSetlist[i];
|
|
||||||
if (!song.songName?.trim()) continue;
|
|
||||||
|
|
||||||
const [setlistResult] = await conn.query(
|
|
||||||
'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)',
|
|
||||||
[concertId, i + 1, song.songName.trim(), song.albumName?.trim() || null]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (song.memberIds?.length > 0) {
|
|
||||||
const memberValues = song.memberIds.map(memberId => [setlistResult.insertId, memberId]);
|
|
||||||
await conn.query('INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?', [memberValues]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 굿즈 관리 (유지할 것 외 삭제 + 새 파일 추가)
|
|
||||||
const [existingMd] = await conn.query(
|
|
||||||
'SELECT id, image_id FROM concert_series_md WHERE series_id = ?', [seriesId]
|
|
||||||
);
|
|
||||||
const keepSet = new Set(keepMerchandiseIds);
|
|
||||||
const toDelete = existingMd.filter(m => !keepSet.has(m.id));
|
|
||||||
|
|
||||||
for (const md of toDelete) {
|
|
||||||
await conn.query('DELETE FROM concert_series_md WHERE id = ?', [md.id]);
|
|
||||||
await conn.query('DELETE FROM images WHERE id = ?', [md.image_id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 유지된 항목 순서 업데이트
|
|
||||||
let sortOrder = 1;
|
|
||||||
for (const keepId of keepMerchandiseIds) {
|
|
||||||
await conn.query('UPDATE concert_series_md SET sort_order = ? WHERE id = ?', [sortOrder++, keepId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새 굿즈 추가
|
|
||||||
for (const buffer of merchandiseBuffers) {
|
|
||||||
const filename = `${String(sortOrder).padStart(2, '0')}.webp`;
|
|
||||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertMerchandise(seriesId, filename, buffer);
|
|
||||||
const [imgResult] = await conn.query(
|
|
||||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
|
||||||
[originalUrl, mediumUrl, thumbUrl]
|
|
||||||
);
|
|
||||||
await conn.query(
|
|
||||||
'INSERT INTO concert_series_md (series_id, image_id, sort_order) VALUES (?, ?, ?)',
|
|
||||||
[seriesId, imgResult.insertId, sortOrder++]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { scheduleIds: newScheduleIds };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Meilisearch 동기화
|
|
||||||
const [categoryRows] = await db.query('SELECT name, color FROM schedule_categories WHERE id = ?', [CONCERT_CATEGORY_ID]);
|
|
||||||
const category = categoryRows[0] || {};
|
|
||||||
let memberNames = '';
|
|
||||||
if (memberIds.length > 0) {
|
|
||||||
const [members] = await db.query('SELECT name FROM members WHERE id IN (?) ORDER BY id', [memberIds]);
|
|
||||||
memberNames = members.map(m => m.name).join(',');
|
|
||||||
}
|
|
||||||
for (const scheduleId of result.scheduleIds) {
|
|
||||||
const [scheduleRows] = await db.query('SELECT title, date, time FROM schedules WHERE id = ?', [scheduleId]);
|
|
||||||
const s = scheduleRows[0];
|
|
||||||
if (s) {
|
|
||||||
await addOrUpdateSchedule(meilisearch, {
|
|
||||||
id: scheduleId,
|
|
||||||
title: s.title,
|
|
||||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date,
|
|
||||||
time: s.time || '',
|
|
||||||
category_id: CONCERT_CATEGORY_ID,
|
|
||||||
category_name: category.name || '',
|
|
||||||
category_color: category.color || '',
|
|
||||||
member_names: memberNames,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'update', category: 'concert', targetType: 'concert', targetId: parseInt(seriesId), summary: `콘서트 일정 수정: ${title}` });
|
|
||||||
return { success: true, seriesId: parseInt(seriesId) };
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`콘서트 수정 오류: ${err.message}`);
|
|
||||||
return serverError(reply, err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/concert/schedule
|
* POST /api/admin/concert/schedule
|
||||||
* 콘서트 일정 저장 (multipart/form-data)
|
* 콘서트 일정 저장 (multipart/form-data)
|
||||||
|
|
@ -364,7 +26,7 @@ export default async function concertRoutes(fastify) {
|
||||||
let title = '';
|
let title = '';
|
||||||
let memberIds = [];
|
let memberIds = [];
|
||||||
let rounds = [];
|
let rounds = [];
|
||||||
let setlists = []; // 회차별 세트리스트 (배열의 배열)
|
let setlist = [];
|
||||||
let posterBuffer = null;
|
let posterBuffer = null;
|
||||||
const merchandiseBuffers = [];
|
const merchandiseBuffers = [];
|
||||||
|
|
||||||
|
|
@ -381,8 +43,7 @@ export default async function concertRoutes(fastify) {
|
||||||
if (part.fieldname === 'title') title = part.value;
|
if (part.fieldname === 'title') title = part.value;
|
||||||
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
|
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
|
||||||
else if (part.fieldname === 'rounds') rounds = JSON.parse(part.value);
|
else if (part.fieldname === 'rounds') rounds = JSON.parse(part.value);
|
||||||
else if (part.fieldname === 'setlists') setlists = JSON.parse(part.value);
|
else if (part.fieldname === 'setlist') setlist = JSON.parse(part.value);
|
||||||
else if (part.fieldname === 'setlist') setlists = [JSON.parse(part.value)]; // 하위호환
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,29 +125,26 @@ export default async function concertRoutes(fastify) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 회차별 세트리스트 저장
|
// 4. 세트리스트 (첫 번째 concert_id 기준으로 저장)
|
||||||
for (let roundIdx = 0; roundIdx < concertIds.length; roundIdx++) {
|
const primaryConcertId = concertIds[0];
|
||||||
const concertId = concertIds[roundIdx];
|
|
||||||
const roundSetlist = setlists[roundIdx] || setlists[0] || [];
|
|
||||||
|
|
||||||
for (let i = 0; i < roundSetlist.length; i++) {
|
for (let i = 0; i < setlist.length; i++) {
|
||||||
const song = roundSetlist[i];
|
const song = setlist[i];
|
||||||
if (!song.songName || !song.songName.trim()) continue;
|
if (!song.songName || !song.songName.trim()) continue;
|
||||||
|
|
||||||
const [setlistResult] = await conn.query(
|
const [setlistResult] = await conn.query(
|
||||||
'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)',
|
'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)',
|
||||||
[concertId, i + 1, song.songName.trim(), song.albumName?.trim() || null]
|
[primaryConcertId, i + 1, song.songName.trim(), song.albumName?.trim() || null]
|
||||||
|
);
|
||||||
|
const setlistId = setlistResult.insertId;
|
||||||
|
|
||||||
|
// 곡별 멤버
|
||||||
|
if (song.memberIds && song.memberIds.length > 0) {
|
||||||
|
const memberValues = song.memberIds.map(memberId => [setlistId, memberId]);
|
||||||
|
await conn.query(
|
||||||
|
'INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?',
|
||||||
|
[memberValues]
|
||||||
);
|
);
|
||||||
const setlistId = setlistResult.insertId;
|
|
||||||
|
|
||||||
// 곡별 멤버
|
|
||||||
if (song.memberIds && song.memberIds.length > 0) {
|
|
||||||
const memberValues = song.memberIds.map(memberId => [setlistId, memberId]);
|
|
||||||
await conn.query(
|
|
||||||
'INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?',
|
|
||||||
[memberValues]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
import { CATEGORY_IDS } from '../../config/index.js';
|
|
||||||
import { uploadVarietyThumbnail } from '../../services/image.js';
|
|
||||||
import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js';
|
|
||||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
|
||||||
import { logActivity } from '../../utils/log.js';
|
|
||||||
|
|
||||||
const VARIETY_CATEGORY_ID = CATEGORY_IDS.VARIETY;
|
|
||||||
const BROADCASTER_KEY = 'variety:broadcasters';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 관련 관리자 라우트
|
|
||||||
*/
|
|
||||||
export default async function varietyRoutes(fastify) {
|
|
||||||
const { db, meilisearch, redis } = fastify;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/variety/broadcasters
|
|
||||||
* 자주 사용된 방송사/플랫폼 목록 (상위 10개)
|
|
||||||
*/
|
|
||||||
fastify.get('/broadcasters', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async () => {
|
|
||||||
// Redis에 캐시가 있으면 사용
|
|
||||||
const cached = await redis.get(BROADCASTER_KEY);
|
|
||||||
if (cached) {
|
|
||||||
return JSON.parse(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB에서 빈도수 조회
|
|
||||||
const [rows] = await db.query(
|
|
||||||
`SELECT broadcaster, COUNT(*) as cnt
|
|
||||||
FROM schedule_variety
|
|
||||||
GROUP BY broadcaster
|
|
||||||
ORDER BY cnt DESC
|
|
||||||
LIMIT 10`
|
|
||||||
);
|
|
||||||
|
|
||||||
const broadcasters = rows.map(r => r.broadcaster);
|
|
||||||
|
|
||||||
// Redis 캐시 (1시간)
|
|
||||||
await redis.setex(BROADCASTER_KEY, 3600, JSON.stringify(broadcasters));
|
|
||||||
|
|
||||||
return broadcasters;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/admin/variety/schedule
|
|
||||||
* 예능 일정 저장 (multipart/form-data)
|
|
||||||
*/
|
|
||||||
fastify.post('/schedule', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const parts = request.parts();
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
let date = '';
|
|
||||||
let time = null;
|
|
||||||
let broadcaster = '';
|
|
||||||
let replayUrl = null;
|
|
||||||
let memberIds = [];
|
|
||||||
let thumbnailBuffer = null;
|
|
||||||
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.type === 'file' && part.fieldname === 'thumbnail') {
|
|
||||||
thumbnailBuffer = await part.toBuffer();
|
|
||||||
} else if (part.type === 'field') {
|
|
||||||
if (part.fieldname === 'title') title = part.value;
|
|
||||||
else if (part.fieldname === 'date') date = part.value;
|
|
||||||
else if (part.fieldname === 'time') time = part.value || null;
|
|
||||||
else if (part.fieldname === 'broadcaster') broadcaster = part.value;
|
|
||||||
else if (part.fieldname === 'replayUrl') replayUrl = part.value || null;
|
|
||||||
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title?.trim()) return badRequest(reply, '프로그램명은 필수입니다.');
|
|
||||||
if (!date) return badRequest(reply, '날짜는 필수입니다.');
|
|
||||||
if (!broadcaster?.trim()) return badRequest(reply, '방송사/플랫폼은 필수입니다.');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// schedules 테이블
|
|
||||||
const [scheduleResult] = await db.query(
|
|
||||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
||||||
[VARIETY_CATEGORY_ID, title.trim(), date, time]
|
|
||||||
);
|
|
||||||
const scheduleId = scheduleResult.insertId;
|
|
||||||
|
|
||||||
// 썸네일 업로드
|
|
||||||
let thumbnailId = null;
|
|
||||||
if (thumbnailBuffer && thumbnailBuffer.length > 0) {
|
|
||||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadVarietyThumbnail(scheduleId, thumbnailBuffer);
|
|
||||||
const [imgResult] = await db.query(
|
|
||||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
|
||||||
[originalUrl, mediumUrl, thumbUrl]
|
|
||||||
);
|
|
||||||
thumbnailId = imgResult.insertId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// schedule_variety 테이블
|
|
||||||
await db.query(
|
|
||||||
'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)',
|
|
||||||
[scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// schedule_members 테이블
|
|
||||||
if (memberIds.length > 0) {
|
|
||||||
const values = memberIds.map(memberId => [scheduleId, memberId]);
|
|
||||||
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meilisearch 동기화
|
|
||||||
const [categoryRows] = await db.query('SELECT name, color FROM schedule_categories WHERE id = ?', [VARIETY_CATEGORY_ID]);
|
|
||||||
const category = categoryRows[0] || {};
|
|
||||||
let memberNames = '';
|
|
||||||
if (memberIds.length > 0) {
|
|
||||||
const [members] = await db.query('SELECT name FROM members WHERE id IN (?) ORDER BY id', [memberIds]);
|
|
||||||
memberNames = members.map(m => m.name).join(',');
|
|
||||||
}
|
|
||||||
await addOrUpdateSchedule(meilisearch, {
|
|
||||||
id: scheduleId,
|
|
||||||
title: title.trim(),
|
|
||||||
date,
|
|
||||||
time: time || '',
|
|
||||||
category_id: VARIETY_CATEGORY_ID,
|
|
||||||
category_name: category.name || '',
|
|
||||||
category_color: category.color || '',
|
|
||||||
member_names: memberNames,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 방송사 캐시 무효화
|
|
||||||
await redis.del(BROADCASTER_KEY);
|
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'variety_schedule', targetId: scheduleId, summary: `예능 일정 생성: ${title.trim()}` });
|
|
||||||
return { success: true, scheduleId };
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`예능 일정 저장 오류: ${err.message}`);
|
|
||||||
return serverError(reply, err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/admin/variety/schedule/:id
|
|
||||||
* 예능 일정 수정 (multipart/form-data)
|
|
||||||
*/
|
|
||||||
fastify.put('/schedule/:id', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params;
|
|
||||||
const parts = request.parts();
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
let date = '';
|
|
||||||
let time = null;
|
|
||||||
let broadcaster = '';
|
|
||||||
let replayUrl = null;
|
|
||||||
let memberIds = [];
|
|
||||||
let thumbnailBuffer = null;
|
|
||||||
let removeThumbnail = false;
|
|
||||||
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.type === 'file' && part.fieldname === 'thumbnail') {
|
|
||||||
thumbnailBuffer = await part.toBuffer();
|
|
||||||
} else if (part.type === 'field') {
|
|
||||||
if (part.fieldname === 'title') title = part.value;
|
|
||||||
else if (part.fieldname === 'date') date = part.value;
|
|
||||||
else if (part.fieldname === 'time') time = part.value || null;
|
|
||||||
else if (part.fieldname === 'broadcaster') broadcaster = part.value;
|
|
||||||
else if (part.fieldname === 'replayUrl') replayUrl = part.value || null;
|
|
||||||
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
|
|
||||||
else if (part.fieldname === 'removeThumbnail') removeThumbnail = part.value === 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title?.trim()) return badRequest(reply, '프로그램명은 필수입니다.');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
|
||||||
if (existing.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
|
|
||||||
|
|
||||||
// schedules 업데이트
|
|
||||||
await db.query('UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', [title.trim(), date, time, id]);
|
|
||||||
|
|
||||||
// 기존 variety 데이터 조회
|
|
||||||
const [varietyRows] = await db.query('SELECT thumbnail_id FROM schedule_variety WHERE schedule_id = ?', [id]);
|
|
||||||
let thumbnailId = varietyRows[0]?.thumbnail_id || null;
|
|
||||||
|
|
||||||
// 썸네일 업데이트
|
|
||||||
if (thumbnailBuffer && thumbnailBuffer.length > 0) {
|
|
||||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadVarietyThumbnail(id, thumbnailBuffer);
|
|
||||||
if (thumbnailId) {
|
|
||||||
await db.query('UPDATE images SET original_url = ?, medium_url = ?, thumb_url = ? WHERE id = ?', [originalUrl, mediumUrl, thumbUrl, thumbnailId]);
|
|
||||||
} else {
|
|
||||||
const [imgResult] = await db.query('INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', [originalUrl, mediumUrl, thumbUrl]);
|
|
||||||
thumbnailId = imgResult.insertId;
|
|
||||||
}
|
|
||||||
} else if (removeThumbnail && thumbnailId) {
|
|
||||||
await db.query('DELETE FROM images WHERE id = ?', [thumbnailId]);
|
|
||||||
thumbnailId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// schedule_variety upsert
|
|
||||||
if (varietyRows.length > 0) {
|
|
||||||
await db.query('UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_id = ? WHERE schedule_id = ?',
|
|
||||||
[broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId, id]);
|
|
||||||
} else {
|
|
||||||
await db.query('INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)',
|
|
||||||
[id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 멤버 업데이트
|
|
||||||
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
|
||||||
if (memberIds.length > 0) {
|
|
||||||
const values = memberIds.map(memberId => [id, memberId]);
|
|
||||||
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncScheduleById(meilisearch, db, parseInt(id));
|
|
||||||
await redis.del(BROADCASTER_KEY);
|
|
||||||
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` });
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`예능 일정 수정 오류: ${err.message}`);
|
|
||||||
return serverError(reply, err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/variety/schedule/:id
|
|
||||||
* 예능 일정 상세 조회 (수정 폼용)
|
|
||||||
*/
|
|
||||||
fastify.get('/schedule/:id', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows] = await db.query(`
|
|
||||||
SELECT s.id, s.title, s.date, s.time,
|
|
||||||
sv.broadcaster, sv.replay_url, sv.thumbnail_id,
|
|
||||||
i.original_url as thumb_original, i.medium_url as thumb_medium, i.thumb_url as thumb_thumb
|
|
||||||
FROM schedules s
|
|
||||||
LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id
|
|
||||||
LEFT JOIN images i ON sv.thumbnail_id = i.id
|
|
||||||
WHERE s.id = ?
|
|
||||||
`, [id]);
|
|
||||||
|
|
||||||
if (rows.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
|
|
||||||
|
|
||||||
const s = rows[0];
|
|
||||||
const [memberRows] = await db.query('SELECT member_id FROM schedule_members WHERE schedule_id = ?', [id]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: s.id,
|
|
||||||
title: s.title,
|
|
||||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0] || '',
|
|
||||||
time: s.time ? s.time.substring(0, 5) : '',
|
|
||||||
broadcaster: s.broadcaster || '',
|
|
||||||
replayUrl: s.replay_url || '',
|
|
||||||
thumbnailUrl: s.thumb_medium || s.thumb_original || '',
|
|
||||||
memberIds: memberRows.map(r => r.member_id),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`예능 일정 조회 오류: ${err.message}`);
|
|
||||||
return serverError(reply, err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
deleteAlbumPhoto,
|
deleteAlbumPhoto,
|
||||||
uploadAlbumVideo,
|
uploadAlbumVideo,
|
||||||
} from '../../services/image.js';
|
} from '../../services/image.js';
|
||||||
import { invalidateAlbumCache } from '../../services/album.js';
|
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { notFound } from '../../utils/error.js';
|
import { notFound } from '../../utils/error.js';
|
||||||
import { logActivity } from '../../utils/log.js';
|
import { logActivity } from '../../utils/log.js';
|
||||||
|
|
@ -13,7 +12,7 @@ import { logActivity } from '../../utils/log.js';
|
||||||
* GET: 공개, POST/DELETE: 인증 필요
|
* GET: 공개, POST/DELETE: 인증 필요
|
||||||
*/
|
*/
|
||||||
export default async function photosRoutes(fastify) {
|
export default async function photosRoutes(fastify) {
|
||||||
const { db, redis } = fastify;
|
const { db } = fastify;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/albums/:albumId/photos
|
* GET /api/albums/:albumId/photos
|
||||||
|
|
@ -197,9 +196,6 @@ export default async function photosRoutes(fastify) {
|
||||||
|
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
|
||||||
// 앨범 캐시 무효화
|
|
||||||
await invalidateAlbumCache(redis, parseInt(albumId));
|
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'upload', category: 'album', targetType: 'photo', targetId: parseInt(albumId), summary: `사진 업로드: ${uploadedPhotos.length}장 (앨범 ${albumId})` });
|
logActivity(db, { actor: 'admin', action: 'upload', category: 'album', targetType: 'photo', targetId: parseInt(albumId), summary: `사진 업로드: ${uploadedPhotos.length}장 (앨범 ${albumId})` });
|
||||||
|
|
||||||
reply.raw.write(`data: ${JSON.stringify({
|
reply.raw.write(`data: ${JSON.stringify({
|
||||||
|
|
@ -252,9 +248,6 @@ export default async function photosRoutes(fastify) {
|
||||||
await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]);
|
await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]);
|
||||||
await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]);
|
await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]);
|
||||||
|
|
||||||
// 앨범 캐시 무효화
|
|
||||||
await invalidateAlbumCache(redis, parseInt(albumId));
|
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'photo', targetId: parseInt(photoId), summary: `사진 삭제: 앨범 ${albumId}` });
|
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'photo', targetId: parseInt(photoId), summary: `사진 삭제: 앨범 ${albumId}` });
|
||||||
return { message: '사진이 삭제되었습니다.' };
|
return { message: '사진이 삭제되었습니다.' };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import {
|
||||||
deleteAlbumPhoto,
|
deleteAlbumPhoto,
|
||||||
deleteAlbumVideo,
|
deleteAlbumVideo,
|
||||||
} from '../../services/image.js';
|
} from '../../services/image.js';
|
||||||
import { invalidateAlbumCache } from '../../services/album.js';
|
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
import { notFound } from '../../utils/error.js';
|
import { notFound } from '../../utils/error.js';
|
||||||
import { logActivity } from '../../utils/log.js';
|
import { logActivity } from '../../utils/log.js';
|
||||||
|
|
@ -12,7 +11,7 @@ import { logActivity } from '../../utils/log.js';
|
||||||
* GET: 공개, DELETE: 인증 필요
|
* GET: 공개, DELETE: 인증 필요
|
||||||
*/
|
*/
|
||||||
export default async function teasersRoutes(fastify) {
|
export default async function teasersRoutes(fastify) {
|
||||||
const { db, redis } = fastify;
|
const { db } = fastify;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/albums/:albumId/teasers
|
* GET /api/albums/:albumId/teasers
|
||||||
|
|
@ -80,8 +79,6 @@ export default async function teasersRoutes(fastify) {
|
||||||
|
|
||||||
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
|
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
|
||||||
|
|
||||||
await invalidateAlbumCache(redis, parseInt(albumId));
|
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'teaser', targetId: parseInt(teaserId), summary: `티저 삭제: 앨범 ${albumId}` });
|
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'teaser', targetId: parseInt(teaserId), summary: `티저 삭제: 앨범 ${albumId}` });
|
||||||
return { message: '티저가 삭제되었습니다.' };
|
return { message: '티저가 삭제되었습니다.' };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import xBotsRoutes from './admin/x-bots.js';
|
||||||
import youtubeAdminRoutes from './admin/youtube.js';
|
import youtubeAdminRoutes from './admin/youtube.js';
|
||||||
import xAdminRoutes from './admin/x.js';
|
import xAdminRoutes from './admin/x.js';
|
||||||
import concertAdminRoutes from './admin/concert.js';
|
import concertAdminRoutes from './admin/concert.js';
|
||||||
import varietyAdminRoutes from './admin/variety.js';
|
|
||||||
import placesAdminRoutes from './admin/places.js';
|
import placesAdminRoutes from './admin/places.js';
|
||||||
import logsAdminRoutes from './admin/logs.js';
|
import logsAdminRoutes from './admin/logs.js';
|
||||||
|
|
||||||
|
|
@ -51,9 +50,6 @@ export default async function routes(fastify) {
|
||||||
// 관리자 - 콘서트 라우트
|
// 관리자 - 콘서트 라우트
|
||||||
fastify.register(concertAdminRoutes, { prefix: '/admin/concert' });
|
fastify.register(concertAdminRoutes, { prefix: '/admin/concert' });
|
||||||
|
|
||||||
// 관리자 - 예능 라우트
|
|
||||||
fastify.register(varietyAdminRoutes, { prefix: '/admin/variety' });
|
|
||||||
|
|
||||||
// 관리자 - 장소 검색 라우트
|
// 관리자 - 장소 검색 라우트
|
||||||
fastify.register(placesAdminRoutes, { prefix: '/admin' });
|
fastify.register(placesAdminRoutes, { prefix: '/admin' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -256,23 +256,3 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) {
|
||||||
|
|
||||||
return { originalUrl, mediumUrl, thumbUrl };
|
return { originalUrl, mediumUrl, thumbUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 썸네일 업로드
|
|
||||||
* @param {number} scheduleId - 일정 ID
|
|
||||||
* @param {Buffer} buffer - 이미지 버퍼
|
|
||||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
|
||||||
*/
|
|
||||||
export async function uploadVarietyThumbnail(scheduleId, buffer) {
|
|
||||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
|
||||||
|
|
||||||
const basePath = `schedule/${scheduleId}/thumbnail`;
|
|
||||||
|
|
||||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
|
||||||
uploadToS3(`${basePath}/original/thumbnail.webp`, originalBuffer),
|
|
||||||
uploadToS3(`${basePath}/medium_800/thumbnail.webp`, mediumBuffer),
|
|
||||||
uploadToS3(`${basePath}/thumb_400/thumbnail.webp`, thumbBuffer),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { originalUrl, mediumUrl, thumbUrl };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export function buildSource(schedule) {
|
||||||
* @returns {object} 포맷된 일정 객체
|
* @returns {object} 포맷된 일정 객체
|
||||||
*/
|
*/
|
||||||
export function formatSchedule(rawSchedule, members = []) {
|
export function formatSchedule(rawSchedule, members = []) {
|
||||||
const result = {
|
return {
|
||||||
id: rawSchedule.id,
|
id: rawSchedule.id,
|
||||||
title: rawSchedule.title,
|
title: rawSchedule.title,
|
||||||
date: normalizeDate(rawSchedule.date),
|
date: normalizeDate(rawSchedule.date),
|
||||||
|
|
@ -88,10 +88,6 @@ export function formatSchedule(rawSchedule, members = []) {
|
||||||
source: buildSource(rawSchedule),
|
source: buildSource(rawSchedule),
|
||||||
members,
|
members,
|
||||||
};
|
};
|
||||||
if (rawSchedule.concert_series_id) {
|
|
||||||
result.concertSeriesId = rawSchedule.concert_series_id;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -200,16 +196,11 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
sx.post_id as x_post_id,
|
sx.post_id as x_post_id,
|
||||||
sx.username as x_username,
|
sx.username as x_username,
|
||||||
sx.content as x_content,
|
sx.content as x_content,
|
||||||
sx.image_urls as x_image_urls,
|
sx.image_urls as x_image_urls
|
||||||
sv.broadcaster as variety_broadcaster,
|
|
||||||
sv.replay_url as variety_replay_url,
|
|
||||||
svi.medium_url as variety_thumbnail_url
|
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
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_youtube sy ON s.id = sy.schedule_id
|
||||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||||
LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id
|
|
||||||
LEFT JOIN images svi ON sv.thumbnail_id = svi.id
|
|
||||||
WHERE s.id = ?
|
WHERE s.id = ?
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|
||||||
|
|
@ -284,10 +275,6 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (s.category_id === CATEGORY_IDS.VARIETY && s.variety_broadcaster) {
|
|
||||||
result.broadcaster = s.variety_broadcaster;
|
|
||||||
result.replayUrl = s.variety_replay_url || null;
|
|
||||||
result.thumbnailUrl = s.variety_thumbnail_url || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -309,13 +296,11 @@ const SCHEDULE_LIST_SQL = `
|
||||||
sy.video_id as youtube_video_id,
|
sy.video_id as youtube_video_id,
|
||||||
sy.video_type as youtube_video_type,
|
sy.video_type as youtube_video_type,
|
||||||
sx.post_id as x_post_id,
|
sx.post_id as x_post_id,
|
||||||
sx.username as x_username,
|
sx.username as x_username
|
||||||
scon.series_id as concert_series_id
|
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
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_youtube sy ON s.id = sy.schedule_id
|
||||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||||
LEFT JOIN schedule_concert scon ON s.id = scon.schedule_id
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
/**
|
/**
|
||||||
* 콘서트 관리자 API
|
* 콘서트 관리자 API
|
||||||
*/
|
*/
|
||||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
import { fetchFormData } from '@/api/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘서트 일정 생성
|
* 콘서트 일정 생성
|
||||||
|
* @param {FormData} formData - 콘서트 데이터
|
||||||
|
* @returns {Promise<{success: boolean, seriesId: number}>}
|
||||||
*/
|
*/
|
||||||
export async function createConcertSchedule(formData) {
|
export async function createConcertSchedule(formData) {
|
||||||
return fetchFormData('/admin/concert/schedule', formData, 'POST');
|
return fetchFormData('/admin/concert/schedule', formData, 'POST');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘서트 시리즈 상세 조회 (수정 폼용)
|
|
||||||
*/
|
|
||||||
export async function getConcertSchedule(seriesId) {
|
|
||||||
return fetchAuthApi(`/admin/concert/schedule/${seriesId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘서트 일정 수정
|
|
||||||
*/
|
|
||||||
export async function updateConcertSchedule(seriesId, formData) {
|
|
||||||
return fetchFormData(`/admin/concert/schedule/${seriesId}`, formData, 'PUT');
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* 예능 관리자 API
|
|
||||||
*/
|
|
||||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 생성
|
|
||||||
*/
|
|
||||||
export async function createVarietySchedule(formData) {
|
|
||||||
return fetchFormData('/admin/variety/schedule', formData, 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 상세 조회
|
|
||||||
*/
|
|
||||||
export async function getVarietySchedule(id) {
|
|
||||||
return fetchAuthApi(`/admin/variety/schedule/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 수정
|
|
||||||
*/
|
|
||||||
export async function updateVarietySchedule(id, formData) {
|
|
||||||
return fetchFormData(`/admin/variety/schedule/${id}`, formData, 'PUT');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자주 사용된 방송사/플랫폼 목록
|
|
||||||
*/
|
|
||||||
export async function getBroadcasters() {
|
|
||||||
return fetchAuthApi('/admin/variety/broadcasters');
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,23 @@
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모바일 헤더 컴포넌트
|
* 모바일 헤더 컴포넌트
|
||||||
* @param {string} title - 페이지 제목 (없으면 fromis_9)
|
* @param {string} title - 페이지 제목 (없으면 fromis_9)
|
||||||
* @param {boolean} noShadow - 그림자 숨김 여부
|
* @param {boolean} noShadow - 그림자 숨김 여부
|
||||||
* @param {boolean} showBack - 뒤로가기 버튼 표시 여부
|
|
||||||
*/
|
*/
|
||||||
function MobileHeader({ title, noShadow = false, showBack = false }) {
|
function MobileHeader({ title, noShadow = false }) {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}
|
className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center h-14 px-2">
|
<div className="flex items-center justify-center h-14 px-4">
|
||||||
{showBack ? (
|
{title ? (
|
||||||
<button onClick={() => window.history.back()} className="p-2 rounded-lg active:bg-gray-100 text-gray-600">
|
<span className="text-xl font-bold text-primary">{title}</span>
|
||||||
<ChevronLeft size={22} />
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-9" />
|
<NavLink to="/" className="text-xl font-bold text-primary">
|
||||||
|
fromis_9
|
||||||
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 text-center">
|
|
||||||
{title ? (
|
|
||||||
<span className="text-xl font-bold text-primary">{title}</span>
|
|
||||||
) : (
|
|
||||||
<NavLink to="/" className="text-xl font-bold text-primary">
|
|
||||||
fromis_9
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-9" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ function MobileLayout({
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
useCustomLayout = false,
|
useCustomLayout = false,
|
||||||
noShadow = false,
|
noShadow = false,
|
||||||
showBack = false,
|
|
||||||
}) {
|
}) {
|
||||||
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -39,7 +38,7 @@ function MobileLayout({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mobile-layout-container bg-white">
|
<div className="mobile-layout-container bg-white">
|
||||||
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} showBack={showBack} />}
|
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
||||||
<main className="mobile-content">{children}</main>
|
<main className="mobile-content">{children}</main>
|
||||||
<MobileBottomNav />
|
<MobileBottomNav />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,12 @@ import {
|
||||||
/**
|
/**
|
||||||
* 카테고리별 수정 경로 반환
|
* 카테고리별 수정 경로 반환
|
||||||
*/
|
*/
|
||||||
export const getEditPath = (scheduleId, categoryName, schedule) => {
|
export const getEditPath = (scheduleId, categoryName) => {
|
||||||
switch (categoryName) {
|
switch (categoryName) {
|
||||||
case '유튜브':
|
case '유튜브':
|
||||||
return `/admin/schedule/${scheduleId}/edit/youtube`;
|
return `/admin/schedule/${scheduleId}/edit/youtube`;
|
||||||
case 'X':
|
case 'X':
|
||||||
return `/admin/schedule/${scheduleId}/edit/x`;
|
return `/admin/schedule/${scheduleId}/edit/x`;
|
||||||
case '콘서트':
|
|
||||||
if (schedule?.concertSeriesId) {
|
|
||||||
return `/admin/schedule/concert/${schedule.concertSeriesId}/edit`;
|
|
||||||
}
|
|
||||||
return `/admin/schedule/${scheduleId}/edit`;
|
|
||||||
case '예능':
|
|
||||||
return `/admin/schedule/${scheduleId}/edit/variety`;
|
|
||||||
default:
|
default:
|
||||||
return `/admin/schedule/${scheduleId}/edit`;
|
return `/admin/schedule/${scheduleId}/edit`;
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +134,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name, schedule))}
|
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||||
>
|
>
|
||||||
<Edit2 size={18} />
|
<Edit2 size={18} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play } from 'lucide-react';
|
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||||
import Birthday from './Birthday';
|
import Birthday from './Birthday';
|
||||||
|
|
@ -39,7 +39,7 @@ function linkifyText(text) {
|
||||||
// URL
|
// URL
|
||||||
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
||||||
parts.push(
|
parts.push(
|
||||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
{matched}
|
{matched}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
@ -475,106 +475,6 @@ function MobileXSection({ schedule }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile 예능 섹션
|
|
||||||
*/
|
|
||||||
function MobileVarietySection({ schedule }) {
|
|
||||||
const members = schedule.members || [];
|
|
||||||
const isFullGroup = members.length === 5;
|
|
||||||
const hasThumbnail = !!schedule.thumbnailUrl;
|
|
||||||
const hasReplayUrl = !!schedule.replayUrl;
|
|
||||||
const isYoutubeReplay = hasReplayUrl && /youtu\.?be/i.test(schedule.replayUrl);
|
|
||||||
const categoryColor = schedule.category?.color || '#06b6d4';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 썸네일 카드 */}
|
|
||||||
<div className="rounded-xl overflow-hidden shadow-sm h-52 relative">
|
|
||||||
{hasThumbnail ? (
|
|
||||||
<>
|
|
||||||
{/* 블러 배경 */}
|
|
||||||
<img
|
|
||||||
src={schedule.thumbnailUrl}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-110 blur-2xl opacity-60"
|
|
||||||
/>
|
|
||||||
{/* 메인 이미지 */}
|
|
||||||
<img
|
|
||||||
src={schedule.thumbnailUrl}
|
|
||||||
alt={schedule.title}
|
|
||||||
className="relative w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="w-full h-full flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: `${categoryColor}10` }}
|
|
||||||
>
|
|
||||||
<Tv size={36} style={{ color: categoryColor }} strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 카드 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
|
||||||
{/* 방송사 + 날짜 */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{schedule.broadcaster && (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-md"
|
|
||||||
style={{ backgroundColor: `${categoryColor}15`, color: categoryColor }}
|
|
||||||
>
|
|
||||||
<Tv size={10} />
|
|
||||||
{schedule.broadcaster}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{formatFullDate(schedule.date)}
|
|
||||||
{schedule.time && ` · ${formatTime(schedule.time)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 제목 */}
|
|
||||||
<h1 className="font-bold text-gray-900 text-base leading-snug mb-3">
|
|
||||||
{decodeHtmlEntities(schedule.title)}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* 멤버 */}
|
|
||||||
{members.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
||||||
{isFullGroup ? (
|
|
||||||
<span className="px-2.5 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
|
||||||
프로미스나인
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
members.map((member) => (
|
|
||||||
<span key={member.id} className="px-2.5 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
|
||||||
{member.name}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 다시보기 */}
|
|
||||||
{hasReplayUrl && (
|
|
||||||
<div className="pt-3 border-t border-gray-100">
|
|
||||||
<a
|
|
||||||
href={schedule.replayUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gray-900 text-white text-xs font-medium rounded-full"
|
|
||||||
>
|
|
||||||
{isYoutubeReplay ? <Play size={12} fill="currentColor" /> : <ExternalLink size={12} />}
|
|
||||||
다시보기
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile 기본 섹션
|
* Mobile 기본 섹션
|
||||||
*/
|
*/
|
||||||
|
|
@ -731,8 +631,6 @@ function MobileScheduleDetail() {
|
||||||
return <MobileYoutubeSection schedule={schedule} />;
|
return <MobileYoutubeSection schedule={schedule} />;
|
||||||
case 'X':
|
case 'X':
|
||||||
return <MobileXSection schedule={schedule} />;
|
return <MobileXSection schedule={schedule} />;
|
||||||
case '예능':
|
|
||||||
return <MobileVarietySection schedule={schedule} />;
|
|
||||||
default:
|
default:
|
||||||
return <MobileDefaultSection schedule={schedule} />;
|
return <MobileDefaultSection schedule={schedule} />;
|
||||||
}
|
}
|
||||||
|
|
@ -742,14 +640,10 @@ function MobileScheduleDetail() {
|
||||||
<div className="mobile-layout-container bg-gray-50">
|
<div className="mobile-layout-container bg-gray-50">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex-shrink-0 bg-white border-b border-gray-100">
|
<div className="flex-shrink-0 bg-white border-b border-gray-100">
|
||||||
<div className="flex items-center h-14 px-2">
|
<div className="flex items-center justify-center h-14 px-4">
|
||||||
<button onClick={() => window.history.back()} className="p-2 -ml-0.5 rounded-lg active:bg-gray-100 text-gray-600">
|
<span className="text-sm font-medium" style={{ color: schedule.category?.color }}>
|
||||||
<ChevronLeft size={22} />
|
|
||||||
</button>
|
|
||||||
<span className="flex-1 text-center text-base font-bold" style={{ color: schedule.category?.color }}>
|
|
||||||
{schedule.category?.name}
|
{schedule.category?.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-9" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Save, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
|
||||||
import Toast from "@/components/common/Toast";
|
|
||||||
import { useToast } from "@/hooks/common";
|
|
||||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
|
||||||
import { getMembers } from "@/api/public/members";
|
|
||||||
import { getAlbums } from "@/api/public/albums";
|
|
||||||
import { getConcertSchedule, updateConcertSchedule } from "@/api/admin/concert";
|
|
||||||
|
|
||||||
import ConcertInfoSection from "../form/concert/ConcertInfoSection";
|
|
||||||
import ScheduleSection from "../form/concert/ScheduleSection";
|
|
||||||
import SetlistSection from "../form/concert/SetlistSection";
|
|
||||||
import MerchandiseSection from "../form/concert/MerchandiseSection";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘서트 일정 수정 폼
|
|
||||||
*/
|
|
||||||
function ConcertEditForm() {
|
|
||||||
const { seriesId } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { toast, setToast } = useToast();
|
|
||||||
const { isAuthenticated } = useAdminAuth();
|
|
||||||
|
|
||||||
// 멤버/앨범 데이터
|
|
||||||
const { data: membersData = [] } = useQuery({
|
|
||||||
queryKey: ["members"],
|
|
||||||
queryFn: getMembers,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
const members = membersData.filter((m) => !m.is_former);
|
|
||||||
|
|
||||||
const { data: albumsData = [] } = useQuery({
|
|
||||||
queryKey: ["albums"],
|
|
||||||
queryFn: getAlbums,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 기존 데이터 로드
|
|
||||||
const { data: concertData, isLoading: isLoadingConcert } = useQuery({
|
|
||||||
queryKey: ["concert", seriesId],
|
|
||||||
queryFn: () => getConcertSchedule(seriesId),
|
|
||||||
enabled: isAuthenticated && !!seriesId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 폼 상태
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [posterFile, setPosterFile] = useState(null);
|
|
||||||
const [posterPreview, setPosterPreview] = useState(null);
|
|
||||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
|
||||||
const [rounds, setRoundsRaw] = useState([]);
|
|
||||||
const [setlists, setSetlists] = useState({});
|
|
||||||
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
// 기존 데이터로 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (concertData && !initialized) {
|
|
||||||
setTitle(concertData.title || "");
|
|
||||||
setPosterPreview(concertData.posterUrl || null);
|
|
||||||
setSelectedMemberIds(concertData.memberIds || []);
|
|
||||||
setRoundsRaw(concertData.rounds || []);
|
|
||||||
setSetlists(concertData.setlists || {});
|
|
||||||
setMerchandiseItems(
|
|
||||||
(concertData.merchandise || []).map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
existingId: m.id,
|
|
||||||
preview: m.thumbUrl || m.mediumUrl,
|
|
||||||
file: null,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
setInitialized(true);
|
|
||||||
}
|
|
||||||
}, [concertData, initialized]);
|
|
||||||
|
|
||||||
// 회차 변경 시 세트리스트 동기화
|
|
||||||
const setRounds = (updater) => {
|
|
||||||
setRoundsRaw((prev) => {
|
|
||||||
const newRounds = typeof updater === "function" ? updater(prev) : updater;
|
|
||||||
|
|
||||||
setSetlists((prevSetlists) => {
|
|
||||||
const updated = { ...prevSetlists };
|
|
||||||
for (const round of newRounds) {
|
|
||||||
if (!updated[round.id]) {
|
|
||||||
const lastRound = prev[prev.length - 1];
|
|
||||||
const source = prevSetlists[lastRound?.id] || [
|
|
||||||
{ id: 1, songName: "", albumName: "", memberIds: [] },
|
|
||||||
];
|
|
||||||
let maxId = Object.values(updated)
|
|
||||||
.flat()
|
|
||||||
.reduce((max, s) => Math.max(max, s.id || 0), 0);
|
|
||||||
updated[round.id] = source.map((s) => ({
|
|
||||||
...s,
|
|
||||||
id: ++maxId,
|
|
||||||
memberIds: [...s.memberIds],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const roundIds = new Set(newRounds.map((r) => r.id));
|
|
||||||
for (const key of Object.keys(updated)) {
|
|
||||||
if (!roundIds.has(Number(key))) {
|
|
||||||
delete updated[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
return newRounds;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 멤버 토글
|
|
||||||
const toggleMember = (memberId) => {
|
|
||||||
setSelectedMemberIds((prev) =>
|
|
||||||
prev.includes(memberId)
|
|
||||||
? prev.filter((id) => id !== memberId)
|
|
||||||
: [...prev, memberId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAllMembers = () => {
|
|
||||||
if (selectedMemberIds.length === members.length) {
|
|
||||||
setSelectedMemberIds([]);
|
|
||||||
} else {
|
|
||||||
setSelectedMemberIds(members.map((m) => m.id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 포스터
|
|
||||||
const handlePosterChange = (file) => {
|
|
||||||
setPosterFile(file);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => setPosterPreview(reader.result);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePosterRemove = () => {
|
|
||||||
setPosterFile(null);
|
|
||||||
setPosterPreview(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 제출
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!title.trim()) {
|
|
||||||
setToast({ type: "error", message: "공연명을 입력해주세요." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validRounds = rounds.filter((r) => r.date);
|
|
||||||
if (validRounds.length === 0) {
|
|
||||||
setToast({ type: "error", message: "최소 1개 이상의 공연 일정이 필요합니다." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("title", title.trim());
|
|
||||||
formData.append("memberIds", JSON.stringify(selectedMemberIds));
|
|
||||||
|
|
||||||
if (posterFile) {
|
|
||||||
formData.append("poster", posterFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roundsData = validRounds.map((r) => ({
|
|
||||||
date: r.date,
|
|
||||||
time: r.time || null,
|
|
||||||
venueId: r.venue?.id || null,
|
|
||||||
venueName: r.venue?.name || null,
|
|
||||||
venueCountry: r.venue?.country || null,
|
|
||||||
venueAddress: r.venue?.address || null,
|
|
||||||
venueLat: r.venue?.lat || null,
|
|
||||||
venueLng: r.venue?.lng || null,
|
|
||||||
}));
|
|
||||||
formData.append("rounds", JSON.stringify(roundsData));
|
|
||||||
|
|
||||||
// 회차별 세트리스트
|
|
||||||
const setlistsData = validRounds.map((r) => {
|
|
||||||
const roundSetlist = setlists[r.id] || [];
|
|
||||||
return roundSetlist
|
|
||||||
.filter((s) => s.songName?.trim())
|
|
||||||
.map((s) => ({
|
|
||||||
songName: s.songName.trim(),
|
|
||||||
albumName: s.albumName?.trim() || null,
|
|
||||||
memberIds: s.memberIds || [],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
formData.append("setlists", JSON.stringify(setlistsData));
|
|
||||||
|
|
||||||
// 기존 유지할 굿즈 ID
|
|
||||||
const keepIds = merchandiseItems
|
|
||||||
.filter((item) => item.existingId && !item.file)
|
|
||||||
.map((item) => item.existingId);
|
|
||||||
formData.append("keepMerchandiseIds", JSON.stringify(keepIds));
|
|
||||||
|
|
||||||
// 새 굿즈 파일
|
|
||||||
merchandiseItems.forEach((item) => {
|
|
||||||
if (item.file) {
|
|
||||||
formData.append("merchandise", item.file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await updateConcertSchedule(seriesId, formData);
|
|
||||||
|
|
||||||
setToast({ type: "success", message: "콘서트 일정이 수정되었습니다." });
|
|
||||||
setTimeout(() => navigate("/admin/schedule"), 1000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("콘서트 수정 실패:", err);
|
|
||||||
setToast({ type: "error", message: err.message || "수정에 실패했습니다." });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoadingConcert) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
|
||||||
|
|
||||||
<motion.form
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<ConcertInfoSection
|
|
||||||
title={title}
|
|
||||||
setTitle={setTitle}
|
|
||||||
posterPreview={posterPreview}
|
|
||||||
onPosterChange={handlePosterChange}
|
|
||||||
onPosterRemove={handlePosterRemove}
|
|
||||||
members={members}
|
|
||||||
selectedMemberIds={selectedMemberIds}
|
|
||||||
onToggleMember={toggleMember}
|
|
||||||
onToggleAllMembers={toggleAllMembers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScheduleSection rounds={rounds} setRounds={setRounds} />
|
|
||||||
|
|
||||||
<MerchandiseSection
|
|
||||||
items={merchandiseItems}
|
|
||||||
setItems={setMerchandiseItems}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SetlistSection
|
|
||||||
rounds={rounds}
|
|
||||||
setlists={setlists}
|
|
||||||
setSetlists={setSetlists}
|
|
||||||
members={members}
|
|
||||||
selectedMemberIds={selectedMemberIds}
|
|
||||||
albums={albumsData}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/admin/schedule")}
|
|
||||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
수정 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save size={18} />
|
|
||||||
수정
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.form>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConcertEditForm;
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Save, Loader2, Tv, Link2, Image, Users } from "lucide-react";
|
|
||||||
import DatePicker from "@/components/pc/admin/common/DatePicker";
|
|
||||||
import TimePicker from "@/components/pc/admin/common/TimePicker";
|
|
||||||
|
|
||||||
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
|
||||||
import Toast from "@/components/common/Toast";
|
|
||||||
import { useToast } from "@/hooks/common";
|
|
||||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
|
||||||
import { getMembers } from "@/api/public/members";
|
|
||||||
import { getVarietySchedule, updateVarietySchedule, getBroadcasters } from "@/api/admin/variety";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 수정 폼
|
|
||||||
*/
|
|
||||||
function VarietyEditForm() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { toast, setToast } = useToast();
|
|
||||||
const { isAuthenticated } = useAdminAuth();
|
|
||||||
|
|
||||||
const { data: membersData = [] } = useQuery({
|
|
||||||
queryKey: ["members"],
|
|
||||||
queryFn: getMembers,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
const members = membersData.filter((m) => !m.is_former);
|
|
||||||
|
|
||||||
const { data: scheduleData, isLoading } = useQuery({
|
|
||||||
queryKey: ["variety-schedule", id],
|
|
||||||
queryFn: () => getVarietySchedule(id),
|
|
||||||
enabled: isAuthenticated && !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: broadcasterPresets = [] } = useQuery({
|
|
||||||
queryKey: ["broadcasters"],
|
|
||||||
queryFn: getBroadcasters,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [broadcaster, setBroadcaster] = useState("");
|
|
||||||
const [date, setDate] = useState("");
|
|
||||||
const [time, setTime] = useState("");
|
|
||||||
const [replayUrl, setReplayUrl] = useState("");
|
|
||||||
const [thumbnailFile, setThumbnailFile] = useState(null);
|
|
||||||
const [thumbnailPreview, setThumbnailPreview] = useState(null);
|
|
||||||
const [removeThumbnail, setRemoveThumbnail] = useState(false);
|
|
||||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scheduleData && !initialized) {
|
|
||||||
setTitle(scheduleData.title || "");
|
|
||||||
setBroadcaster(scheduleData.broadcaster || "");
|
|
||||||
setDate(scheduleData.date || "");
|
|
||||||
setTime(scheduleData.time || "");
|
|
||||||
setReplayUrl(scheduleData.replayUrl || "");
|
|
||||||
if (scheduleData.thumbnailUrl) setThumbnailPreview(scheduleData.thumbnailUrl);
|
|
||||||
setSelectedMemberIds(scheduleData.memberIds || []);
|
|
||||||
setInitialized(true);
|
|
||||||
}
|
|
||||||
}, [scheduleData, initialized]);
|
|
||||||
|
|
||||||
const toggleMember = (memberId) => {
|
|
||||||
setSelectedMemberIds((prev) =>
|
|
||||||
prev.includes(memberId) ? prev.filter((i) => i !== memberId) : [...prev, memberId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAllMembers = () => {
|
|
||||||
setSelectedMemberIds(
|
|
||||||
selectedMemberIds.length === members.length ? [] : members.map((m) => m.id)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!title.trim() || !broadcaster.trim() || !date) {
|
|
||||||
setToast({ type: "error", message: "필수 항목을 입력해주세요." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("title", title.trim());
|
|
||||||
formData.append("broadcaster", broadcaster.trim());
|
|
||||||
formData.append("date", date);
|
|
||||||
if (time) formData.append("time", time);
|
|
||||||
if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim());
|
|
||||||
formData.append("memberIds", JSON.stringify(selectedMemberIds));
|
|
||||||
if (thumbnailFile) formData.append("thumbnail", thumbnailFile);
|
|
||||||
if (removeThumbnail) formData.append("removeThumbnail", "true");
|
|
||||||
|
|
||||||
await updateVarietySchedule(id, formData);
|
|
||||||
sessionStorage.setItem("scheduleToast", JSON.stringify({ type: "success", message: "예능 일정이 수정되었습니다." }));
|
|
||||||
navigate("/admin/schedule");
|
|
||||||
} catch (err) {
|
|
||||||
setToast({ type: "error", message: err.message || "수정에 실패했습니다." });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
|
||||||
<motion.form
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4 }}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{/* 프로그램 정보 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="text-lg font-bold text-gray-900 mb-6">프로그램 정보</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Tv size={14} />프로그램명 *</label>
|
|
||||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="예: 워크돌 EP.15" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">방송사/플랫폼 *</label>
|
|
||||||
<input type="text" value={broadcaster} onChange={(e) => setBroadcaster(e.target.value)} placeholder="방송사 또는 플랫폼명" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent mb-2" />
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{broadcasterPresets.map((p) => (
|
|
||||||
<button key={p} type="button" onClick={() => setBroadcaster(p)} className={`px-3 py-1 text-xs rounded-full border transition-colors ${broadcaster === p ? "border-primary bg-primary text-white" : "border-gray-200 text-gray-500 hover:border-gray-300"}`}>{p}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">날짜 *</label>
|
|
||||||
<DatePicker value={date} onChange={setDate} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">시간 (선택)</label>
|
|
||||||
<TimePicker value={time} onChange={setTime} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출연 멤버 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6"><Users size={18} />출연 멤버</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button type="button" onClick={toggleAllMembers} className={`px-4 py-1.5 rounded-full border text-sm transition-colors ${selectedMemberIds.length === members.length ? "border-primary bg-primary text-white" : "border-gray-200 text-gray-500 hover:border-gray-300"}`}>{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}</button>
|
|
||||||
{members.map((m) => (
|
|
||||||
<button key={m.id} type="button" onClick={() => toggleMember(m.id)} className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${selectedMemberIds.includes(m.id) ? "border-primary" : "border-gray-200"}`}>
|
|
||||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">{m.image_url ? <img src={m.image_url} alt={m.name} className="w-full h-full object-cover" /> : <div className="w-full h-full bg-gray-300" />}</div>
|
|
||||||
<span className="text-sm text-gray-700">{m.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 추가 정보 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="text-lg font-bold text-gray-900 mb-6">추가 정보</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Link2 size={14} />다시보기 링크 (선택)</label>
|
|
||||||
<input type="url" value={replayUrl} onChange={(e) => setReplayUrl(e.target.value)} placeholder="https://..." className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Image size={14} />썸네일 이미지 (선택)</label>
|
|
||||||
{thumbnailPreview ? (
|
|
||||||
<div className="relative inline-block">
|
|
||||||
<img src={thumbnailPreview} alt="미리보기" className="h-32 rounded-lg object-cover" />
|
|
||||||
<button type="button" onClick={() => { setThumbnailFile(null); setThumbnailPreview(null); setRemoveThumbnail(true); }} className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600">✕</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<label className="flex items-center justify-center w-full h-32 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors">
|
|
||||||
<div className="text-center"><Image size={24} className="mx-auto text-gray-400 mb-1" /><span className="text-sm text-gray-400">클릭하여 이미지 선택</span></div>
|
|
||||||
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files[0]; if (f) { setThumbnailFile(f); setRemoveThumbnail(false); const r = new FileReader(); r.onloadend = () => setThumbnailPreview(r.result); r.readAsDataURL(f); } }} />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 버튼 */}
|
|
||||||
<div className="flex items-center justify-end gap-4">
|
|
||||||
<button type="button" onClick={() => navigate("/admin/schedule")} className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors">취소</button>
|
|
||||||
<button type="submit" disabled={saving} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50">
|
|
||||||
{saving ? (<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />수정 중...</>) : (<><Save size={18} />수정</>)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.form>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VarietyEditForm;
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Save, Tv, Link2, Image, Users } from "lucide-react";
|
|
||||||
|
|
||||||
import Toast from "@/components/common/Toast";
|
|
||||||
import DatePicker from "@/components/pc/admin/common/DatePicker";
|
|
||||||
import TimePicker from "@/components/pc/admin/common/TimePicker";
|
|
||||||
import { useToast } from "@/hooks/common";
|
|
||||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
|
||||||
import { getMembers } from "@/api/public/members";
|
|
||||||
import { createVarietySchedule, getBroadcasters } from "@/api/admin/variety";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 추가 폼
|
|
||||||
*/
|
|
||||||
function VarietyForm() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { toast, setToast } = useToast();
|
|
||||||
const { isAuthenticated } = useAdminAuth();
|
|
||||||
|
|
||||||
// 멤버 목록
|
|
||||||
const { data: membersData = [] } = useQuery({
|
|
||||||
queryKey: ["members"],
|
|
||||||
queryFn: getMembers,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
const members = membersData.filter((m) => !m.is_former);
|
|
||||||
|
|
||||||
// 폼 상태
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [broadcaster, setBroadcaster] = useState("");
|
|
||||||
const [date, setDate] = useState("");
|
|
||||||
const [time, setTime] = useState("");
|
|
||||||
const [replayUrl, setReplayUrl] = useState("");
|
|
||||||
const [thumbnailFile, setThumbnailFile] = useState(null);
|
|
||||||
const [thumbnailPreview, setThumbnailPreview] = useState(null);
|
|
||||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// 자주 사용된 방송사 목록
|
|
||||||
const { data: broadcasterPresets = [] } = useQuery({
|
|
||||||
queryKey: ["broadcasters"],
|
|
||||||
queryFn: getBroadcasters,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 멤버 토글
|
|
||||||
const toggleMember = (memberId) => {
|
|
||||||
setSelectedMemberIds((prev) =>
|
|
||||||
prev.includes(memberId)
|
|
||||||
? prev.filter((id) => id !== memberId)
|
|
||||||
: [...prev, memberId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAllMembers = () => {
|
|
||||||
if (selectedMemberIds.length === members.length) {
|
|
||||||
setSelectedMemberIds([]);
|
|
||||||
} else {
|
|
||||||
setSelectedMemberIds(members.map((m) => m.id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 제출
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!title.trim()) {
|
|
||||||
setToast({ type: "error", message: "프로그램명을 입력해주세요." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!broadcaster.trim()) {
|
|
||||||
setToast({ type: "error", message: "방송사/플랫폼을 선택하거나 입력해주세요." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!date) {
|
|
||||||
setToast({ type: "error", message: "날짜를 선택해주세요." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("title", title.trim());
|
|
||||||
formData.append("broadcaster", broadcaster.trim());
|
|
||||||
formData.append("date", date);
|
|
||||||
if (time) formData.append("time", time);
|
|
||||||
if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim());
|
|
||||||
formData.append("memberIds", JSON.stringify(selectedMemberIds));
|
|
||||||
if (thumbnailFile) formData.append("thumbnail", thumbnailFile);
|
|
||||||
|
|
||||||
await createVarietySchedule(formData);
|
|
||||||
|
|
||||||
sessionStorage.setItem(
|
|
||||||
"scheduleToast",
|
|
||||||
JSON.stringify({ type: "success", message: "예능 일정이 추가되었습니다." })
|
|
||||||
);
|
|
||||||
navigate("/admin/schedule");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("예능 일정 저장 실패:", err);
|
|
||||||
setToast({ type: "error", message: err.message || "저장에 실패했습니다." });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
|
||||||
|
|
||||||
<motion.form
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{/* 프로그램 정보 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="text-lg font-bold text-gray-900 mb-6">프로그램 정보</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 프로그램명 */}
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
|
||||||
<Tv size={14} />
|
|
||||||
프로그램명 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="예: 워크돌 EP.15"
|
|
||||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 방송사/플랫폼 */}
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
|
||||||
방송사/플랫폼 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={broadcaster}
|
|
||||||
onChange={(e) => setBroadcaster(e.target.value)}
|
|
||||||
placeholder="방송사 또는 플랫폼명"
|
|
||||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent mb-2"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{broadcasterPresets.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBroadcaster(preset)}
|
|
||||||
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
|
|
||||||
broadcaster === preset
|
|
||||||
? "border-primary bg-primary text-white"
|
|
||||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{preset}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 날짜/시간 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">날짜 *</label>
|
|
||||||
<DatePicker value={date} onChange={setDate} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">시간 (선택)</label>
|
|
||||||
<TimePicker value={time} onChange={setTime} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출연 멤버 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6">
|
|
||||||
<Users size={18} />
|
|
||||||
출연 멤버
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleAllMembers}
|
|
||||||
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
|
||||||
selectedMemberIds.length === members.length
|
|
||||||
? "border-primary bg-primary text-white"
|
|
||||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}
|
|
||||||
</button>
|
|
||||||
{members.map((member) => {
|
|
||||||
const isSelected = selectedMemberIds.includes(member.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={member.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleMember(member.id)}
|
|
||||||
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
|
||||||
isSelected ? "border-primary" : "border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
|
||||||
{member.image_url ? (
|
|
||||||
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-gray-300" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700">{member.name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 추가 정보 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
||||||
<h2 className="text-lg font-bold text-gray-900 mb-6">추가 정보</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 다시보기 링크 */}
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
|
||||||
<Link2 size={14} />
|
|
||||||
다시보기 링크 (선택)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={replayUrl}
|
|
||||||
onChange={(e) => setReplayUrl(e.target.value)}
|
|
||||||
placeholder="https://www.youtube.com/watch?v=... 또는 OTT 링크"
|
|
||||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 썸네일 이미지 */}
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
|
||||||
<Image size={14} />
|
|
||||||
썸네일 이미지 (선택)
|
|
||||||
</label>
|
|
||||||
{thumbnailPreview ? (
|
|
||||||
<div className="relative inline-block">
|
|
||||||
<img src={thumbnailPreview} alt="썸네일 미리보기" className="h-32 rounded-lg object-cover" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setThumbnailFile(null); setThumbnailPreview(null); }}
|
|
||||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<label className="flex items-center justify-center w-full h-32 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors">
|
|
||||||
<div className="text-center">
|
|
||||||
<Image size={24} className="mx-auto text-gray-400 mb-1" />
|
|
||||||
<span className="text-sm text-gray-400">클릭하여 이미지 선택</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
setThumbnailFile(file);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => setThumbnailPreview(reader.result);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 버튼 */}
|
|
||||||
<div className="flex items-center justify-end gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/admin/schedule")}
|
|
||||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
저장 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save size={18} />
|
|
||||||
저장
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VarietyForm;
|
|
||||||
|
|
@ -1,40 +1,22 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Plus, Trash2, Users, Search, Copy, ChevronDown } from "lucide-react";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Plus, Trash2, Users, Search } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||||
import SongSearchDialog from "./SongSearchDialog";
|
import SongSearchDialog from "./SongSearchDialog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세트리스트 섹션 (회차별 탭)
|
* 세트리스트 섹션
|
||||||
* - 회차별로 독립적인 세트리스트
|
* - 곡 추가/삭제
|
||||||
* - 다른 회차에서 복사 기능
|
* - 곡명, 앨범명, 참여 멤버
|
||||||
|
* - 순서 자동 부여
|
||||||
*/
|
*/
|
||||||
function SetlistSection({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) {
|
function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, albums }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [activeRoundId, setActiveRoundId] = useState(rounds[0]?.id || 1);
|
const [nextId, setNextId] = useState(() => {
|
||||||
|
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0);
|
||||||
// 현재 활성 회차의 세트리스트
|
return maxId + 1;
|
||||||
const setlist = setlists[activeRoundId] || [];
|
});
|
||||||
|
|
||||||
// 활성 회차가 삭제되면 첫 번째 회차로 전환
|
|
||||||
useEffect(() => {
|
|
||||||
if (!rounds.find((r) => r.id === activeRoundId) && rounds.length > 0) {
|
|
||||||
setActiveRoundId(rounds[0].id);
|
|
||||||
}
|
|
||||||
}, [rounds, activeRoundId]);
|
|
||||||
|
|
||||||
// 다음 ID 계산
|
|
||||||
const getNextId = () => {
|
|
||||||
return Object.values(setlists).flat().reduce((max, s) => Math.max(max, s.id || 0), 0) + 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 현재 회차의 세트리스트 업데이트
|
|
||||||
const updateCurrentSetlist = (updater) => {
|
|
||||||
setSetlists((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[activeRoundId]: typeof updater === 'function' ? updater(prev[activeRoundId] || []) : updater,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 삭제 확인 다이얼로그
|
// 삭제 확인 다이얼로그
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||||
|
|
@ -46,35 +28,16 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
// 곡 검색 다이얼로그
|
// 곡 검색 다이얼로그
|
||||||
const [songSearchOpen, setSongSearchOpen] = useState(false);
|
const [songSearchOpen, setSongSearchOpen] = useState(false);
|
||||||
|
|
||||||
// 복사 소스 선택
|
|
||||||
const [copyFrom, setCopyFrom] = useState(null);
|
|
||||||
|
|
||||||
// 회차 드롭다운 열림 상태
|
|
||||||
const [roundDropdownOpen, setRoundDropdownOpen] = useState(false);
|
|
||||||
const roundDropdownRef = useRef(null);
|
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (roundDropdownRef.current && !roundDropdownRef.current.contains(e.target)) {
|
|
||||||
setRoundDropdownOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (roundDropdownOpen) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [roundDropdownOpen]);
|
|
||||||
|
|
||||||
// 직접 입력 곡 추가
|
// 직접 입력 곡 추가
|
||||||
const addSong = () => {
|
const addSong = () => {
|
||||||
const newSong = {
|
const newSong = {
|
||||||
id: getNextId(),
|
id: nextId,
|
||||||
songName: "",
|
songName: "",
|
||||||
albumName: "",
|
albumName: "",
|
||||||
memberIds: [...selectedMemberIds],
|
memberIds: [...selectedMemberIds],
|
||||||
};
|
};
|
||||||
updateCurrentSetlist((prev) => [...prev, newSong]);
|
setSetlist((prev) => [...prev, newSong]);
|
||||||
|
setNextId(nextId + 1);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
|
|
@ -88,14 +51,15 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
|
|
||||||
// 검색에서 선택한 곡 추가
|
// 검색에서 선택한 곡 추가
|
||||||
const addSongsFromSearch = (songs) => {
|
const addSongsFromSearch = (songs) => {
|
||||||
let id = getNextId();
|
let id = nextId;
|
||||||
const newSongs = songs.map((song) => ({
|
const newSongs = songs.map((song) => ({
|
||||||
id: id++,
|
id: id++,
|
||||||
songName: song.songName,
|
songName: song.songName,
|
||||||
albumName: song.albumName,
|
albumName: song.albumName,
|
||||||
memberIds: [...selectedMemberIds],
|
memberIds: [...selectedMemberIds],
|
||||||
}));
|
}));
|
||||||
updateCurrentSetlist((prev) => [...prev, ...newSongs]);
|
setSetlist((prev) => [...prev, ...newSongs]);
|
||||||
|
setNextId(id);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
|
|
@ -107,25 +71,18 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다른 회차에서 복사
|
|
||||||
const copyFromRound = (sourceRoundId) => {
|
|
||||||
const source = setlists[sourceRoundId] || [];
|
|
||||||
let id = getNextId();
|
|
||||||
const copied = source.map((s) => ({
|
|
||||||
...s,
|
|
||||||
id: id++,
|
|
||||||
memberIds: [...s.memberIds],
|
|
||||||
}));
|
|
||||||
updateCurrentSetlist(copied);
|
|
||||||
setCopyFrom(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 곡 삭제 시도
|
// 곡 삭제 시도
|
||||||
const handleRemoveSong = (id) => {
|
const handleRemoveSong = (id) => {
|
||||||
if (setlist.length <= 1) return;
|
if (setlist.length <= 1) return;
|
||||||
|
|
||||||
const song = setlist.find((s) => s.id === id);
|
const song = setlist.find((s) => s.id === id);
|
||||||
|
|
||||||
if (song && (song.songName || song.albumName)) {
|
if (song && (song.songName || song.albumName)) {
|
||||||
setDeleteConfirm({ isOpen: true, songId: id, songName: song.songName || "제목 없음" });
|
setDeleteConfirm({
|
||||||
|
isOpen: true,
|
||||||
|
songId: id,
|
||||||
|
songName: song.songName || "제목 없음",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
removeSong(id);
|
removeSong(id);
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +90,7 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
|
|
||||||
// 곡 삭제 실행
|
// 곡 삭제 실행
|
||||||
const removeSong = (id) => {
|
const removeSong = (id) => {
|
||||||
updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id));
|
setSetlist((prev) => prev.filter((s) => s.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 삭제 확인
|
// 삭제 확인
|
||||||
|
|
@ -146,14 +103,14 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
|
|
||||||
// 곡 필드 업데이트
|
// 곡 필드 업데이트
|
||||||
const updateSong = (id, field, value) => {
|
const updateSong = (id, field, value) => {
|
||||||
updateCurrentSetlist((prev) =>
|
setSetlist((prev) =>
|
||||||
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 곡별 멤버 토글
|
// 곡별 멤버 토글
|
||||||
const toggleSongMember = (songId, memberId) => {
|
const toggleSongMember = (songId, memberId) => {
|
||||||
updateCurrentSetlist((prev) =>
|
setSetlist((prev) =>
|
||||||
prev.map((s) => {
|
prev.map((s) => {
|
||||||
if (s.id !== songId) return s;
|
if (s.id !== songId) return s;
|
||||||
const has = s.memberIds.includes(memberId);
|
const has = s.memberIds.includes(memberId);
|
||||||
|
|
@ -169,7 +126,7 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
|
|
||||||
// 곡별 멤버 전체 선택/해제
|
// 곡별 멤버 전체 선택/해제
|
||||||
const toggleAllSongMembers = (songId) => {
|
const toggleAllSongMembers = (songId) => {
|
||||||
updateCurrentSetlist((prev) =>
|
setSetlist((prev) =>
|
||||||
prev.map((s) => {
|
prev.map((s) => {
|
||||||
if (s.id !== songId) return s;
|
if (s.id !== songId) return s;
|
||||||
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
||||||
|
|
@ -181,14 +138,13 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 현재 활성 회차의 인덱스
|
|
||||||
const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, songId: null, songName: null })}
|
onClose={() =>
|
||||||
|
setDeleteConfirm({ isOpen: false, songId: null, songName: null })
|
||||||
|
}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
title="곡 삭제"
|
title="곡 삭제"
|
||||||
message={
|
message={
|
||||||
|
|
@ -209,83 +165,18 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h2 className="text-lg font-bold text-gray-900 mb-6">세트리스트</h2>
|
||||||
<h2 className="text-lg font-bold text-gray-900">세트리스트</h2>
|
|
||||||
{/* 다른 회차에서 복사 */}
|
|
||||||
{rounds.length > 1 && (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCopyFrom(copyFrom ? null : true)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Copy size={12} />
|
|
||||||
다른 회차에서 복사
|
|
||||||
</button>
|
|
||||||
{copyFrom && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1 min-w-[120px]">
|
|
||||||
{rounds
|
|
||||||
.filter((r) => r.id !== activeRoundId)
|
|
||||||
.map((r, i) => {
|
|
||||||
const roundIdx = rounds.indexOf(r);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={r.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyFromRound(r.id)}
|
|
||||||
className="w-full px-4 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
{roundIdx + 1}회차
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 회차 선택 커스텀 드롭다운 (2개 이상일 때만) */}
|
|
||||||
{rounds.length > 1 && (
|
|
||||||
<div className="relative mb-4" ref={roundDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRoundDropdownOpen(!roundDropdownOpen)}
|
|
||||||
className="flex items-center justify-between gap-2 w-48 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white hover:border-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{activeRoundIndex + 1}회차
|
|
||||||
{rounds[activeRoundIndex]?.date ? ` (${rounds[activeRoundIndex].date})` : ''}
|
|
||||||
</span>
|
|
||||||
<ChevronDown size={14} className={`text-gray-400 transition-transform ${roundDropdownOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
{roundDropdownOpen && (
|
|
||||||
<div className="absolute left-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1">
|
|
||||||
{rounds.map((round, index) => (
|
|
||||||
<button
|
|
||||||
key={round.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveRoundId(round.id);
|
|
||||||
setRoundDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
|
|
||||||
round.id === activeRoundId
|
|
||||||
? 'bg-primary/10 text-primary font-medium'
|
|
||||||
: 'hover:bg-gray-50 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{index + 1}회차{round.date ? ` (${round.date})` : ''}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={containerRef} className="flex flex-col gap-4">
|
<div ref={containerRef} className="flex flex-col gap-4">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
{setlist.map((song, index) => (
|
{setlist.map((song, index) => (
|
||||||
<div key={song.id}>
|
<motion.div
|
||||||
|
key={song.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98, y: -8 }}
|
||||||
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
|
>
|
||||||
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
|
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -312,7 +203,9 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={song.songName}
|
value={song.songName}
|
||||||
onChange={(e) => updateSong(song.id, "songName", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateSong(song.id, "songName", e.target.value)
|
||||||
|
}
|
||||||
placeholder="예: Feel Good"
|
placeholder="예: Feel Good"
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
@ -324,7 +217,9 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={song.albumName}
|
value={song.albumName}
|
||||||
onChange={(e) => updateSong(song.id, "albumName", e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateSong(song.id, "albumName", e.target.value)
|
||||||
|
}
|
||||||
placeholder="예: Unlock My World"
|
placeholder="예: Unlock My World"
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
@ -338,46 +233,63 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
참여 멤버
|
참여 멤버
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* 전체 선택 버튼 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleAllSongMembers(song.id)}
|
onClick={() => toggleAllSongMembers(song.id)}
|
||||||
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
||||||
members.every((m) => song.memberIds.includes(m.id))
|
members.every((m) =>
|
||||||
|
song.memberIds.includes(m.id)
|
||||||
|
)
|
||||||
? "border-primary bg-primary text-white"
|
? "border-primary bg-primary text-white"
|
||||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{members.every((m) => song.memberIds.includes(m.id))
|
{members.every((m) =>
|
||||||
|
song.memberIds.includes(m.id)
|
||||||
|
)
|
||||||
? "전체 해제"
|
? "전체 해제"
|
||||||
: "전체 선택"}
|
: "전체 선택"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const isSelected = song.memberIds.includes(member.id);
|
const isSelected = song.memberIds.includes(member.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={member.id}
|
key={member.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSongMember(song.id, member.id)}
|
onClick={() =>
|
||||||
|
toggleSongMember(song.id, member.id)
|
||||||
|
}
|
||||||
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
||||||
isSelected ? "border-primary" : "border-gray-200"
|
isSelected
|
||||||
|
? "border-primary"
|
||||||
|
: "border-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
||||||
{member.image_url ? (
|
{member.image_url ? (
|
||||||
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
|
<img
|
||||||
|
src={member.image_url}
|
||||||
|
alt={member.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-gray-300" />
|
<div className="w-full h-full bg-gray-300" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-700">{member.name}</span>
|
<span className="text-sm text-gray-700">
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
|
|
@ -400,9 +312,8 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 mt-3">
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
{rounds.length > 1
|
곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은
|
||||||
? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요."
|
개별 조정하세요.
|
||||||
: "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -48,49 +48,14 @@ function ConcertForm() {
|
||||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||||
|
|
||||||
// 공연 일정 (다회차)
|
// 공연 일정 (다회차)
|
||||||
const [rounds, setRoundsRaw] = useState([
|
const [rounds, setRounds] = useState([
|
||||||
{ id: 1, date: "", time: "", venue: null },
|
{ id: 1, date: "", time: "", venue: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 회차 변경 시 세트리스트 동기화
|
// 세트리스트
|
||||||
const setRounds = (updater) => {
|
const [setlist, setSetlist] = useState([
|
||||||
setRoundsRaw((prev) => {
|
{ id: 1, songName: "", albumName: "", memberIds: [] },
|
||||||
const newRounds = typeof updater === 'function' ? updater(prev) : updater;
|
]);
|
||||||
|
|
||||||
setSetlists((prevSetlists) => {
|
|
||||||
const updated = { ...prevSetlists };
|
|
||||||
// 새로 추가된 회차에 세트리스트 복사
|
|
||||||
for (const round of newRounds) {
|
|
||||||
if (!updated[round.id]) {
|
|
||||||
// 마지막 회차의 세트리스트를 복사 (deep copy)
|
|
||||||
const lastRound = prev[prev.length - 1];
|
|
||||||
const source = prevSetlists[lastRound?.id] || [{ id: 1, songName: "", albumName: "", memberIds: [] }];
|
|
||||||
let maxId = Object.values(updated).flat().reduce((max, s) => Math.max(max, s.id || 0), 0);
|
|
||||||
updated[round.id] = source.map((s) => ({
|
|
||||||
...s,
|
|
||||||
id: ++maxId,
|
|
||||||
memberIds: [...s.memberIds],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 삭제된 회차의 세트리스트 제거
|
|
||||||
const roundIds = new Set(newRounds.map((r) => r.id));
|
|
||||||
for (const key of Object.keys(updated)) {
|
|
||||||
if (!roundIds.has(Number(key))) {
|
|
||||||
delete updated[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
return newRounds;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 회차별 세트리스트 (key: round id)
|
|
||||||
const [setlists, setSetlists] = useState({
|
|
||||||
1: [{ id: 1, songName: "", albumName: "", memberIds: [] }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 굿즈 이미지
|
// 굿즈 이미지
|
||||||
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
||||||
|
|
@ -175,18 +140,14 @@ function ConcertForm() {
|
||||||
}));
|
}));
|
||||||
formData.append("rounds", JSON.stringify(roundsData));
|
formData.append("rounds", JSON.stringify(roundsData));
|
||||||
|
|
||||||
// 회차별 세트리스트 (rounds 순서에 맞춰 배열로 전송)
|
// 세트리스트
|
||||||
const setlistsData = validRounds.map((r) => {
|
const validSetlist = setlist.filter((s) => s.songName?.trim());
|
||||||
const roundSetlist = setlists[r.id] || [];
|
const setlistData = validSetlist.map((s) => ({
|
||||||
return roundSetlist
|
songName: s.songName.trim(),
|
||||||
.filter((s) => s.songName?.trim())
|
albumName: s.albumName?.trim() || null,
|
||||||
.map((s) => ({
|
memberIds: s.memberIds || [],
|
||||||
songName: s.songName.trim(),
|
}));
|
||||||
albumName: s.albumName?.trim() || null,
|
formData.append("setlist", JSON.stringify(setlistData));
|
||||||
memberIds: s.memberIds || [],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
formData.append("setlists", JSON.stringify(setlistsData));
|
|
||||||
|
|
||||||
// 굿즈 이미지
|
// 굿즈 이미지
|
||||||
merchandiseItems.forEach((item) => {
|
merchandiseItems.forEach((item) => {
|
||||||
|
|
@ -242,9 +203,8 @@ function ConcertForm() {
|
||||||
|
|
||||||
{/* 세트리스트 */}
|
{/* 세트리스트 */}
|
||||||
<SetlistSection
|
<SetlistSection
|
||||||
rounds={rounds}
|
setlist={setlist}
|
||||||
setlists={setlists}
|
setSetlist={setSetlist}
|
||||||
setSetlists={setSetlists}
|
|
||||||
members={members}
|
members={members}
|
||||||
selectedMemberIds={selectedMemberIds}
|
selectedMemberIds={selectedMemberIds}
|
||||||
albums={albumsData}
|
albums={albumsData}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Home, ChevronRight } from "lucide-react";
|
import { Home, ChevronRight } from "lucide-react";
|
||||||
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
||||||
|
|
@ -10,7 +9,6 @@ import CategorySelector from "@/components/pc/admin/schedule/CategorySelector";
|
||||||
import YouTubeForm from "./YouTubeForm";
|
import YouTubeForm from "./YouTubeForm";
|
||||||
import XForm from "./XForm";
|
import XForm from "./XForm";
|
||||||
import ConcertForm from "./concert";
|
import ConcertForm from "./concert";
|
||||||
import VarietyForm from "./VarietyForm";
|
|
||||||
|
|
||||||
// 애니메이션 variants
|
// 애니메이션 variants
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
|
|
@ -39,28 +37,32 @@ function ScheduleFormPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAuthenticated } = useAdminAuth();
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
// 카테고리 로드 (React Query)
|
// 카테고리 로드
|
||||||
const { data: categories = [], isLoading: loading } = useQuery({
|
|
||||||
queryKey: ["scheduleCategories"],
|
|
||||||
queryFn: categoriesApi.getCategories,
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
staleTime: 10 * 60 * 1000,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.length > 0 && !selectedCategory) {
|
|
||||||
setSelectedCategory(data[0].id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 카테고리 로드 시 기본값 설정
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (categories.length > 0 && !selectedCategory) {
|
if (!isAuthenticated) return;
|
||||||
setSelectedCategory(categories[0].id);
|
|
||||||
}
|
const fetchCategories = async () => {
|
||||||
}, [categories, selectedCategory]);
|
try {
|
||||||
|
const data = await categoriesApi.getCategories();
|
||||||
|
setCategories(data);
|
||||||
|
// 첫 번째 카테고리를 기본값으로
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedCategory(data[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 로드 오류:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
// 카테고리에 따른 폼 렌더링
|
// 카테고리에 따른 폼 렌더링
|
||||||
const renderForm = () => {
|
const renderForm = () => {
|
||||||
|
|
@ -76,9 +78,6 @@ function ScheduleFormPage() {
|
||||||
case '콘서트':
|
case '콘서트':
|
||||||
return <ConcertForm />;
|
return <ConcertForm />;
|
||||||
|
|
||||||
case '예능':
|
|
||||||
return <VarietyForm />;
|
|
||||||
|
|
||||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
|
|
||||||
// 섹션 컴포넌트들
|
// 섹션 컴포넌트들
|
||||||
import { YoutubeSection, XSection, VarietySection, DefaultSection, decodeHtmlEntities } from './sections';
|
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||||
import Birthday from './Birthday';
|
import Birthday from './Birthday';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -153,8 +153,6 @@ function PCScheduleDetail() {
|
||||||
return <YoutubeSection schedule={schedule} />;
|
return <YoutubeSection schedule={schedule} />;
|
||||||
case 'X':
|
case 'X':
|
||||||
return <XSection schedule={schedule} />;
|
return <XSection schedule={schedule} />;
|
||||||
case '예능':
|
|
||||||
return <VarietySection schedule={schedule} />;
|
|
||||||
default:
|
default:
|
||||||
return <DefaultSection schedule={schedule} />;
|
return <DefaultSection schedule={schedule} />;
|
||||||
}
|
}
|
||||||
|
|
@ -162,8 +160,7 @@ function PCScheduleDetail() {
|
||||||
|
|
||||||
const isYoutube = categoryName === '유튜브';
|
const isYoutube = categoryName === '유튜브';
|
||||||
const isX = categoryName === 'X';
|
const isX = categoryName === 'X';
|
||||||
const isVariety = categoryName === '예능';
|
const hasCustomLayout = isYoutube || isX;
|
||||||
const hasCustomLayout = isYoutube || isX || isVariety;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { Calendar, Clock, Tv, ExternalLink, Play } from 'lucide-react';
|
|
||||||
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예능 일정 섹션 컴포넌트
|
|
||||||
*/
|
|
||||||
function VarietySection({ schedule }) {
|
|
||||||
const members = schedule.members || [];
|
|
||||||
const isFullGroup = members.length === 5;
|
|
||||||
const hasThumbnail = !!schedule.thumbnailUrl;
|
|
||||||
const hasReplayUrl = !!schedule.replayUrl;
|
|
||||||
const isYoutubeReplay = hasReplayUrl && /youtu\.?be/i.test(schedule.replayUrl);
|
|
||||||
const categoryColor = schedule.category?.color || '#06b6d4';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-5 items-start">
|
|
||||||
{/* 왼쪽: 썸네일 카드 (높이 고정) */}
|
|
||||||
<div className="flex-shrink-0 w-52 h-72 bg-white rounded-2xl overflow-hidden shadow-sm border border-gray-100">
|
|
||||||
{hasThumbnail ? (
|
|
||||||
<img
|
|
||||||
src={schedule.thumbnailUrl}
|
|
||||||
alt={schedule.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="w-full h-full flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: `${categoryColor}10` }}
|
|
||||||
>
|
|
||||||
<Tv size={44} style={{ color: categoryColor }} strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 정보 카드 */}
|
|
||||||
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
|
||||||
{/* 방송사 + 날짜 */}
|
|
||||||
<div className="flex items-center gap-2.5 mb-3">
|
|
||||||
{schedule.broadcaster && (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-sm font-semibold rounded-md"
|
|
||||||
style={{ backgroundColor: `${categoryColor}15`, color: categoryColor }}
|
|
||||||
>
|
|
||||||
<Tv size={13} />
|
|
||||||
{schedule.broadcaster}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-gray-400">
|
|
||||||
{formatFullDate(schedule.date)}
|
|
||||||
{schedule.time && ` · ${formatTime(schedule.time)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 제목 */}
|
|
||||||
<h1 className="text-xl font-bold text-gray-900 leading-snug mb-4">
|
|
||||||
{decodeHtmlEntities(schedule.title)}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* 멤버 */}
|
|
||||||
{members.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{isFullGroup ? (
|
|
||||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
|
||||||
프로미스나인
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
members.map((member) => (
|
|
||||||
<span key={member.id} className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
|
||||||
{member.name}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 다시보기 */}
|
|
||||||
{hasReplayUrl && (
|
|
||||||
<div className="pt-4 border-t border-gray-100">
|
|
||||||
<a
|
|
||||||
href={schedule.replayUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-900 hover:bg-black text-white text-sm font-medium rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
{isYoutubeReplay ? <Play size={14} fill="currentColor" /> : <ExternalLink size={14} />}
|
|
||||||
다시보기
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VarietySection;
|
|
||||||
|
|
@ -33,7 +33,7 @@ function linkifyText(text) {
|
||||||
} else {
|
} else {
|
||||||
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
||||||
parts.push(
|
parts.push(
|
||||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
{matched}
|
{matched}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
export { default as YoutubeSection } from './YoutubeSection';
|
export { default as YoutubeSection } from './YoutubeSection';
|
||||||
export { default as XSection } from './XSection';
|
export { default as XSection } from './XSection';
|
||||||
export { default as VarietySection } from './VarietySection';
|
|
||||||
export { default as DefaultSection } from './DefaultSection';
|
export { default as DefaultSection } from './DefaultSection';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export default function MobileRoutes() {
|
||||||
<Route
|
<Route
|
||||||
path="/album/:name"
|
path="/album/:name"
|
||||||
element={
|
element={
|
||||||
<Layout pageTitle="앨범" showBack>
|
<Layout pageTitle="앨범">
|
||||||
<AlbumDetail />
|
<AlbumDetail />
|
||||||
</Layout>
|
</Layout>
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +73,7 @@ export default function MobileRoutes() {
|
||||||
<Route
|
<Route
|
||||||
path="/album/:name/track/:trackTitle"
|
path="/album/:name/track/:trackTitle"
|
||||||
element={
|
element={
|
||||||
<Layout pageTitle="앨범" showBack>
|
<Layout pageTitle="앨범">
|
||||||
<TrackDetail />
|
<TrackDetail />
|
||||||
</Layout>
|
</Layout>
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +81,7 @@ export default function MobileRoutes() {
|
||||||
<Route
|
<Route
|
||||||
path="/album/:name/gallery"
|
path="/album/:name/gallery"
|
||||||
element={
|
element={
|
||||||
<Layout pageTitle="컨셉 포토" showBack>
|
<Layout pageTitle="앨범">
|
||||||
<AlbumGallery />
|
<AlbumGallery />
|
||||||
</Layout>
|
</Layout>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
||||||
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||||
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||||
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||||
import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm';
|
|
||||||
import AdminVarietyEditForm from '@/pages/pc/admin/schedules/edit/VarietyEditForm';
|
|
||||||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||||
|
|
@ -59,8 +57,6 @@ export default function AdminRoutes() {
|
||||||
<Route path="/admin/schedule/new-legacy" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
|
<Route path="/admin/schedule/new-legacy" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/:id/edit" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
|
<Route path="/admin/schedule/:id/edit" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
|
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/concert/:seriesId/edit" element={<RequireAuth><AdminConcertEditForm /></RequireAuth>} />
|
|
||||||
<Route path="/admin/schedule/:id/edit/variety" element={<RequireAuth><AdminVarietyEditForm /></RequireAuth>} />
|
|
||||||
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue