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:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:enableOnBackInvokedCallback="false">
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
|
|
|
|||
|
|
@ -214,7 +214,6 @@ class TrackDetail {
|
|||
final String? arranger;
|
||||
final String? lyrics;
|
||||
final String? musicVideoUrl;
|
||||
final String? videoType;
|
||||
final TrackAlbum? album;
|
||||
|
||||
TrackDetail({
|
||||
|
|
@ -228,7 +227,6 @@ class TrackDetail {
|
|||
this.arranger,
|
||||
this.lyrics,
|
||||
this.musicVideoUrl,
|
||||
this.videoType,
|
||||
this.album,
|
||||
});
|
||||
|
||||
|
|
@ -243,8 +241,7 @@ class TrackDetail {
|
|||
composer: json['composer'] as String?,
|
||||
arranger: json['arranger'] as String?,
|
||||
lyrics: json['lyrics'] as String?,
|
||||
musicVideoUrl: json['video_url'] as String?,
|
||||
videoType: json['video_type'] as String?,
|
||||
musicVideoUrl: json['music_video_url'] as String?,
|
||||
album: json['album'] != null ? TrackAlbum.fromJson(json['album']) : null,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,17 +45,6 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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>(
|
||||
future: _albumFuture,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -133,8 +122,27 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
|
|||
: album.tracks?.take(5).toList();
|
||||
|
||||
return CustomScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
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(
|
||||
child: _HeroSection(album: album, formatDate: _formatDate),
|
||||
|
|
@ -682,7 +690,6 @@ class _TrackItem extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 재생 시간
|
||||
Text(
|
||||
track.duration ?? '-',
|
||||
|
|
|
|||
|
|
@ -36,17 +36,6 @@ class _AlbumGalleryViewState extends State<AlbumGalleryView> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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>(
|
||||
future: _albumFuture,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -114,8 +103,26 @@ class _AlbumGalleryViewState extends State<AlbumGalleryView> {
|
|||
}
|
||||
|
||||
return CustomScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.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 '../../core/constants.dart';
|
||||
import '../../models/album.dart';
|
||||
|
|
@ -62,18 +61,7 @@ class _TrackDetailViewState extends State<TrackDetailView> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
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)),
|
||||
),
|
||||
backgroundColor: AppColors.background,
|
||||
body: FutureBuilder<TrackDetail>(
|
||||
future: _trackFuture,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -105,20 +93,34 @@ class _TrackDetailViewState extends State<TrackDetailView> {
|
|||
final youtubeVideoId = _getYoutubeVideoId(track.musicVideoUrl);
|
||||
|
||||
return CustomScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
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(
|
||||
child: _TrackHeader(track: track),
|
||||
),
|
||||
|
||||
// 뮤직비디오 / 스페셜 영상
|
||||
// 뮤직비디오
|
||||
if (youtubeVideoId != null)
|
||||
SliverToBoxAdapter(
|
||||
child: _MusicVideoSection(
|
||||
videoId: youtubeVideoId,
|
||||
trackTitle: track.title,
|
||||
videoType: track.videoType,
|
||||
onTap: () => _openYoutube(youtubeVideoId),
|
||||
),
|
||||
),
|
||||
|
|
@ -255,6 +257,8 @@ class _TrackHeader extends StatelessWidget {
|
|||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 재생 시간
|
||||
|
|
@ -282,17 +286,15 @@ class _TrackHeader extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 뮤직비디오 / 스페셜 영상 섹션
|
||||
/// 뮤직비디오 섹션
|
||||
class _MusicVideoSection extends StatelessWidget {
|
||||
final String videoId;
|
||||
final String trackTitle;
|
||||
final String? videoType;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MusicVideoSection({
|
||||
required this.videoId,
|
||||
required this.trackTitle,
|
||||
this.videoType,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
|
|
@ -315,9 +317,9 @@ class _MusicVideoSection extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
videoType == 'special' ? '스페셜 영상' : '뮤직비디오',
|
||||
style: const TextStyle(
|
||||
const Text(
|
||||
'뮤직비디오',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
|
@ -325,36 +327,59 @@ class _MusicVideoSection extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 영상 플레이어
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: OmniVideoPlayer(
|
||||
configuration: VideoPlayerConfiguration(
|
||||
videoSourceConfiguration: VideoSourceConfiguration.youtube(
|
||||
videoUrl: Uri.parse('https://www.youtube.com/watch?v=$videoId'),
|
||||
preferredQualities: [OmniVideoQuality.high720],
|
||||
// 썸네일
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -411,7 +411,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
|||
child: Transform.translate(
|
||||
offset: itemSlide.value,
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/album/${album.folderName}'),
|
||||
onTap: () => context.go('/album/${album.folderName}'),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
|
|
|
|||
|
|
@ -2,52 +2,23 @@
|
|||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../core/constants.dart';
|
||||
|
||||
/// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠)
|
||||
class MainShell extends StatefulWidget {
|
||||
class MainShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const MainShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
DateTime? _lastBackPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = widget.child;
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
final isMembersPage = location == '/members';
|
||||
final isSchedulePage = location.startsWith('/schedule');
|
||||
|
||||
return PopScope(
|
||||
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(
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
// 앱바 (툴바) - 일정 페이지는 자체 툴바 사용, 멤버 페이지는 그림자 제거
|
||||
appBar: isSchedulePage
|
||||
|
|
@ -89,7 +60,6 @@ class _MainShellState extends State<MainShell> {
|
|||
body: child,
|
||||
// 바텀 네비게이션
|
||||
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,
|
||||
X: 3,
|
||||
CONCERT: 6,
|
||||
VARIETY: 10,
|
||||
BIRTHDAY: 8,
|
||||
DEBUT: 9,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,344 +13,6 @@ const CONCERT_CATEGORY_ID = CATEGORY_IDS.CONCERT;
|
|||
export default async function concertRoutes(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
|
||||
* 콘서트 일정 저장 (multipart/form-data)
|
||||
|
|
@ -364,7 +26,7 @@ export default async function concertRoutes(fastify) {
|
|||
let title = '';
|
||||
let memberIds = [];
|
||||
let rounds = [];
|
||||
let setlists = []; // 회차별 세트리스트 (배열의 배열)
|
||||
let setlist = [];
|
||||
let posterBuffer = null;
|
||||
const merchandiseBuffers = [];
|
||||
|
||||
|
|
@ -381,8 +43,7 @@ export default async function concertRoutes(fastify) {
|
|||
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 === 'setlist') setlist = JSON.parse(part.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -464,29 +125,26 @@ export default async function concertRoutes(fastify) {
|
|||
}
|
||||
}
|
||||
|
||||
// 4. 회차별 세트리스트 저장
|
||||
for (let roundIdx = 0; roundIdx < concertIds.length; roundIdx++) {
|
||||
const concertId = concertIds[roundIdx];
|
||||
const roundSetlist = setlists[roundIdx] || setlists[0] || [];
|
||||
// 4. 세트리스트 (첫 번째 concert_id 기준으로 저장)
|
||||
const primaryConcertId = concertIds[0];
|
||||
|
||||
for (let i = 0; i < roundSetlist.length; i++) {
|
||||
const song = roundSetlist[i];
|
||||
if (!song.songName || !song.songName.trim()) continue;
|
||||
for (let i = 0; i < setlist.length; i++) {
|
||||
const song = setlist[i];
|
||||
if (!song.songName || !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]
|
||||
const [setlistResult] = await conn.query(
|
||||
'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)',
|
||||
[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,
|
||||
uploadAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
import { invalidateAlbumCache } from '../../services/album.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { notFound } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
|
@ -13,7 +12,7 @@ import { logActivity } from '../../utils/log.js';
|
|||
* GET: 공개, POST/DELETE: 인증 필요
|
||||
*/
|
||||
export default async function photosRoutes(fastify) {
|
||||
const { db, redis } = fastify;
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/albums/:albumId/photos
|
||||
|
|
@ -197,9 +196,6 @@ export default async function photosRoutes(fastify) {
|
|||
|
||||
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})` });
|
||||
|
||||
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_photos WHERE id = ?', [photoId]);
|
||||
|
||||
// 앨범 캐시 무효화
|
||||
await invalidateAlbumCache(redis, parseInt(albumId));
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'photo', targetId: parseInt(photoId), summary: `사진 삭제: 앨범 ${albumId}` });
|
||||
return { message: '사진이 삭제되었습니다.' };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {
|
|||
deleteAlbumPhoto,
|
||||
deleteAlbumVideo,
|
||||
} from '../../services/image.js';
|
||||
import { invalidateAlbumCache } from '../../services/album.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { notFound } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
|
@ -12,7 +11,7 @@ import { logActivity } from '../../utils/log.js';
|
|||
* GET: 공개, DELETE: 인증 필요
|
||||
*/
|
||||
export default async function teasersRoutes(fastify) {
|
||||
const { db, redis } = fastify;
|
||||
const { db } = fastify;
|
||||
|
||||
/**
|
||||
* 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 invalidateAlbumCache(redis, parseInt(albumId));
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'teaser', targetId: parseInt(teaserId), summary: `티저 삭제: 앨범 ${albumId}` });
|
||||
return { message: '티저가 삭제되었습니다.' };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import xBotsRoutes from './admin/x-bots.js';
|
|||
import youtubeAdminRoutes from './admin/youtube.js';
|
||||
import xAdminRoutes from './admin/x.js';
|
||||
import concertAdminRoutes from './admin/concert.js';
|
||||
import varietyAdminRoutes from './admin/variety.js';
|
||||
import placesAdminRoutes from './admin/places.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(varietyAdminRoutes, { prefix: '/admin/variety' });
|
||||
|
||||
// 관리자 - 장소 검색 라우트
|
||||
fastify.register(placesAdminRoutes, { prefix: '/admin' });
|
||||
|
||||
|
|
|
|||
|
|
@ -256,23 +256,3 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) {
|
|||
|
||||
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} 포맷된 일정 객체
|
||||
*/
|
||||
export function formatSchedule(rawSchedule, members = []) {
|
||||
const result = {
|
||||
return {
|
||||
id: rawSchedule.id,
|
||||
title: rawSchedule.title,
|
||||
date: normalizeDate(rawSchedule.date),
|
||||
|
|
@ -88,10 +88,6 @@ export function formatSchedule(rawSchedule, members = []) {
|
|||
source: buildSource(rawSchedule),
|
||||
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.username as x_username,
|
||||
sx.content as x_content,
|
||||
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
|
||||
sx.image_urls as x_image_urls
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id
|
||||
LEFT JOIN images svi ON sv.thumbnail_id = svi.id
|
||||
WHERE s.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;
|
||||
|
|
@ -309,13 +296,11 @@ const SCHEDULE_LIST_SQL = `
|
|||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id,
|
||||
sx.username as x_username,
|
||||
scon.series_id as concert_series_id
|
||||
sx.username as x_username
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_concert scon ON s.id = scon.schedule_id
|
||||
`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,25 +1,13 @@
|
|||
/**
|
||||
* 콘서트 관리자 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) {
|
||||
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 { ChevronLeft } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 모바일 헤더 컴포넌트
|
||||
* @param {string} title - 페이지 제목 (없으면 fromis_9)
|
||||
* @param {boolean} noShadow - 그림자 숨김 여부
|
||||
* @param {boolean} showBack - 뒤로가기 버튼 표시 여부
|
||||
*/
|
||||
function MobileHeader({ title, noShadow = false, showBack = false }) {
|
||||
function MobileHeader({ title, noShadow = false }) {
|
||||
return (
|
||||
<header
|
||||
className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}
|
||||
>
|
||||
<div className="flex items-center h-14 px-2">
|
||||
{showBack ? (
|
||||
<button onClick={() => window.history.back()} className="p-2 rounded-lg active:bg-gray-100 text-gray-600">
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
<div className="flex items-center justify-center h-14 px-4">
|
||||
{title ? (
|
||||
<span className="text-xl font-bold text-primary">{title}</span>
|
||||
) : (
|
||||
<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>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ function MobileLayout({
|
|||
hideHeader = false,
|
||||
useCustomLayout = false,
|
||||
noShadow = false,
|
||||
showBack = false,
|
||||
}) {
|
||||
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
||||
useEffect(() => {
|
||||
|
|
@ -39,7 +38,7 @@ function MobileLayout({
|
|||
|
||||
return (
|
||||
<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>
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,19 +17,12 @@ import {
|
|||
/**
|
||||
* 카테고리별 수정 경로 반환
|
||||
*/
|
||||
export const getEditPath = (scheduleId, categoryName, schedule) => {
|
||||
export const getEditPath = (scheduleId, categoryName) => {
|
||||
switch (categoryName) {
|
||||
case '유튜브':
|
||||
return `/admin/schedule/${scheduleId}/edit/youtube`;
|
||||
case '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:
|
||||
return `/admin/schedule/${scheduleId}/edit`;
|
||||
}
|
||||
|
|
@ -141,7 +134,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
</a>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useParams, Link } from 'react-router-dom';
|
|||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play } from 'lucide-react';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||
import { getSchedule } from '@/api';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||
import Birthday from './Birthday';
|
||||
|
|
@ -39,7 +39,7 @@ function linkifyText(text) {
|
|||
// URL
|
||||
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
||||
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}
|
||||
</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 기본 섹션
|
||||
*/
|
||||
|
|
@ -731,8 +631,6 @@ function MobileScheduleDetail() {
|
|||
return <MobileYoutubeSection schedule={schedule} />;
|
||||
case 'X':
|
||||
return <MobileXSection schedule={schedule} />;
|
||||
case '예능':
|
||||
return <MobileVarietySection schedule={schedule} />;
|
||||
default:
|
||||
return <MobileDefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
@ -742,14 +640,10 @@ function MobileScheduleDetail() {
|
|||
<div className="mobile-layout-container bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-100">
|
||||
<div className="flex items-center h-14 px-2">
|
||||
<button onClick={() => window.history.back()} className="p-2 -ml-0.5 rounded-lg active:bg-gray-100 text-gray-600">
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
<span className="flex-1 text-center text-base font-bold" style={{ color: schedule.category?.color }}>
|
||||
<div className="flex items-center justify-center h-14 px-4">
|
||||
<span className="text-sm font-medium" style={{ color: schedule.category?.color }}>
|
||||
{schedule.category?.name}
|
||||
</span>
|
||||
<div className="w-9" />
|
||||
</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,412 +1,323 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { Plus, Trash2, Users, Search, Copy, ChevronDown } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
import SongSearchDialog from "./SongSearchDialog";
|
||||
|
||||
/**
|
||||
* 세트리스트 섹션 (회차별 탭)
|
||||
* - 회차별로 독립적인 세트리스트
|
||||
* - 다른 회차에서 복사 기능
|
||||
*/
|
||||
function SetlistSection({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) {
|
||||
const containerRef = useRef(null);
|
||||
const [activeRoundId, setActiveRoundId] = useState(rounds[0]?.id || 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({
|
||||
isOpen: false,
|
||||
songId: null,
|
||||
songName: null,
|
||||
});
|
||||
|
||||
// 곡 검색 다이얼로그
|
||||
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 newSong = {
|
||||
id: getNextId(),
|
||||
songName: "",
|
||||
albumName: "",
|
||||
memberIds: [...selectedMemberIds],
|
||||
};
|
||||
updateCurrentSetlist((prev) => [...prev, newSong]);
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 검색에서 선택한 곡 추가
|
||||
const addSongsFromSearch = (songs) => {
|
||||
let id = getNextId();
|
||||
const newSongs = songs.map((song) => ({
|
||||
id: id++,
|
||||
songName: song.songName,
|
||||
albumName: song.albumName,
|
||||
memberIds: [...selectedMemberIds],
|
||||
}));
|
||||
updateCurrentSetlist((prev) => [...prev, ...newSongs]);
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 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) => {
|
||||
if (setlist.length <= 1) return;
|
||||
const song = setlist.find((s) => s.id === id);
|
||||
if (song && (song.songName || song.albumName)) {
|
||||
setDeleteConfirm({ isOpen: true, songId: id, songName: song.songName || "제목 없음" });
|
||||
} else {
|
||||
removeSong(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 곡 삭제 실행
|
||||
const removeSong = (id) => {
|
||||
updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteConfirm.songId !== null) {
|
||||
removeSong(deleteConfirm.songId);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, songId: null, songName: null });
|
||||
};
|
||||
|
||||
// 곡 필드 업데이트
|
||||
const updateSong = (id, field, value) => {
|
||||
updateCurrentSetlist((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||
);
|
||||
};
|
||||
|
||||
// 곡별 멤버 토글
|
||||
const toggleSongMember = (songId, memberId) => {
|
||||
updateCurrentSetlist((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== songId) return s;
|
||||
const has = s.memberIds.includes(memberId);
|
||||
return {
|
||||
...s,
|
||||
memberIds: has
|
||||
? s.memberIds.filter((id) => id !== memberId)
|
||||
: [...s.memberIds, memberId],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 곡별 멤버 전체 선택/해제
|
||||
const toggleAllSongMembers = (songId) => {
|
||||
updateCurrentSetlist((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== songId) return s;
|
||||
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
||||
return {
|
||||
...s,
|
||||
memberIds: allSelected ? [] : members.map((m) => m.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 현재 활성 회차의 인덱스
|
||||
const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, songId: null, songName: null })}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="곡 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.songName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<SongSearchDialog
|
||||
isOpen={songSearchOpen}
|
||||
onClose={() => setSongSearchOpen(false)}
|
||||
onSelect={addSongsFromSearch}
|
||||
albums={albums}
|
||||
/>
|
||||
|
||||
<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">세트리스트</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">
|
||||
{setlist.map((song, index) => (
|
||||
<div key={song.id}>
|
||||
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{index + 1}번 곡
|
||||
</span>
|
||||
{setlist.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSong(song.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 곡명 & 앨범명 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
곡명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={song.songName}
|
||||
onChange={(e) => updateSong(song.id, "songName", e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
앨범명 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={song.albumName}
|
||||
onChange={(e) => updateSong(song.id, "albumName", e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참여 멤버 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<Users size={14} />
|
||||
참여 멤버
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllSongMembers(song.id)}
|
||||
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))
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{members.every((m) => song.memberIds.includes(m.id))
|
||||
? "전체 해제"
|
||||
: "전체 선택"}
|
||||
</button>
|
||||
{members.map((member) => {
|
||||
const isSelected = song.memberIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
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 ${
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSongSearchOpen(true)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Search size={14} />
|
||||
곡 검색
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSong}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
직접 입력
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{rounds.length > 1
|
||||
? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요."
|
||||
: "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetlistSection;
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plus, Trash2, Users, Search } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
import SongSearchDialog from "./SongSearchDialog";
|
||||
|
||||
/**
|
||||
* 세트리스트 섹션
|
||||
* - 곡 추가/삭제
|
||||
* - 곡명, 앨범명, 참여 멤버
|
||||
* - 순서 자동 부여
|
||||
*/
|
||||
function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, albums }) {
|
||||
const containerRef = useRef(null);
|
||||
const [nextId, setNextId] = useState(() => {
|
||||
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0);
|
||||
return maxId + 1;
|
||||
});
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
songId: null,
|
||||
songName: null,
|
||||
});
|
||||
|
||||
// 곡 검색 다이얼로그
|
||||
const [songSearchOpen, setSongSearchOpen] = useState(false);
|
||||
|
||||
// 직접 입력 곡 추가
|
||||
const addSong = () => {
|
||||
const newSong = {
|
||||
id: nextId,
|
||||
songName: "",
|
||||
albumName: "",
|
||||
memberIds: [...selectedMemberIds],
|
||||
};
|
||||
setSetlist((prev) => [...prev, newSong]);
|
||||
setNextId(nextId + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 검색에서 선택한 곡 추가
|
||||
const addSongsFromSearch = (songs) => {
|
||||
let id = nextId;
|
||||
const newSongs = songs.map((song) => ({
|
||||
id: id++,
|
||||
songName: song.songName,
|
||||
albumName: song.albumName,
|
||||
memberIds: [...selectedMemberIds],
|
||||
}));
|
||||
setSetlist((prev) => [...prev, ...newSongs]);
|
||||
setNextId(id);
|
||||
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
const lastChild = containerRef.current.lastElementChild;
|
||||
if (lastChild) {
|
||||
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 곡 삭제 시도
|
||||
const handleRemoveSong = (id) => {
|
||||
if (setlist.length <= 1) return;
|
||||
|
||||
const song = setlist.find((s) => s.id === id);
|
||||
|
||||
if (song && (song.songName || song.albumName)) {
|
||||
setDeleteConfirm({
|
||||
isOpen: true,
|
||||
songId: id,
|
||||
songName: song.songName || "제목 없음",
|
||||
});
|
||||
} else {
|
||||
removeSong(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 곡 삭제 실행
|
||||
const removeSong = (id) => {
|
||||
setSetlist((prev) => prev.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteConfirm.songId !== null) {
|
||||
removeSong(deleteConfirm.songId);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, songId: null, songName: null });
|
||||
};
|
||||
|
||||
// 곡 필드 업데이트
|
||||
const updateSong = (id, field, value) => {
|
||||
setSetlist((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||
);
|
||||
};
|
||||
|
||||
// 곡별 멤버 토글
|
||||
const toggleSongMember = (songId, memberId) => {
|
||||
setSetlist((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== songId) return s;
|
||||
const has = s.memberIds.includes(memberId);
|
||||
return {
|
||||
...s,
|
||||
memberIds: has
|
||||
? s.memberIds.filter((id) => id !== memberId)
|
||||
: [...s.memberIds, memberId],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 곡별 멤버 전체 선택/해제
|
||||
const toggleAllSongMembers = (songId) => {
|
||||
setSetlist((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== songId) return s;
|
||||
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
||||
return {
|
||||
...s,
|
||||
memberIds: allSelected ? [] : members.map((m) => m.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, songId: null, songName: null })
|
||||
}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="곡 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.songName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<SongSearchDialog
|
||||
isOpen={songSearchOpen}
|
||||
onClose={() => setSongSearchOpen(false)}
|
||||
onSelect={addSongsFromSearch}
|
||||
albums={albums}
|
||||
/>
|
||||
|
||||
<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 ref={containerRef} className="flex flex-col gap-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{setlist.map((song, index) => (
|
||||
<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="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{index + 1}번 곡
|
||||
</span>
|
||||
{setlist.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSong(song.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 곡명 & 앨범명 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
곡명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={song.songName}
|
||||
onChange={(e) =>
|
||||
updateSong(song.id, "songName", e.target.value)
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
앨범명 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={song.albumName}
|
||||
onChange={(e) =>
|
||||
updateSong(song.id, "albumName", e.target.value)
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참여 멤버 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<Users size={14} />
|
||||
참여 멤버
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 전체 선택 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllSongMembers(song.id)}
|
||||
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)
|
||||
)
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{members.every((m) =>
|
||||
song.memberIds.includes(m.id)
|
||||
)
|
||||
? "전체 해제"
|
||||
: "전체 선택"}
|
||||
</button>
|
||||
|
||||
{members.map((member) => {
|
||||
const isSelected = song.memberIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
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 ${
|
||||
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>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSongSearchOpen(true)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<Search size={14} />
|
||||
곡 검색
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSong}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
직접 입력
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은
|
||||
개별 조정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetlistSection;
|
||||
|
|
|
|||
|
|
@ -48,49 +48,14 @@ function ConcertForm() {
|
|||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||
|
||||
// 공연 일정 (다회차)
|
||||
const [rounds, setRoundsRaw] = useState([
|
||||
const [rounds, setRounds] = useState([
|
||||
{ id: 1, date: "", time: "", venue: null },
|
||||
]);
|
||||
|
||||
// 회차 변경 시 세트리스트 동기화
|
||||
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]) {
|
||||
// 마지막 회차의 세트리스트를 복사 (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 [setlist, setSetlist] = useState([
|
||||
{ id: 1, songName: "", albumName: "", memberIds: [] },
|
||||
]);
|
||||
|
||||
// 굿즈 이미지
|
||||
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
||||
|
|
@ -175,18 +140,14 @@ function ConcertForm() {
|
|||
}));
|
||||
formData.append("rounds", JSON.stringify(roundsData));
|
||||
|
||||
// 회차별 세트리스트 (rounds 순서에 맞춰 배열로 전송)
|
||||
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));
|
||||
// 세트리스트
|
||||
const validSetlist = setlist.filter((s) => s.songName?.trim());
|
||||
const setlistData = validSetlist.map((s) => ({
|
||||
songName: s.songName.trim(),
|
||||
albumName: s.albumName?.trim() || null,
|
||||
memberIds: s.memberIds || [],
|
||||
}));
|
||||
formData.append("setlist", JSON.stringify(setlistData));
|
||||
|
||||
// 굿즈 이미지
|
||||
merchandiseItems.forEach((item) => {
|
||||
|
|
@ -242,9 +203,8 @@ function ConcertForm() {
|
|||
|
||||
{/* 세트리스트 */}
|
||||
<SetlistSection
|
||||
rounds={rounds}
|
||||
setlists={setlists}
|
||||
setSetlists={setSetlists}
|
||||
setlist={setlist}
|
||||
setSetlist={setSetlist}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
albums={albumsData}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Home, ChevronRight } from "lucide-react";
|
||||
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 XForm from "./XForm";
|
||||
import ConcertForm from "./concert";
|
||||
import VarietyForm from "./VarietyForm";
|
||||
|
||||
// 애니메이션 variants
|
||||
const containerVariants = {
|
||||
|
|
@ -39,28 +37,32 @@ function ScheduleFormPage() {
|
|||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [loading, setLoading] = 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(() => {
|
||||
if (categories.length > 0 && !selectedCategory) {
|
||||
setSelectedCategory(categories[0].id);
|
||||
}
|
||||
}, [categories, selectedCategory]);
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const fetchCategories = async () => {
|
||||
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 = () => {
|
||||
|
|
@ -76,9 +78,6 @@ function ScheduleFormPage() {
|
|||
case '콘서트':
|
||||
return <ConcertForm />;
|
||||
|
||||
case '예능':
|
||||
return <VarietyForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||
default:
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
|||
import { getSchedule } from '@/api';
|
||||
|
||||
// 섹션 컴포넌트들
|
||||
import { YoutubeSection, XSection, VarietySection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
/**
|
||||
|
|
@ -153,8 +153,6 @@ function PCScheduleDetail() {
|
|||
return <YoutubeSection schedule={schedule} />;
|
||||
case 'X':
|
||||
return <XSection schedule={schedule} />;
|
||||
case '예능':
|
||||
return <VarietySection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
@ -162,8 +160,7 @@ function PCScheduleDetail() {
|
|||
|
||||
const isYoutube = categoryName === '유튜브';
|
||||
const isX = categoryName === 'X';
|
||||
const isVariety = categoryName === '예능';
|
||||
const hasCustomLayout = isYoutube || isX || isVariety;
|
||||
const hasCustomLayout = isYoutube || isX;
|
||||
|
||||
return (
|
||||
<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 {
|
||||
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export { default as YoutubeSection } from './YoutubeSection';
|
||||
export { default as XSection } from './XSection';
|
||||
export { default as VarietySection } from './VarietySection';
|
||||
export { default as DefaultSection } from './DefaultSection';
|
||||
export * from './utils';
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export default function MobileRoutes() {
|
|||
<Route
|
||||
path="/album/:name"
|
||||
element={
|
||||
<Layout pageTitle="앨범" showBack>
|
||||
<Layout pageTitle="앨범">
|
||||
<AlbumDetail />
|
||||
</Layout>
|
||||
}
|
||||
|
|
@ -73,7 +73,7 @@ export default function MobileRoutes() {
|
|||
<Route
|
||||
path="/album/:name/track/:trackTitle"
|
||||
element={
|
||||
<Layout pageTitle="앨범" showBack>
|
||||
<Layout pageTitle="앨범">
|
||||
<TrackDetail />
|
||||
</Layout>
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ export default function MobileRoutes() {
|
|||
<Route
|
||||
path="/album/:name/gallery"
|
||||
element={
|
||||
<Layout pageTitle="컨셉 포토" showBack>
|
||||
<Layout pageTitle="앨범">
|
||||
<AlbumGallery />
|
||||
</Layout>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
|||
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||
import 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 AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||
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/:id/edit" element={<RequireAuth><AdminScheduleForm /></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/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue