Compare commits

..

No commits in common. "9e87549ca3194c8a1b4a30750bd819f63edcc0c3" and "7c20e9bb17cffab8c52ccdf489e4039c3a84d41f" have entirely different histories.

7 changed files with 213 additions and 366 deletions

View file

@ -9,9 +9,6 @@
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@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-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -306,59 +303,6 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/linux-x64": {
"version": "0.21.5", "version": "0.21.5",
"cpu": [ "cpu": [

View file

@ -10,9 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@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-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",

View file

@ -241,7 +241,7 @@ function ConcertEditForm() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }} transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="space-y-6 max-w-4xl mx-auto px-6 py-8" className="space-y-6"
> >
<ConcertInfoSection <ConcertInfoSection
title={title} title={title}

View file

@ -128,7 +128,7 @@ function VarietyEditForm() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="space-y-6 max-w-4xl mx-auto px-6 py-8" className="space-y-6"
> >
{/* 프로그램 정보 */} {/* 프로그램 정보 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"> <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">

View file

@ -1,240 +1,169 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { import { motion, AnimatePresence, Reorder } from "framer-motion";
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor, import { Image, Plus, Trash2, GripVertical } from "lucide-react";
useSensor, useSensors,
} from "@dnd-kit/core"; import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
import {
SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy, /**
arrayMove, * 굿즈 섹션
} from "@dnd-kit/sortable"; * - 다수의 굿즈 이미지 업로드
import { CSS } from "@dnd-kit/utilities"; * - 드래그로 순서 변경
import { Image, Plus, Trash2 } from "lucide-react"; */
function MerchandiseSection({ items, setItems }) {
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog"; const fileInputRef = useRef(null);
const [deleteConfirm, setDeleteConfirm] = useState({
/** isOpen: false,
* 카드 컨텐츠 (Sortable wrapper와 DragOverlay에서 공통 사용) itemId: null,
*/ itemName: null,
function MerchandiseCardContent({ item, index, onRemove, dragging = false }) { });
return (
<div //
className={`relative group aspect-[3/4] rounded-xl overflow-hidden bg-white border transition-shadow ${ const handleFileChange = (e) => {
dragging ? "border-primary shadow-xl" : "border-gray-100" const files = Array.from(e.target.files);
}`} if (files.length === 0) return;
>
<img const newItems = files.map((file, i) => {
src={item.preview} const url = URL.createObjectURL(file);
alt={`굿즈 ${index + 1}`} return {
className="w-full h-full object-contain bg-gray-50 pointer-events-none" id: `md-${Date.now()}-${i}`,
draggable={false} file,
/> preview: url,
<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 && ( setItems((prev) => [...prev, ...newItems]);
<button
type="button" // input
onClick={(e) => { e.target.value = "";
e.stopPropagation(); };
onRemove(item.id);
}} //
onPointerDown={(e) => e.stopPropagation()} const handleRemoveItem = (id) => {
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" const item = items.find((it) => it.id === id);
> setDeleteConfirm({
<Trash2 size={13} /> isOpen: true,
</button> itemId: id,
)} itemName: item?.file?.name || "이미지",
</div> });
); };
}
//
/** const confirmRemoveItem = () => {
* 정렬 가능한 굿즈 카드 if (deleteConfirm.itemId !== null) {
*/ setItems((prev) => {
function SortableMerchandiseCard({ item, index, onRemove }) { const item = prev.find((it) => it.id === deleteConfirm.itemId);
const { if (item?.preview) {
attributes, listeners, setNodeRef, transform, transition, isDragging, URL.revokeObjectURL(item.preview);
} = useSortable({ }
id: item.id, return prev.filter((it) => it.id !== deleteConfirm.itemId);
transition: { duration: 200, easing: "cubic-bezier(0.25, 0.1, 0.25, 1)" }, });
}); }
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null });
const style = { };
transform: CSS.Transform.toString(transform),
transition, return (
}; <>
<ConfirmDialog
return ( isOpen={deleteConfirm.isOpen}
<div onClose={() =>
ref={setNodeRef} setDeleteConfirm({ isOpen: false, itemId: null, itemName: null })
style={style} }
{...attributes} onConfirm={confirmRemoveItem}
{...listeners} title="이미지 삭제"
className={`touch-none cursor-grab active:cursor-grabbing ${isDragging ? "opacity-30" : ""}`} message={
> <p>
<MerchandiseCardContent item={item} index={index} onRemove={onRemove} /> <span className="font-medium">{deleteConfirm.itemName}</span>
</div> () 삭제하시겠습니까?
); </p>
} }
confirmText="삭제"
function MerchandiseSection({ items, setItems }) { cancelText="취소"
const fileInputRef = useRef(null); />
const [deleteConfirm, setDeleteConfirm] = useState({
isOpen: false, <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
itemId: null, <h2 className="text-lg font-bold text-gray-900 mb-6">굿즈</h2>
itemName: null,
}); <input
const [activeId, setActiveId] = useState(null); ref={fileInputRef}
type="file"
const sensors = useSensors( accept="image/*"
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), multiple
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) onChange={handleFileChange}
); className="hidden"
/>
//
const handleFileChange = (e) => { {items.length === 0 ? (
const files = Array.from(e.target.files); <div
if (files.length === 0) return; 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"
const newItems = files.map((file, i) => { >
const url = URL.createObjectURL(file); <Image size={36} className="text-gray-300 mb-3" />
return { <p className="text-sm text-gray-400">
id: `md-${Date.now()}-${i}`, 클릭하여 굿즈 이미지를 추가하세요
file, </p>
preview: url, <p className="text-xs text-gray-300 mt-1">여러 선택 가능</p>
}; </div>
}); ) : (
<Reorder.Group
setItems((prev) => [...prev, ...newItems]); axis="y"
e.target.value = ""; values={items}
}; onReorder={setItems}
className="flex flex-col gap-3"
// >
const handleRemoveItem = (id) => { <AnimatePresence initial={false}>
const item = items.find((it) => it.id === id); {items.map((item, index) => (
setDeleteConfirm({ <Reorder.Item
isOpen: true, key={item.id}
itemId: id, value={item}
itemName: item?.file?.name || "이미지", 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" }}
const confirmRemoveItem = () => { className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
if (deleteConfirm.itemId !== null) { >
setItems((prev) => { <div className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 transition-colors">
const item = prev.find((it) => it.id === deleteConfirm.itemId); <GripVertical size={18} />
if (item?.preview) { </div>
URL.revokeObjectURL(item.preview);
} <div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-gray-200">
return prev.filter((it) => it.id !== deleteConfirm.itemId); <img
}); src={item.preview}
} alt={`굿즈 ${index + 1}`}
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null }); className="w-full h-full object-cover"
}; draggable={false}
/>
const handleDragEnd = (event) => { </div>
const { active, over } = event;
setActiveId(null); <div className="flex-1 min-w-0">
if (!over || active.id === over.id) return; <p className="text-sm text-gray-700 truncate">
{item.file?.name}
const oldIdx = items.findIndex((it) => it.id === active.id); </p>
const newIdx = items.findIndex((it) => it.id === over.id); <p className="text-xs text-gray-400 mt-0.5">
setItems(arrayMove(items, oldIdx, newIdx)); {index + 1}번째
}; </p>
</div>
const activeItem = items.find((it) => it.id === activeId);
const activeIndex = items.findIndex((it) => it.id === activeId); <button
type="button"
return ( onClick={() => handleRemoveItem(item.id)}
<> className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
<ConfirmDialog >
isOpen={deleteConfirm.isOpen} <Trash2 size={16} />
onClose={() => </button>
setDeleteConfirm({ isOpen: false, itemId: null, itemName: null }) </Reorder.Item>
} ))}
onConfirm={confirmRemoveItem} </AnimatePresence>
title="이미지 삭제" </Reorder.Group>
message={ )}
<p>
<span className="font-medium">{deleteConfirm.itemName}</span> {items.length > 0 && (
() 삭제하시겠습니까? <p className="text-xs text-gray-400 mt-3">
</p> 드래그하여 순서를 변경할 있습니다. 순서대로 표시됩니다.
} </p>
confirmText="삭제" )}
cancelText="취소" </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>
export default MerchandiseSection;
<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;

View file

@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Reorder } from "framer-motion"; import { Plus, Trash2, Users, Search, Copy, ChevronDown } from "lucide-react";
import { Plus, Trash2, Users, Search, Copy, ChevronDown, GripVertical } from "lucide-react";
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog"; import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
import SongSearchDialog from "./SongSearchDialog"; import SongSearchDialog from "./SongSearchDialog";
@ -37,7 +36,6 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
})); }));
}; };
// //
const [deleteConfirm, setDeleteConfirm] = useState({ const [deleteConfirm, setDeleteConfirm] = useState({
isOpen: false, isOpen: false,
@ -285,24 +283,10 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
</div> </div>
)} )}
<Reorder.Group <div ref={containerRef} className="flex flex-col gap-4">
axis="y" {setlist.map((song, index) => (
values={setlist} <div key={song.id}>
onReorder={(next) => updateCurrentSetlist(next)} <div className="p-4 bg-gray-50 rounded-xl space-y-3">
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"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
@ -391,10 +375,10 @@ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMember
})} })}
</div> </div>
</div> </div>
</div>
</div> </div>
</Reorder.Item> ))}
))} </div>
</Reorder.Group>
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4">
<button <button

View file

@ -1,7 +1,7 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { X, Search, Music, Disc3 } from "lucide-react"; import { X, Search, Music, Check, Disc3 } from "lucide-react";
/** /**
* 검색 다이얼로그 * 검색 다이얼로그
@ -71,14 +71,7 @@ function SongSearchDialog({ isOpen, onClose, onSelect, albums }) {
}); });
}; };
// (trackId ) const isSelected = (trackId) => selectedTracks.some((t) => t.id === 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 = () => { const handleConfirm = () => {
@ -176,41 +169,41 @@ function SongSearchDialog({ isOpen, onClose, onSelect, albums }) {
{/* 트랙 목록 */} {/* 트랙 목록 */}
<div className="space-y-1"> <div className="space-y-1">
{group.tracks.map((track) => { {group.tracks.map((track) => (
const order = selectionOrder.get(track.id); <button
const selected = order !== undefined; key={track.id}
return ( type="button"
<button onClick={() => toggleTrack(track)}
key={track.id} className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
type="button" isSelected(track.id)
onClick={() => toggleTrack(track)} ? "bg-primary/10"
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${ : "hover:bg-gray-50"
selected ? "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"
}`} }`}
> >
<div {isSelected(track.id) && (
className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-[11px] font-bold leading-none transition-colors ${ <Check size={12} className="text-white" />
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>
<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>
); <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>
))} ))}