Compare commits

..

36 commits

Author SHA1 Message Date
aae606725f feat(mobile): 앨범/곡/컨셉포토 상세 페이지 뒤로가기 버튼 추가
- MobileHeader에 showBack prop 추가 (ChevronLeft + history.back)
- Layout에 showBack 전달
- 라우터에서 상세 페이지에 showBack 적용
- 개별 페이지의 중복 액션바 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:16:19 +09:00
901d788d9a feat(mobile): 일정 상세 페이지 액션바에 뒤로가기 버튼 추가
- ChevronLeft 아이콘 + history.back()
- 오른쪽 w-9 빈 영역으로 제목 중앙 정렬 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:11:35 +09:00
1626091539 style(mobile): 일정 상세 액션바 카테고리 텍스트 font-bold로 변경
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:09:38 +09:00
aaec7fa071 style(mobile): 예능 썸네일 블러 배경 + 높이 축소
- 높이 h-52로 고정
- 배경에 동일 이미지를 scale-110 blur-2xl로 깔아서 빈 공간 채움
- 메인 이미지는 object-contain으로 원본 비율 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:07:46 +09:00
45819f7a01 style(pc): 예능 상세 썸네일/정보 가로 배치, 높이 독립
- 썸네일: w-52 h-72 고정, object-cover
- 정보 카드: flex-1, 콘텐츠에 따라 높이 자유
- items-start로 서로 높이 영향 없음

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:05:37 +09:00
de3adc0ad6 refactor: 예능 상세 페이지 썸네일/정보 분리 레이아웃
- 가로 flex → 세로 분리 (썸네일 카드 + 정보 카드)
- 썸네일: object-contain으로 원본 비율 유지, max-h 제한
- 기본 이미지: 카테고리 색상 배경 + Tv 아이콘
- 제목이 길어져도 레이아웃 깨지지 않음
- PC/모바일 모두 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:03:48 +09:00
2a1849f608 feat: 예능 썸네일 없을 때 카테고리 색상 기본 이미지 표시
- 썸네일 없으면 카테고리 색상 배경 + Tv 아이콘 표시
- PC/모바일 모두 적용
- 항상 flex 레이아웃 유지 (썸네일 유무와 무관)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:58:57 +09:00
4bc188b4f9 feat: 모바일 예능 일정 상세 섹션 추가
- MobileVarietySection: 썸네일(w-32) + 콘텐츠 flex 레이아웃
- 방송사 뱃지, 제목, 멤버 칩, 다시보기 버튼
- PC 버전과 동일한 구조, 모바일 사이즈에 맞게 축소

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:57:03 +09:00
83dd852ffb style: 예능 다시보기 버튼을 다크 스타일로 변경
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:54:49 +09:00
de1399579f style: 예능 상세 텍스트 크기 전체적으로 확대
- 방송사 뱃지/날짜: text-xs → text-sm
- 제목: text-lg → text-xl
- 멤버 칩: text-xs → text-sm, 패딩 증가
- 다시보기 버튼: text-xs → text-sm, 패딩 증가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:53:11 +09:00
b1d890d758 style: 예능 상세 레이아웃 상단/하단 분리
- 방송사+날짜, 제목, 멤버: 상단에 붙임
- 다시보기 버튼: mt-auto로 하단에 고정
- justify-between으로 세로 공간 분배

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:52:12 +09:00
c990af41e1 style: 예능 상세 페이지 디자인 정리
- 썸네일 hover 효과 제거, 너비 w-44로 축소
- 다시보기 버튼: 멤버 칩과 같은 줄에 compact하게 배치 (회색 pill)
- 불필요한 border-t 구분선 제거
- 전체적으로 compact한 레이아웃

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:51:09 +09:00
fcbda88464 fix: 예능 상세 썸네일을 세로 포스터 레이아웃으로 변경
- 가로 전체 → 왼쪽 세로 포스터(w-52) + 오른쪽 콘텐츠 (flex 레이아웃)
- 세로 이미지가 잘리지 않도록 object-cover + 고정 너비

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:48:12 +09:00
36854a223f style: PC 예능 상세 페이지 디자인 개선
- 썸네일: aspect-video 비율, hover 시 확대 + 재생 버튼 오버레이
- 방송사 뱃지: 카테고리 색상 기반, 날짜와 한 줄로 배치
- 다시보기 버튼: 다크 pill 스타일, 유튜브면 Play 아이콘
- 전체 카드 레이아웃으로 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:46:31 +09:00
6feedae267 feat: PC 예능 일정 상세 페이지 추가
- VarietySection: 썸네일, 방송사 뱃지, 제목, 날짜/시간, 멤버, 다시보기 버튼
- ScheduleDetail에서 '예능' 카테고리 분기 연결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:45:14 +09:00
c14bd90e89 fix: 카테고리 API를 React Query로 변경 (중복 호출 방지)
- useEffect + useState → useQuery로 변경
- staleTime 10분으로 캐시하여 중복 요청 제거
- 카테고리 색상 Redis 캐시도 삭제 (DB 변경 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:40:17 +09:00
48ed3bb9e0 feat(variety): 방송사 프리셋을 입력 빈도수 기반으로 변경
- GET /admin/variety/broadcasters: DB에서 빈도수 상위 10개 조회 (Redis 1시간 캐시)
- 일정 생성/수정 시 캐시 무효화
- 프론트엔드: 하드코딩 프리셋 제거, API에서 동적으로 로드

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:37:52 +09:00
a01d368728 feat(variety): 썸네일을 RustFS 이미지 업로드로 변경
- schedule/{id}/thumbnail/ 경로에 original/medium_800/thumb_400 webp 업로드
- images 테이블로 이미지 관리, schedule_variety.thumbnail_id로 참조
- 프론트엔드: URL 입력 → 파일 업로드(드래그&드롭) + 미리보기로 변경
- 수정 시 기존 썸네일 교체/삭제 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:07:08 +09:00
c88eb1fb60 fix(variety): 날짜/시간 입력을 커스텀 DatePicker/TimePicker로 변경
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:45:28 +09:00
73f84fd7ac feat: 예능 카테고리 관리 기능 구현
- 백엔드: POST/PUT/GET /admin/variety/schedule API
- 백엔드: 일정 상세 응답에 broadcaster, replayUrl, thumbnailUrl 포함
- 프론트엔드: VarietyForm (추가), VarietyEditForm (수정) 페이지
- 방송사 프리셋 버튼 (KBS, MBC, SBS, tvN, 유튜브, 티빙 등)
- 출연 멤버 선택, 다시보기 링크, 썸네일 URL 지원
- 라우트 등록 및 일정 목록 편집 링크 연결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:16:16 +09:00
96969e1bf0 feat: 예능 카테고리 및 schedule_variety 테이블 추가
- schedule_variety: broadcaster, replay_url, thumbnail_url
- 카테고리 ID 10 '예능' (#22c55e) 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:08:56 +09:00
9375a78d63 feat(app): 뒤로가기 두 번 눌러서 앱 종료
메인 화면에서 뒤로가기 1회 → '한 번 더 누르면 앱이 종료됩니다' 스낵바
2초 내 다시 누르면 앱 종료

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:11:53 +09:00
dd15958e90 fix(app): 곡 상세 화면 카드 배경 제거, 웹과 동일하게 변경
- 각 섹션 카드(Container + 흰색 배경) → Padding으로 원복
- Scaffold 배경을 흰색으로 변경 (웹과 동일)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:42:37 +09:00
68a0f89816 fix(app): 곡 상세 화면 UI 개선
- 액션바 제목 '트랙' → '앨범'
- YouTube 버튼: 빨간 배경 + 흰색 글씨로 변경
- 각 섹션(헤더, 영상, 크레딧, 가사)에 흰색 카드 배경 추가
- 앨범 이름 ellipsis 제거 (줄바꿈 허용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:14:36 +09:00
45e69a9b9c fix(app): 홈에서 앨범 상세 이동 시 뒤로가기 안 되는 버그 수정
context.go() → context.push()로 변경.
go()는 네비게이션 스택을 교체하므로 뒤로가기가 불가했음.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:11:18 +09:00
aa750b19d4 feat(app): 곡 상세 화면 뮤직비디오 앱 내 재생 + YouTube 이동 버튼
- 썸네일+재생 버튼 → OmniVideoPlayer로 교체 (앱 내 직접 재생)
- 하단에 'YouTube에서 보기' 텍스트 버튼 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:08:14 +09:00
5f70b6852f fix(app): 곡 상세 화면 뮤직비디오/스페셜 영상 구분 표시
video_type이 'special'이면 '스페셜 영상', 그 외는 '뮤직비디오'로 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:04:03 +09:00
574662b24d fix(app): 곡 상세 화면 뮤직비디오 표시 안 되는 문제 수정
TrackDetail.fromJson에서 music_video_url → video_url로 필드명 수정.
DB 컬럼명은 video_url인데 잘못된 키로 파싱하고 있었음.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:01:30 +09:00
af6b3e1654 style: X 게시글 하이퍼링크 색상을 파란색으로 변경 (해시태그와 구분)
- 해시태그: primary (초록)
- URL 링크: blue-500 (파란)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:36:03 +09:00
78b3b4121c fix(app): 앨범 상세/곡 상세/갤러리 화면 툴바 스크롤 시 늘어남 수정
- SliverAppBar에 stretch: false 명시
- CustomScrollView에 ClampingScrollPhysics 적용 (overscroll 방지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:52:01 +09:00
737247eec3 fix(app): 앨범 수록곡 TITLE 뱃지와 재생 시간 사이 여백 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:32:59 +09:00
9e3a71e156 fix: 사진/티저 업로드·삭제 시 앨범 캐시 무효화 누락 수정
photos.js, teasers.js에서 invalidateAlbumCache 호출 추가.
앨범 생성 후 사진/티저 추가 시 캐시된 빈 데이터가 반환되던 문제 해결.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:31:40 +09:00
ce41fc1a60 feat(concert): 콘서트 수정 페이지 추가
- 백엔드: GET /admin/concert/schedule/:seriesId (상세 조회)
- 백엔드: PUT /admin/concert/schedule/:seriesId (수정)
- 프론트엔드: ConcertEditForm 페이지 (생성 폼 컴포넌트 재사용)
- 라우트: /admin/schedule/concert/:seriesId/edit 등록
- 일정 목록: 콘서트 카테고리 편집 버튼이 수정 페이지로 연결
- schedule.js: formatSchedule에 concertSeriesId 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:25:01 +09:00
637172ddd7 style(concert): 네이티브 select를 커스텀 드롭다운으로 변경
- 선택된 회차 + 날짜 표시, 화살표 아이콘 회전 애니메이션
- 활성 항목 primary 하이라이트
- 외부 클릭 시 자동 닫기

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:09:26 +09:00
cdb94972a3 refactor(concert): 세트리스트 회차 선택을 탭에서 드롭다운으로 변경
- 탭 UI → select 드롭다운 (회차가 많아도 깔끔)
- 드롭다운에 날짜 표시 (예: "1회차 (2026-04-01)")
- 회차 전환 시 애니메이션 제거
- framer-motion 의존성 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:08:39 +09:00
8ccf18e8b1 feat(concert): 회차별 세트리스트 입력 지원
- 프론트엔드: 단일 setlist → 회차별 setlists (탭 UI로 전환)
- 회차 추가 시 이전 회차의 세트리스트 자동 복사
- '다른 회차에서 복사' 기능 추가
- 백엔드: 각 concert_id별로 독립적인 세트리스트 저장
- 하위호환: 기존 setlist 필드도 지원 (단일 배열 → 첫 회차에 적용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:03:08 +09:00
34 changed files with 2440 additions and 526 deletions

View file

@ -17,7 +17,8 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:enableOnBackInvokedCallback="false">
<!-- 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,6 +214,7 @@ class TrackDetail {
final String? arranger;
final String? lyrics;
final String? musicVideoUrl;
final String? videoType;
final TrackAlbum? album;
TrackDetail({
@ -227,6 +228,7 @@ class TrackDetail {
this.arranger,
this.lyrics,
this.musicVideoUrl,
this.videoType,
this.album,
});
@ -241,7 +243,8 @@ class TrackDetail {
composer: json['composer'] as String?,
arranger: json['arranger'] as String?,
lyrics: json['lyrics'] as String?,
musicVideoUrl: json['music_video_url'] as String?,
musicVideoUrl: json['video_url'] as String?,
videoType: json['video_type'] as String?,
album: json['album'] != null ? TrackAlbum.fromJson(json['album']) : null,
);
}

View file

@ -45,6 +45,17 @@ 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) {
@ -122,27 +133,8 @@ 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),
@ -690,6 +682,7 @@ class _TrackItem extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
//
Text(
track.duration ?? '-',

View file

@ -36,6 +36,17 @@ 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) {
@ -103,26 +114,8 @@ 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,6 +5,7 @@ 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';
@ -61,7 +62,18 @@ class _TrackDetailViewState extends State<TrackDetailView> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
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)),
),
body: FutureBuilder<TrackDetail>(
future: _trackFuture,
builder: (context, snapshot) {
@ -93,34 +105,20 @@ 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),
),
),
@ -257,8 +255,6 @@ class _TrackHeader extends StatelessWidget {
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
//
@ -286,15 +282,17 @@ 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,
});
@ -317,9 +315,9 @@ class _MusicVideoSection extends StatelessWidget {
),
),
const SizedBox(width: 8),
const Text(
'뮤직비디오',
style: TextStyle(
Text(
videoType == 'special' ? '스페셜 영상' : '뮤직비디오',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
@ -327,59 +325,36 @@ class _MusicVideoSection extends StatelessWidget {
],
),
const SizedBox(height: 12),
//
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,
),
),
),
),
],
//
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],
),
),
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,
),
),
),
@ -546,7 +521,7 @@ class _LyricsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 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.go('/album/${album.folderName}'),
onTap: () => context.push('/album/${album.folderName}'),
child: Container(
decoration: BoxDecoration(
color: Colors.white,

View file

@ -2,23 +2,52 @@
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 StatelessWidget {
class MainShell extends StatefulWidget {
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 Scaffold(
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(
backgroundColor: AppColors.background,
// () - ,
appBar: isSchedulePage
@ -60,6 +89,7 @@ class MainShell extends StatelessWidget {
body: child,
//
bottomNavigationBar: const _BottomNavBar(),
),
);
}

View file

@ -0,0 +1,12 @@
-- 예능 일정 상세 테이블
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,6 +3,7 @@ export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
CONCERT: 6,
VARIETY: 10,
BIRTHDAY: 8,
DEBUT: 9,
};

View file

@ -13,6 +13,344 @@ 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)
@ -26,7 +364,7 @@ export default async function concertRoutes(fastify) {
let title = '';
let memberIds = [];
let rounds = [];
let setlist = [];
let setlists = []; // 회차별 세트리스트 (배열의 배열)
let posterBuffer = null;
const merchandiseBuffers = [];
@ -43,7 +381,8 @@ 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 === 'setlist') setlist = JSON.parse(part.value);
else if (part.fieldname === 'setlists') setlists = JSON.parse(part.value);
else if (part.fieldname === 'setlist') setlists = [JSON.parse(part.value)]; // 하위호환
}
}
@ -125,26 +464,29 @@ export default async function concertRoutes(fastify) {
}
}
// 4. 세트리스트 (첫 번째 concert_id 기준으로 저장)
const primaryConcertId = concertIds[0];
// 4. 회차별 세트리스트 저장
for (let roundIdx = 0; roundIdx < concertIds.length; roundIdx++) {
const concertId = concertIds[roundIdx];
const roundSetlist = setlists[roundIdx] || setlists[0] || [];
for (let i = 0; i < setlist.length; i++) {
const song = setlist[i];
if (!song.songName || !song.songName.trim()) continue;
for (let i = 0; i < roundSetlist.length; i++) {
const song = roundSetlist[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 (?, ?, ?, ?)',
[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 [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 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

@ -0,0 +1,267 @@
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,6 +3,7 @@ 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';
@ -12,7 +13,7 @@ import { logActivity } from '../../utils/log.js';
* GET: 공개, POST/DELETE: 인증 필요
*/
export default async function photosRoutes(fastify) {
const { db } = fastify;
const { db, redis } = fastify;
/**
* GET /api/albums/:albumId/photos
@ -196,6 +197,9 @@ 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({
@ -248,6 +252,9 @@ 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,6 +2,7 @@ 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';
@ -11,7 +12,7 @@ import { logActivity } from '../../utils/log.js';
* GET: 공개, DELETE: 인증 필요
*/
export default async function teasersRoutes(fastify) {
const { db } = fastify;
const { db, redis } = fastify;
/**
* GET /api/albums/:albumId/teasers
@ -79,6 +80,8 @@ 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,6 +9,7 @@ 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';
@ -50,6 +51,9 @@ 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,3 +256,23 @@ 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 = []) {
return {
const result = {
id: rawSchedule.id,
title: rawSchedule.title,
date: normalizeDate(rawSchedule.date),
@ -88,6 +88,10 @@ export function formatSchedule(rawSchedule, members = []) {
source: buildSource(rawSchedule),
members,
};
if (rawSchedule.concert_series_id) {
result.concertSeriesId = rawSchedule.concert_series_id;
}
return result;
}
/**
@ -196,11 +200,16 @@ 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
sx.image_urls as x_image_urls,
sv.broadcaster as variety_broadcaster,
sv.replay_url as variety_replay_url,
svi.medium_url as variety_thumbnail_url
FROM schedules s
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]);
@ -275,6 +284,10 @@ 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;
@ -296,11 +309,13 @@ 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
sx.username as x_username,
scon.series_id as concert_series_id
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
LEFT JOIN schedule_concert scon ON s.id = scon.schedule_id
`;
/**

View file

@ -1,13 +1,25 @@
/**
* 콘서트 관리자 API
*/
import { fetchFormData } from '@/api/client';
import { fetchAuthApi, 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

@ -0,0 +1,32 @@
/**
* 예능 관리자 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,23 +1,35 @@
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 }) {
function MobileHeader({ title, noShadow = false, showBack = false }) {
return (
<header
className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}
>
<div className="flex items-center justify-center h-14 px-4">
{title ? (
<span className="text-xl font-bold text-primary">{title}</span>
<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>
) : (
<NavLink to="/" className="text-xl font-bold text-primary">
fromis_9
</NavLink>
<div className="w-9" />
)}
<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,6 +17,7 @@ function MobileLayout({
hideHeader = false,
useCustomLayout = false,
noShadow = false,
showBack = false,
}) {
// (body )
useEffect(() => {
@ -38,7 +39,7 @@ function MobileLayout({
return (
<div className="mobile-layout-container bg-white">
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} showBack={showBack} />}
<main className="mobile-content">{children}</main>
<MobileBottomNav />
</div>

View file

@ -17,12 +17,19 @@ import {
/**
* 카테고리별 수정 경로 반환
*/
export const getEditPath = (scheduleId, categoryName) => {
export const getEditPath = (scheduleId, categoryName, schedule) => {
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`;
}
@ -134,7 +141,7 @@ const ScheduleItem = memo(function ScheduleItem({
</a>
)}
<button
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name, schedule))}
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 } from 'lucide-react';
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play } 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-primary hover:underline">
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{matched}
</a>
);
@ -475,6 +475,106 @@ 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 기본 섹션
*/
@ -631,6 +731,8 @@ function MobileScheduleDetail() {
return <MobileYoutubeSection schedule={schedule} />;
case 'X':
return <MobileXSection schedule={schedule} />;
case '예능':
return <MobileVarietySection schedule={schedule} />;
default:
return <MobileDefaultSection schedule={schedule} />;
}
@ -640,10 +742,14 @@ 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 justify-center h-14 px-4">
<span className="text-sm font-medium" style={{ color: schedule.category?.color }}>
<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 }}>
{schedule.category?.name}
</span>
<div className="w-9" />
</div>
</div>

View file

@ -0,0 +1,305 @@
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

@ -0,0 +1,214 @@
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

@ -0,0 +1,326 @@
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,323 +1,412 @@
import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, Trash2, Users, Search } from "lucide-react";
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
import SongSearchDialog from "./SongSearchDialog";
/**
* 세트리스트 섹션
* - 추가/삭제
* - 곡명, 앨범명, 참여 멤버
* - 순서 자동 부여
*/
function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, albums }) {
const containerRef = useRef(null);
const [nextId, setNextId] = useState(() => {
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0);
return maxId + 1;
});
//
const [deleteConfirm, setDeleteConfirm] = useState({
isOpen: false,
songId: null,
songName: null,
});
//
const [songSearchOpen, setSongSearchOpen] = useState(false);
//
const addSong = () => {
const newSong = {
id: nextId,
songName: "",
albumName: "",
memberIds: [...selectedMemberIds],
};
setSetlist((prev) => [...prev, newSong]);
setNextId(nextId + 1);
setTimeout(() => {
if (containerRef.current) {
const lastChild = containerRef.current.lastElementChild;
if (lastChild) {
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, 100);
};
//
const addSongsFromSearch = (songs) => {
let id = nextId;
const newSongs = songs.map((song) => ({
id: id++,
songName: song.songName,
albumName: song.albumName,
memberIds: [...selectedMemberIds],
}));
setSetlist((prev) => [...prev, ...newSongs]);
setNextId(id);
setTimeout(() => {
if (containerRef.current) {
const lastChild = containerRef.current.lastElementChild;
if (lastChild) {
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, 100);
};
//
const handleRemoveSong = (id) => {
if (setlist.length <= 1) return;
const song = setlist.find((s) => s.id === id);
if (song && (song.songName || song.albumName)) {
setDeleteConfirm({
isOpen: true,
songId: id,
songName: song.songName || "제목 없음",
});
} else {
removeSong(id);
}
};
//
const removeSong = (id) => {
setSetlist((prev) => prev.filter((s) => s.id !== id));
};
//
const handleConfirmDelete = () => {
if (deleteConfirm.songId !== null) {
removeSong(deleteConfirm.songId);
}
setDeleteConfirm({ isOpen: false, songId: null, songName: null });
};
//
const updateSong = (id, field, value) => {
setSetlist((prev) =>
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
);
};
//
const toggleSongMember = (songId, memberId) => {
setSetlist((prev) =>
prev.map((s) => {
if (s.id !== songId) return s;
const has = s.memberIds.includes(memberId);
return {
...s,
memberIds: has
? s.memberIds.filter((id) => id !== memberId)
: [...s.memberIds, memberId],
};
})
);
};
// /
const toggleAllSongMembers = (songId) => {
setSetlist((prev) =>
prev.map((s) => {
if (s.id !== songId) return s;
const allSelected = members.every((m) => s.memberIds.includes(m.id));
return {
...s,
memberIds: allSelected ? [] : members.map((m) => m.id),
};
})
);
};
return (
<>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() =>
setDeleteConfirm({ isOpen: false, songId: null, songName: null })
}
onConfirm={handleConfirmDelete}
title="곡 삭제"
message={
<p>
<span className="font-medium">{deleteConfirm.songName}</span>
() 삭제하시겠습니까?
</p>
}
confirmText="삭제"
cancelText="취소"
/>
<SongSearchDialog
isOpen={songSearchOpen}
onClose={() => setSongSearchOpen(false)}
onSelect={addSongsFromSearch}
albums={albums}
/>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">세트리스트</h2>
<div ref={containerRef} className="flex flex-col gap-4">
<AnimatePresence initial={false}>
{setlist.map((song, index) => (
<motion.div
key={song.id}
initial={{ opacity: 0, scale: 0.98, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: -8 }}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
{index + 1}
</span>
{setlist.length > 1 && (
<button
type="button"
onClick={() => handleRemoveSong(song.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
)}
</div>
{/* 곡명 & 앨범명 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">
곡명 *
</label>
<input
type="text"
value={song.songName}
onChange={(e) =>
updateSong(song.id, "songName", e.target.value)
}
placeholder="예: Feel Good"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
앨범명 (선택)
</label>
<input
type="text"
value={song.albumName}
onChange={(e) =>
updateSong(song.id, "albumName", e.target.value)
}
placeholder="예: Unlock My World"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
{/* 참여 멤버 */}
<div>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<Users size={14} />
참여 멤버
</label>
<div className="flex flex-wrap gap-2">
{/* 전체 선택 버튼 */}
<button
type="button"
onClick={() => toggleAllSongMembers(song.id)}
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
members.every((m) =>
song.memberIds.includes(m.id)
)
? "border-primary bg-primary text-white"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{members.every((m) =>
song.memberIds.includes(m.id)
)
? "전체 해제"
: "전체 선택"}
</button>
{members.map((member) => {
const isSelected = song.memberIds.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() =>
toggleSongMember(song.id, member.id)
}
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
isSelected
? "border-primary"
: "border-gray-200"
}`}
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-300" />
)}
</div>
<span className="text-sm text-gray-700">
{member.name}
</span>
</button>
);
})}
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={() => setSongSearchOpen(true)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
>
<Search size={14} />
검색
</button>
<button
type="button"
onClick={addSong}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors"
>
<Plus size={14} />
직접 입력
</button>
</div>
<p className="text-xs text-gray-400 mt-3">
추가 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은
개별 조정하세요.
</p>
</div>
</>
);
}
export default SetlistSection;
import { useState, useRef, useEffect } from "react";
import { Plus, Trash2, Users, Search, Copy, ChevronDown } from "lucide-react";
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
import SongSearchDialog from "./SongSearchDialog";
/**
* 세트리스트 섹션 (회차별 )
* - 회차별로 독립적인 세트리스트
* - 다른 회차에서 복사 기능
*/
function SetlistSection({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) {
const containerRef = useRef(null);
const [activeRoundId, setActiveRoundId] = useState(rounds[0]?.id || 1);
//
const setlist = setlists[activeRoundId] || [];
//
useEffect(() => {
if (!rounds.find((r) => r.id === activeRoundId) && rounds.length > 0) {
setActiveRoundId(rounds[0].id);
}
}, [rounds, activeRoundId]);
// ID
const getNextId = () => {
return Object.values(setlists).flat().reduce((max, s) => Math.max(max, s.id || 0), 0) + 1;
};
//
const updateCurrentSetlist = (updater) => {
setSetlists((prev) => ({
...prev,
[activeRoundId]: typeof updater === 'function' ? updater(prev[activeRoundId] || []) : updater,
}));
};
//
const [deleteConfirm, setDeleteConfirm] = useState({
isOpen: false,
songId: null,
songName: null,
});
//
const [songSearchOpen, setSongSearchOpen] = useState(false);
//
const [copyFrom, setCopyFrom] = useState(null);
//
const [roundDropdownOpen, setRoundDropdownOpen] = useState(false);
const roundDropdownRef = useRef(null);
//
useEffect(() => {
const handleClickOutside = (e) => {
if (roundDropdownRef.current && !roundDropdownRef.current.contains(e.target)) {
setRoundDropdownOpen(false);
}
};
if (roundDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [roundDropdownOpen]);
//
const addSong = () => {
const newSong = {
id: getNextId(),
songName: "",
albumName: "",
memberIds: [...selectedMemberIds],
};
updateCurrentSetlist((prev) => [...prev, newSong]);
setTimeout(() => {
if (containerRef.current) {
const lastChild = containerRef.current.lastElementChild;
if (lastChild) {
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, 100);
};
//
const addSongsFromSearch = (songs) => {
let id = getNextId();
const newSongs = songs.map((song) => ({
id: id++,
songName: song.songName,
albumName: song.albumName,
memberIds: [...selectedMemberIds],
}));
updateCurrentSetlist((prev) => [...prev, ...newSongs]);
setTimeout(() => {
if (containerRef.current) {
const lastChild = containerRef.current.lastElementChild;
if (lastChild) {
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, 100);
};
//
const copyFromRound = (sourceRoundId) => {
const source = setlists[sourceRoundId] || [];
let id = getNextId();
const copied = source.map((s) => ({
...s,
id: id++,
memberIds: [...s.memberIds],
}));
updateCurrentSetlist(copied);
setCopyFrom(null);
};
//
const handleRemoveSong = (id) => {
if (setlist.length <= 1) return;
const song = setlist.find((s) => s.id === id);
if (song && (song.songName || song.albumName)) {
setDeleteConfirm({ isOpen: true, songId: id, songName: song.songName || "제목 없음" });
} else {
removeSong(id);
}
};
//
const removeSong = (id) => {
updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id));
};
//
const handleConfirmDelete = () => {
if (deleteConfirm.songId !== null) {
removeSong(deleteConfirm.songId);
}
setDeleteConfirm({ isOpen: false, songId: null, songName: null });
};
//
const updateSong = (id, field, value) => {
updateCurrentSetlist((prev) =>
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
);
};
//
const toggleSongMember = (songId, memberId) => {
updateCurrentSetlist((prev) =>
prev.map((s) => {
if (s.id !== songId) return s;
const has = s.memberIds.includes(memberId);
return {
...s,
memberIds: has
? s.memberIds.filter((id) => id !== memberId)
: [...s.memberIds, memberId],
};
})
);
};
// /
const toggleAllSongMembers = (songId) => {
updateCurrentSetlist((prev) =>
prev.map((s) => {
if (s.id !== songId) return s;
const allSelected = members.every((m) => s.memberIds.includes(m.id));
return {
...s,
memberIds: allSelected ? [] : members.map((m) => m.id),
};
})
);
};
//
const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId);
return (
<>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, songId: null, songName: null })}
onConfirm={handleConfirmDelete}
title="곡 삭제"
message={
<p>
<span className="font-medium">{deleteConfirm.songName}</span>
() 삭제하시겠습니까?
</p>
}
confirmText="삭제"
cancelText="취소"
/>
<SongSearchDialog
isOpen={songSearchOpen}
onClose={() => setSongSearchOpen(false)}
onSelect={addSongsFromSearch}
albums={albums}
/>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-900">세트리스트</h2>
{/* 다른 회차에서 복사 */}
{rounds.length > 1 && (
<div className="relative">
<button
type="button"
onClick={() => setCopyFrom(copyFrom ? null : true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<Copy size={12} />
다른 회차에서 복사
</button>
{copyFrom && (
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1 min-w-[120px]">
{rounds
.filter((r) => r.id !== activeRoundId)
.map((r, i) => {
const roundIdx = rounds.indexOf(r);
return (
<button
key={r.id}
type="button"
onClick={() => copyFromRound(r.id)}
className="w-full px-4 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
>
{roundIdx + 1}회차
</button>
);
})}
</div>
)}
</div>
)}
</div>
{/* 회차 선택 커스텀 드롭다운 (2개 이상일 때만) */}
{rounds.length > 1 && (
<div className="relative mb-4" ref={roundDropdownRef}>
<button
type="button"
onClick={() => setRoundDropdownOpen(!roundDropdownOpen)}
className="flex items-center justify-between gap-2 w-48 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white hover:border-gray-300 transition-colors"
>
<span className="truncate">
{activeRoundIndex + 1}회차
{rounds[activeRoundIndex]?.date ? ` (${rounds[activeRoundIndex].date})` : ''}
</span>
<ChevronDown size={14} className={`text-gray-400 transition-transform ${roundDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{roundDropdownOpen && (
<div className="absolute left-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1">
{rounds.map((round, index) => (
<button
key={round.id}
type="button"
onClick={() => {
setActiveRoundId(round.id);
setRoundDropdownOpen(false);
}}
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
round.id === activeRoundId
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
{index + 1}회차{round.date ? ` (${round.date})` : ''}
</button>
))}
</div>
)}
</div>
)}
<div ref={containerRef} className="flex flex-col gap-4">
{setlist.map((song, index) => (
<div key={song.id}>
<div className="p-4 bg-gray-50 rounded-xl space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
{index + 1}
</span>
{setlist.length > 1 && (
<button
type="button"
onClick={() => handleRemoveSong(song.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
)}
</div>
{/* 곡명 & 앨범명 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">
곡명 *
</label>
<input
type="text"
value={song.songName}
onChange={(e) => updateSong(song.id, "songName", e.target.value)}
placeholder="예: Feel Good"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
앨범명 (선택)
</label>
<input
type="text"
value={song.albumName}
onChange={(e) => updateSong(song.id, "albumName", e.target.value)}
placeholder="예: Unlock My World"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
{/* 참여 멤버 */}
<div>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<Users size={14} />
참여 멤버
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggleAllSongMembers(song.id)}
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
members.every((m) => song.memberIds.includes(m.id))
? "border-primary bg-primary text-white"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{members.every((m) => song.memberIds.includes(m.id))
? "전체 해제"
: "전체 선택"}
</button>
{members.map((member) => {
const isSelected = song.memberIds.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleSongMember(song.id, member.id)}
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
isSelected ? "border-primary" : "border-gray-200"
}`}
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
{member.image_url ? (
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-300" />
)}
</div>
<span className="text-sm text-gray-700">{member.name}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
))}
</div>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={() => setSongSearchOpen(true)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
>
<Search size={14} />
검색
</button>
<button
type="button"
onClick={addSong}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors"
>
<Plus size={14} />
직접 입력
</button>
</div>
<p className="text-xs text-gray-400 mt-3">
{rounds.length > 1
? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요."
: "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."}
</p>
</div>
</>
);
}
export default SetlistSection;

View file

@ -48,14 +48,49 @@ function ConcertForm() {
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
// ()
const [rounds, setRounds] = useState([
const [rounds, setRoundsRaw] = useState([
{ id: 1, date: "", time: "", venue: null },
]);
//
const [setlist, setSetlist] = useState([
{ id: 1, songName: "", albumName: "", memberIds: [] },
]);
//
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 [merchandiseItems, setMerchandiseItems] = useState([]);
@ -140,14 +175,18 @@ function ConcertForm() {
}));
formData.append("rounds", JSON.stringify(roundsData));
//
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));
// (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));
// 굿
merchandiseItems.forEach((item) => {
@ -203,8 +242,9 @@ function ConcertForm() {
{/* 세트리스트 */}
<SetlistSection
setlist={setlist}
setSetlist={setSetlist}
rounds={rounds}
setlists={setlists}
setSetlists={setSetlists}
members={members}
selectedMemberIds={selectedMemberIds}
albums={albumsData}

View file

@ -1,5 +1,6 @@
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";
@ -9,6 +10,7 @@ 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 = {
@ -37,32 +39,28 @@ 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);
//
useEffect(() => {
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);
// (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);
}
};
},
});
fetchCategories();
}, [isAuthenticated]);
//
useEffect(() => {
if (categories.length > 0 && !selectedCategory) {
setSelectedCategory(categories[0].id);
}
}, [categories, selectedCategory]);
//
const renderForm = () => {
@ -78,6 +76,9 @@ 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, DefaultSection, decodeHtmlEntities } from './sections';
import { YoutubeSection, XSection, VarietySection, DefaultSection, decodeHtmlEntities } from './sections';
import Birthday from './Birthday';
/**
@ -153,6 +153,8 @@ function PCScheduleDetail() {
return <YoutubeSection schedule={schedule} />;
case 'X':
return <XSection schedule={schedule} />;
case '예능':
return <VarietySection schedule={schedule} />;
default:
return <DefaultSection schedule={schedule} />;
}
@ -160,7 +162,8 @@ function PCScheduleDetail() {
const isYoutube = categoryName === '유튜브';
const isX = categoryName === 'X';
const hasCustomLayout = isYoutube || isX;
const isVariety = categoryName === '예능';
const hasCustomLayout = isYoutube || isX || isVariety;
return (
<div className="min-h-[calc(100vh-64px)] bg-gray-50">

View file

@ -0,0 +1,95 @@
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-primary hover:underline">
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{matched}
</a>
);

View file

@ -1,4 +1,5 @@
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="앨범">
<Layout pageTitle="앨범" showBack>
<AlbumDetail />
</Layout>
}
@ -73,7 +73,7 @@ export default function MobileRoutes() {
<Route
path="/album/:name/track/:trackTitle"
element={
<Layout pageTitle="앨범">
<Layout pageTitle="앨범" showBack>
<TrackDetail />
</Layout>
}
@ -81,7 +81,7 @@ export default function MobileRoutes() {
<Route
path="/album/:name/gallery"
element={
<Layout pageTitle="앨범">
<Layout pageTitle="컨셉 포토" showBack>
<AlbumGallery />
</Layout>
}

View file

@ -32,6 +32,8 @@ 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';
@ -57,6 +59,8 @@ 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>} />