style(admin): 콘서트 굿즈 섹션 UI 개선 + 드래그앤드롭 재정렬

- 그리드 4열 레이아웃으로 전환해 카드 공백감 해소
- 세로 이미지 잘림 방지: aspect-[3/4] + object-contain + 회색 배경
- 호버 시 삭제 버튼 노출, 순서 뱃지 상시 표시
- 마지막 칸에 '+ 추가' 점선 타일 추가 (다중 업로드 가능)
- @dnd-kit 기반 드래그앤드롭 재정렬 도입 (DragOverlay, rectSortingStrategy)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-23 17:13:20 +09:00
parent 2564e1ddef
commit 5472725e9c
3 changed files with 299 additions and 169 deletions

View file

@ -9,6 +9,9 @@
"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",
@ -303,6 +306,59 @@
"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,6 +10,9 @@
}, },
"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

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