Compare commits

..

No commits in common. "aae606725fb01d0257923ed324bf9166b5d63797" and "812478bc37192c7d18e05bff42f9ccfe03493f4e" have entirely different histories.

34 changed files with 527 additions and 2441 deletions

View file

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

View file

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

View file

@ -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 ?? '-',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@ export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
CONCERT: 6,
VARIETY: 10,
BIRTHDAY: 8,
DEBUT: 9,
};

View file

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

View file

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

View file

@ -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: '사진이 삭제되었습니다.' };
});

View file

@ -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: '티저가 삭제되었습니다.' };
});

View file

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

View file

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

View file

@ -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
`;
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,40 +1,22 @@
import { useState, useRef, useEffect } from "react";
import { Plus, Trash2, Users, Search, Copy, ChevronDown } from "lucide-react";
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({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) {
function SetlistSection({ setlist, setSetlist, 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 [nextId, setNextId] = useState(() => {
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0);
return maxId + 1;
});
//
const [deleteConfirm, setDeleteConfirm] = useState({
@ -46,35 +28,16 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
//
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(),
id: nextId,
songName: "",
albumName: "",
memberIds: [...selectedMemberIds],
};
updateCurrentSetlist((prev) => [...prev, newSong]);
setSetlist((prev) => [...prev, newSong]);
setNextId(nextId + 1);
setTimeout(() => {
if (containerRef.current) {
@ -88,14 +51,15 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
//
const addSongsFromSearch = (songs) => {
let id = getNextId();
let id = nextId;
const newSongs = songs.map((song) => ({
id: id++,
songName: song.songName,
albumName: song.albumName,
memberIds: [...selectedMemberIds],
}));
updateCurrentSetlist((prev) => [...prev, ...newSongs]);
setSetlist((prev) => [...prev, ...newSongs]);
setNextId(id);
setTimeout(() => {
if (containerRef.current) {
@ -107,25 +71,18 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
}, 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 || "제목 없음" });
setDeleteConfirm({
isOpen: true,
songId: id,
songName: song.songName || "제목 없음",
});
} else {
removeSong(id);
}
@ -133,7 +90,7 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
//
const removeSong = (id) => {
updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id));
setSetlist((prev) => prev.filter((s) => s.id !== id));
};
//
@ -146,14 +103,14 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
//
const updateSong = (id, field, value) => {
updateCurrentSetlist((prev) =>
setSetlist((prev) =>
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
);
};
//
const toggleSongMember = (songId, memberId) => {
updateCurrentSetlist((prev) =>
setSetlist((prev) =>
prev.map((s) => {
if (s.id !== songId) return s;
const has = s.memberIds.includes(memberId);
@ -169,7 +126,7 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
// /
const toggleAllSongMembers = (songId) => {
updateCurrentSetlist((prev) =>
setSetlist((prev) =>
prev.map((s) => {
if (s.id !== songId) return s;
const allSelected = members.every((m) => s.memberIds.includes(m.id));
@ -181,14 +138,13 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
);
};
//
const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId);
return (
<>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, songId: null, songName: null })}
onClose={() =>
setDeleteConfirm({ isOpen: false, songId: null, songName: null })
}
onConfirm={handleConfirmDelete}
title="곡 삭제"
message={
@ -209,83 +165,18 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
/>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div className="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>
)}
<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) => (
<div key={song.id}>
<motion.div
key={song.id}
initial={{ opacity: 0, scale: 0.98, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: -8 }}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
@ -312,7 +203,9 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
<input
type="text"
value={song.songName}
onChange={(e) => updateSong(song.id, "songName", e.target.value)}
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"
/>
@ -324,7 +217,9 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
<input
type="text"
value={song.albumName}
onChange={(e) => updateSong(song.id, "albumName", e.target.value)}
onChange={(e) =>
updateSong(song.id, "albumName", e.target.value)
}
placeholder="예: Unlock My World"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
@ -338,46 +233,63 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
참여 멤버
</label>
<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))
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))
{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)}
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"
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" />
<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>
<span className="text-sm text-gray-700">
{member.name}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<div className="flex gap-2 mt-4">
@ -400,9 +312,8 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
</div>
<p className="text-xs text-gray-400 mt-3">
{rounds.length > 1
? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요."
: "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."}
추가 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은
개별 조정하세요.
</p>
</div>
</>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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