feat: 세트리스트 섹션 및 곡 검색 다이얼로그 추가
- 세트리스트 섹션: 곡명, 앨범명, 참여 멤버 선택 - 곡 검색 다이얼로그: 앨범별 트랙 검색 및 다중 선택 - 직접 입력과 곡 검색 두 가지 방식으로 곡 추가 가능 - 공연 일정/세트리스트 추가 버튼을 하단으로 이동 - ConfirmDialog에 createPortal 적용 - 콘서트 정보 → 공연 정보로 명칭 변경 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
169c584d31
commit
ad8406fdd7
6 changed files with 613 additions and 14 deletions
|
|
@ -14,6 +14,7 @@
|
|||
* - loadingText: 로딩 중 텍스트 (기본: "삭제 중...")
|
||||
* - variant: 버튼 색상 (기본: "danger", "primary" 가능)
|
||||
*/
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ function ConfirmDialog({
|
|||
primary: 'text-primary',
|
||||
};
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
|
|
@ -108,7 +109,8 @@ function ConfirmDialog({
|
|||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function ConcertInfoSection({
|
|||
|
||||
return (
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">공연 정보</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공연명 */}
|
||||
|
|
|
|||
|
|
@ -142,17 +142,7 @@ function ScheduleSection({ rounds, setRounds }) {
|
|||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-900">공연 일정</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRound}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
회차 추가
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">공연 일정</h2>
|
||||
|
||||
<div ref={containerRef} className="flex flex-col gap-4">
|
||||
<AnimatePresence initial={false}>
|
||||
|
|
@ -254,6 +244,15 @@ function ScheduleSection({ rounds, setRounds }) {
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRound}
|
||||
className="w-full mt-4 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"
|
||||
>
|
||||
<Plus size={14} />
|
||||
회차 추가
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
시간과 장소는 선택사항입니다. 미정인 경우 비워두세요.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Search, Music, Check, Disc3 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 곡 검색 다이얼로그
|
||||
* - 앨범 목록에서 곡을 검색/선택
|
||||
* - 다중 선택 지원
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen
|
||||
* @param {Function} props.onClose
|
||||
* @param {Function} props.onSelect - 선택된 곡 배열 반환 [{ songName, albumName }]
|
||||
* @param {Array} props.albums - getAlbums() 결과
|
||||
*/
|
||||
function SongSearchDialog({ isOpen, onClose, onSelect, albums }) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTracks, setSelectedTracks] = useState([]);
|
||||
|
||||
// 전체 트랙 목록 (앨범 정보 포함)
|
||||
const allTracks = useMemo(() => {
|
||||
if (!albums || albums.length === 0) return [];
|
||||
return albums.flatMap((album) =>
|
||||
(album.tracks || []).map((track) => ({
|
||||
id: `${album.id}-${track.id}`,
|
||||
songName: track.title,
|
||||
albumName: album.title,
|
||||
albumCover: album.cover_thumb_url,
|
||||
isTitleTrack: track.is_title_track,
|
||||
trackNumber: track.track_number,
|
||||
}))
|
||||
);
|
||||
}, [albums]);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredTracks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allTracks;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allTracks.filter(
|
||||
(track) =>
|
||||
track.songName.toLowerCase().includes(query) ||
|
||||
track.albumName.toLowerCase().includes(query)
|
||||
);
|
||||
}, [allTracks, searchQuery]);
|
||||
|
||||
// 앨범별 그룹핑
|
||||
const groupedTracks = useMemo(() => {
|
||||
const groups = {};
|
||||
filteredTracks.forEach((track) => {
|
||||
if (!groups[track.albumName]) {
|
||||
groups[track.albumName] = {
|
||||
albumName: track.albumName,
|
||||
albumCover: track.albumCover,
|
||||
tracks: [],
|
||||
};
|
||||
}
|
||||
groups[track.albumName].tracks.push(track);
|
||||
});
|
||||
return Object.values(groups);
|
||||
}, [filteredTracks]);
|
||||
|
||||
// 트랙 선택 토글
|
||||
const toggleTrack = (track) => {
|
||||
setSelectedTracks((prev) => {
|
||||
const exists = prev.find((t) => t.id === track.id);
|
||||
if (exists) {
|
||||
return prev.filter((t) => t.id !== track.id);
|
||||
}
|
||||
return [...prev, track];
|
||||
});
|
||||
};
|
||||
|
||||
const isSelected = (trackId) => selectedTracks.some((t) => t.id === trackId);
|
||||
|
||||
// 확인
|
||||
const handleConfirm = () => {
|
||||
onSelect(
|
||||
selectedTracks.map((t) => ({
|
||||
songName: t.songName,
|
||||
albumName: t.albumName,
|
||||
}))
|
||||
);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 닫기
|
||||
const handleClose = () => {
|
||||
setSearchQuery("");
|
||||
setSelectedTracks([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl flex flex-col h-[60vh] min-h-[400px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900">곡 검색</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative mb-4">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="곡명 또는 앨범명으로 검색"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택 카운트 */}
|
||||
{selectedTracks.length > 0 && (
|
||||
<div className="mb-3 text-sm text-primary font-medium">
|
||||
{selectedTracks.length}곡 선택됨
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 목록 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{groupedTracks.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{groupedTracks.map((group) => (
|
||||
<div key={group.albumName}>
|
||||
{/* 앨범 헤더 */}
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white py-1">
|
||||
{group.albumCover ? (
|
||||
<img
|
||||
src={group.albumCover}
|
||||
alt={group.albumName}
|
||||
className="w-8 h-8 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-md bg-gray-100 flex items-center justify-center">
|
||||
<Disc3 size={16} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{group.albumName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 트랙 목록 */}
|
||||
<div className="space-y-1">
|
||||
{group.tracks.map((track) => (
|
||||
<button
|
||||
key={track.id}
|
||||
type="button"
|
||||
onClick={() => toggleTrack(track)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
||||
isSelected(track.id)
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected(track.id)
|
||||
? "bg-primary border-primary"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected(track.id) && (
|
||||
<Check size={12} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 w-5 text-right flex-shrink-0">
|
||||
{track.trackNumber}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 flex-1 truncate">
|
||||
{track.songName}
|
||||
</span>
|
||||
{!!track.isTitleTrack && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-primary/10 text-primary rounded font-medium flex-shrink-0">
|
||||
타이틀
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Music size={32} className="mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? "검색 결과가 없습니다"
|
||||
: "등록된 곡이 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedTracks.length === 0}
|
||||
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{selectedTracks.length > 0
|
||||
? `${selectedTracks.length}곡 추가`
|
||||
: "추가"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default SongSearchDialog;
|
||||
|
|
@ -8,9 +8,11 @@ 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 ConcertInfoSection from "./ConcertInfoSection";
|
||||
import ScheduleSection from "./ScheduleSection";
|
||||
import SetlistSection from "./SetlistSection";
|
||||
import MerchandiseSection from "./MerchandiseSection";
|
||||
|
||||
/**
|
||||
|
|
@ -30,6 +32,14 @@ function ConcertForm() {
|
|||
});
|
||||
const members = membersData.filter((m) => !m.is_former);
|
||||
|
||||
// 앨범 목록 조회 (곡 검색용)
|
||||
const { data: albumsData = [] } = useQuery({
|
||||
queryKey: ["albums"],
|
||||
queryFn: getAlbums,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// 콘서트 정보
|
||||
const [title, setTitle] = useState("");
|
||||
const [posterFile, setPosterFile] = useState(null);
|
||||
|
|
@ -41,6 +51,11 @@ function ConcertForm() {
|
|||
{ id: 1, date: "", time: "", venue: null },
|
||||
]);
|
||||
|
||||
// 세트리스트
|
||||
const [setlist, setSetlist] = useState([
|
||||
{ id: 1, songName: "", albumName: "", memberIds: [] },
|
||||
]);
|
||||
|
||||
// 굿즈 이미지
|
||||
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
||||
|
||||
|
|
@ -123,6 +138,15 @@ function ConcertForm() {
|
|||
setItems={setMerchandiseItems}
|
||||
/>
|
||||
|
||||
{/* 세트리스트 */}
|
||||
<SetlistSection
|
||||
setlist={setlist}
|
||||
setSetlist={setSetlist}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
albums={albumsData}
|
||||
/>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue