From 5472725e9cbacb6265de6dc70b6cca34cd1dcbd9 Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 23 Apr 2026 17:13:20 +0900 Subject: [PATCH] =?UTF-8?q?style(admin):=20=EC=BD=98=EC=84=9C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=BF=EC=A6=88=20=EC=84=B9=EC=85=98=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20+=20=EB=93=9C=EB=9E=98=EA=B7=B8=EC=95=A4=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=20=EC=9E=AC=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그리드 4열 레이아웃으로 전환해 카드 공백감 해소 - 세로 이미지 잘림 방지: aspect-[3/4] + object-contain + 회색 배경 - 호버 시 삭제 버튼 노출, 순서 뱃지 상시 표시 - 마지막 칸에 '+ 추가' 점선 타일 추가 (다중 업로드 가능) - @dnd-kit 기반 드래그앤드롭 재정렬 도입 (DragOverlay, rectSortingStrategy) Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/package-lock.json | 56 +++ frontend/package.json | 3 + .../form/concert/MerchandiseSection.jsx | 409 ++++++++++-------- 3 files changed, 299 insertions(+), 169 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 04fc9a0..babcc36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": [ diff --git a/frontend/package.json b/frontend/package.json index 3ae2e24..b36295b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/MerchandiseSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/MerchandiseSection.jsx index acec621..81024cf 100644 --- a/frontend/src/pages/pc/admin/schedules/form/concert/MerchandiseSection.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/concert/MerchandiseSection.jsx @@ -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 ( - <> - - setDeleteConfirm({ isOpen: false, itemId: null, itemName: null }) - } - onConfirm={confirmRemoveItem} - title="이미지 삭제" - message={ -

- {deleteConfirm.itemName} - 을(를) 삭제하시겠습니까? -

- } - confirmText="삭제" - cancelText="취소" - /> - -
-

굿즈

- - - - {items.length === 0 ? ( -
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" - > - -

- 클릭하여 굿즈 이미지를 추가하세요 -

-

여러 장 선택 가능

-
- ) : ( - - - {items.map((item, index) => ( - -
- -
- -
- {`굿즈 -
- -
-

- {item.file?.name} -

-

- {index + 1}번째 -

-
- - -
- ))} -
-
- )} - - {items.length > 0 && ( -

- 드래그하여 순서를 변경할 수 있습니다. 순서대로 표시됩니다. -

- )} -
- - ); -} - -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 ( +
+ {`굿즈 + + {index + 1} + + {onRemove && ( + + )} +
+ ); +} + +/** + * 정렬 가능한 굿즈 카드 + */ +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 ( +
+ +
+ ); +} + +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 ( + <> + + setDeleteConfirm({ isOpen: false, itemId: null, itemName: null }) + } + onConfirm={confirmRemoveItem} + title="이미지 삭제" + message={ +

+ {deleteConfirm.itemName} + 을(를) 삭제하시겠습니까? +

+ } + confirmText="삭제" + cancelText="취소" + /> + +
+

굿즈

+ + + + {items.length === 0 ? ( +
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" + > + +

+ 클릭하여 굿즈 이미지를 추가하세요 +

+

여러 장 선택 가능

+
+ ) : ( + <> + setActiveId(e.active.id)} + onDragCancel={() => setActiveId(null)} + onDragEnd={handleDragEnd} + > + it.id)} strategy={rectSortingStrategy}> +
+ {items.map((item, index) => ( + + ))} + +
+
+ + {activeItem ? ( + + ) : null} + +
+

+ 드래그하여 순서를 변경할 수 있습니다. 순서대로 표시됩니다. +

+ + )} +
+ + ); +} + +export default MerchandiseSection;