Compare commits
4 commits
7c20e9bb17
...
9e87549ca3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e87549ca3 | |||
| 18efd952c4 | |||
| 5472725e9c | |||
| 2564e1ddef |
7 changed files with 366 additions and 213 deletions
56
frontend/package-lock.json
generated
56
frontend/package-lock.json
generated
|
|
@ -9,6 +9,9 @@
|
|||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
|
@ -303,6 +306,59 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"cpu": [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ function ConcertEditForm() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
className="space-y-6 max-w-4xl mx-auto px-6 py-8"
|
||||
>
|
||||
<ConcertInfoSection
|
||||
title={title}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ function VarietyEditForm() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
className="space-y-6 max-w-4xl mx-auto px-6 py-8"
|
||||
>
|
||||
{/* 프로그램 정보 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
|
|
|
|||
|
|
@ -1,169 +1,240 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { motion, AnimatePresence, Reorder } from "framer-motion";
|
||||
import { Image, Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
|
||||
/**
|
||||
* 굿즈 섹션
|
||||
* - 다수의 굿즈 이미지 업로드
|
||||
* - 드래그로 순서 변경
|
||||
*/
|
||||
function MerchandiseSection({ items, setItems }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
itemId: null,
|
||||
itemName: null,
|
||||
});
|
||||
|
||||
// 이미지 추가
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const newItems = files.map((file, i) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
return {
|
||||
id: `md-${Date.now()}-${i}`,
|
||||
file,
|
||||
preview: url,
|
||||
};
|
||||
});
|
||||
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
|
||||
// input 초기화
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 이미지 삭제 시도
|
||||
const handleRemoveItem = (id) => {
|
||||
const item = items.find((it) => it.id === id);
|
||||
setDeleteConfirm({
|
||||
isOpen: true,
|
||||
itemId: id,
|
||||
itemName: item?.file?.name || "이미지",
|
||||
});
|
||||
};
|
||||
|
||||
// 이미지 삭제 실행
|
||||
const confirmRemoveItem = () => {
|
||||
if (deleteConfirm.itemId !== null) {
|
||||
setItems((prev) => {
|
||||
const item = prev.find((it) => it.id === deleteConfirm.itemId);
|
||||
if (item?.preview) {
|
||||
URL.revokeObjectURL(item.preview);
|
||||
}
|
||||
return prev.filter((it) => it.id !== deleteConfirm.itemId);
|
||||
});
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null })
|
||||
}
|
||||
onConfirm={confirmRemoveItem}
|
||||
title="이미지 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.itemName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Image size={36} className="text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-400">
|
||||
클릭하여 굿즈 이미지를 추가하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-300 mt-1">여러 장 선택 가능</p>
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={setItems}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
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" }}
|
||||
className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
|
||||
>
|
||||
<div className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 transition-colors">
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
|
||||
<div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-gray-200">
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={`굿즈 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 truncate">
|
||||
{item.file?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{index + 1}번째
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
드래그하여 순서를 변경할 수 있습니다. 순서대로 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchandiseSection;
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor,
|
||||
useSensor, useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Image, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
|
||||
/**
|
||||
* 카드 컨텐츠 (Sortable wrapper와 DragOverlay에서 공통 사용)
|
||||
*/
|
||||
function MerchandiseCardContent({ item, index, onRemove, dragging = false }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative group aspect-[3/4] rounded-xl overflow-hidden bg-white border transition-shadow ${
|
||||
dragging ? "border-primary shadow-xl" : "border-gray-100"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={`굿즈 ${index + 1}`}
|
||||
className="w-full h-full object-contain bg-gray-50 pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
<span className="absolute top-2 left-2 min-w-[24px] h-6 px-2 rounded-full bg-black/60 text-white text-xs font-medium flex items-center justify-center pointer-events-none">
|
||||
{index + 1}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(item.id);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 hover:bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 가능한 굿즈 카드
|
||||
*/
|
||||
function SortableMerchandiseCard({ item, index, onRemove }) {
|
||||
const {
|
||||
attributes, listeners, setNodeRef, transform, transition, isDragging,
|
||||
} = useSortable({
|
||||
id: item.id,
|
||||
transition: { duration: 200, easing: "cubic-bezier(0.25, 0.1, 0.25, 1)" },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`touch-none cursor-grab active:cursor-grabbing ${isDragging ? "opacity-30" : ""}`}
|
||||
>
|
||||
<MerchandiseCardContent item={item} index={index} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MerchandiseSection({ items, setItems }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
itemId: null,
|
||||
itemName: null,
|
||||
});
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
// 이미지 추가
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const newItems = files.map((file, i) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
return {
|
||||
id: `md-${Date.now()}-${i}`,
|
||||
file,
|
||||
preview: url,
|
||||
};
|
||||
});
|
||||
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 이미지 삭제
|
||||
const handleRemoveItem = (id) => {
|
||||
const item = items.find((it) => it.id === id);
|
||||
setDeleteConfirm({
|
||||
isOpen: true,
|
||||
itemId: id,
|
||||
itemName: item?.file?.name || "이미지",
|
||||
});
|
||||
};
|
||||
|
||||
const confirmRemoveItem = () => {
|
||||
if (deleteConfirm.itemId !== null) {
|
||||
setItems((prev) => {
|
||||
const item = prev.find((it) => it.id === deleteConfirm.itemId);
|
||||
if (item?.preview) {
|
||||
URL.revokeObjectURL(item.preview);
|
||||
}
|
||||
return prev.filter((it) => it.id !== deleteConfirm.itemId);
|
||||
});
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null });
|
||||
};
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIdx = items.findIndex((it) => it.id === active.id);
|
||||
const newIdx = items.findIndex((it) => it.id === over.id);
|
||||
setItems(arrayMove(items, oldIdx, newIdx));
|
||||
};
|
||||
|
||||
const activeItem = items.find((it) => it.id === activeId);
|
||||
const activeIndex = items.findIndex((it) => it.id === activeId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() =>
|
||||
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null })
|
||||
}
|
||||
onConfirm={confirmRemoveItem}
|
||||
title="이미지 삭제"
|
||||
message={
|
||||
<p>
|
||||
<span className="font-medium">{deleteConfirm.itemName}</span>
|
||||
을(를) 삭제하시겠습니까?
|
||||
</p>
|
||||
}
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Image size={36} className="text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-400">
|
||||
클릭하여 굿즈 이미지를 추가하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-300 mt-1">여러 장 선택 가능</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(e) => setActiveId(e.active.id)}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={items.map((it) => it.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableMerchandiseCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onRemove={handleRemoveItem}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="aspect-[3/4] rounded-xl border-2 border-dashed border-gray-200 hover:border-primary hover:bg-primary/5 text-gray-400 hover:text-primary transition-colors flex flex-col items-center justify-center"
|
||||
>
|
||||
<Plus size={24} />
|
||||
<span className="text-xs mt-1">추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: "cubic-bezier(0.25, 0.1, 0.25, 1)",
|
||||
}}
|
||||
>
|
||||
{activeItem ? (
|
||||
<MerchandiseCardContent item={activeItem} index={activeIndex} dragging />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
드래그하여 순서를 변경할 수 있습니다. 순서대로 표시됩니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchandiseSection;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { Plus, Trash2, Users, Search, Copy, ChevronDown } from "lucide-react";
|
||||
import { Reorder } from "framer-motion";
|
||||
import { Plus, Trash2, Users, Search, Copy, ChevronDown, GripVertical } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
|
||||
import SongSearchDialog from "./SongSearchDialog";
|
||||
|
|
@ -36,6 +37,7 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
|||
}));
|
||||
};
|
||||
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||
isOpen: false,
|
||||
|
|
@ -283,10 +285,24 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
|||
</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">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={setlist}
|
||||
onReorder={(next) => updateCurrentSetlist(next)}
|
||||
ref={containerRef}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{setlist.map((song, index) => (
|
||||
<Reorder.Item
|
||||
key={song.id}
|
||||
value={song}
|
||||
className="flex items-stretch bg-gray-50 rounded-xl overflow-hidden cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
{/* 드래그 핸들 (시각적 표시용, 카드 전체가 드래그 가능) */}
|
||||
<div className="flex items-center px-2 text-gray-300">
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-3 min-w-0">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
|
|
@ -375,10 +391,10 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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";
|
||||
import { X, Search, Music, Disc3 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 곡 검색 다이얼로그
|
||||
|
|
@ -71,7 +71,14 @@ function SongSearchDialog({ isOpen, onClose, onSelect, albums }) {
|
|||
});
|
||||
};
|
||||
|
||||
const isSelected = (trackId) => selectedTracks.some((t) => t.id === trackId);
|
||||
// 선택 순서 매핑 (trackId → 순번)
|
||||
const selectionOrder = useMemo(() => {
|
||||
const m = new Map();
|
||||
selectedTracks.forEach((t, i) => m.set(t.id, i + 1));
|
||||
return m;
|
||||
}, [selectedTracks]);
|
||||
|
||||
const isSelected = (trackId) => selectionOrder.has(trackId);
|
||||
|
||||
// 확인
|
||||
const handleConfirm = () => {
|
||||
|
|
@ -169,41 +176,41 @@ function SongSearchDialog({ isOpen, onClose, onSelect, albums }) {
|
|||
|
||||
{/* 트랙 목록 */}
|
||||
<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"
|
||||
{group.tracks.map((track) => {
|
||||
const order = selectionOrder.get(track.id);
|
||||
const selected = order !== undefined;
|
||||
return (
|
||||
<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 ${
|
||||
selected ? "bg-primary/10" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
타이틀
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-[11px] font-bold leading-none transition-colors ${
|
||||
selected
|
||||
? "bg-primary text-white"
|
||||
: "border border-gray-300 text-transparent"
|
||||
}`}
|
||||
>
|
||||
<span className="translate-y-[0.5px]">{order ?? ""}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 w-5 text-right flex-shrink-0">
|
||||
{track.trackNumber}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue