From 88c6d5d5f3e48cc9a1a95bc91f8a3fa366e9f278 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 13 Apr 2026 16:06:08 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=8A=A4=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=95=A4=20=EB=93=9C=EB=A1=AD=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @dnd-kit으로 드래그앤드롭 정렬 구현 - DragOverlay 패턴으로 부드러운 드래그 애니메이션 - 드롭 시 즉시 UI 반영 + reorder API 호출 - React 19 peer dep 충돌 해결을 위해 npm install --legacy-peer-deps 사용 Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 2 +- frontend/package-lock.json | 60 +++++- frontend/package.json | 3 + .../features/boss-crystal/admin/BossList.jsx | 188 ++++++++++++++---- 4 files changed, 214 insertions(+), 39 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 84b87cb..6f10ec0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: volumes: - ./frontend:/app - frontend_modules:/app/node_modules - command: sh -c "npm install && npm run dev" + command: sh -c "npm install --legacy-peer-deps && npm run dev" labels: - "com.centurylinklabs.watchtower.enable=false" networks: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f7db7fa..9486582 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.91.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -267,6 +270,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/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -2813,9 +2869,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/frontend/package.json b/frontend/package.json index 41983c4..2565050 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.91.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/frontend/src/features/boss-crystal/admin/BossList.jsx b/frontend/src/features/boss-crystal/admin/BossList.jsx index ed8bd7b..7d42cab 100644 --- a/frontend/src/features/boss-crystal/admin/BossList.jsx +++ b/frontend/src/features/boss-crystal/admin/BossList.jsx @@ -1,14 +1,141 @@ +import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +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 { api } from '../../../api/client' -import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants' +import { DIFFICULTIES, formatMeso } from './constants' + +function BossCardContent({ boss, dragging = false }) { + return ( +
+ {/* 핸들 자리 */} +
+ + + + + + + + +
+ +
+
+ {boss.name} +
+
+
+

{boss.name}

+ 최대 {boss.max_party_size}인 +
+
+ {DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => { + const bd = boss.difficulties.find((x) => x.difficulty === d.key) + return ( + + {d.label} + + ) + })} +
+
+
+
+ ) +} + +function SortableBossCard({ boss }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef } = useSortable({ + id: boss.id, + transition: { duration: 200, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }, + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {/* 드래그 핸들 (좌측) */} +
+ ) +} export default function BossList() { + const queryClient = useQueryClient() const { data: bosses = [], isLoading } = useQuery({ queryKey: ['admin', 'boss-crystal', 'bosses'], queryFn: () => api('/api/admin/boss-crystal/bosses').catch(() => []), }) + const [items, setItems] = useState([]) + const [activeId, setActiveId] = useState(null) + + useEffect(() => { setItems(bosses) }, [bosses]) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const reorderMutation = useMutation({ + mutationFn: (ids) => api('/api/admin/boss-crystal/bosses/reorder', { + method: 'POST', + body: { ids }, + }), + onError: (err) => { + alert(err.message) + queryClient.invalidateQueries({ queryKey: ['admin', 'boss-crystal', 'bosses'] }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['boss-crystal'] }) + }, + }) + + const handleDragEnd = (event) => { + const { active, over } = event + setActiveId(null) + if (!over || active.id === over.id) return + + const oldIdx = items.findIndex((b) => b.id === active.id) + const newIdx = items.findIndex((b) => b.id === over.id) + const next = arrayMove(items, oldIdx, newIdx) + setItems(next) + reorderMutation.mutate(next.map((b) => b.id)) + } + + const activeBoss = items.find((b) => b.id === activeId) + return (
@@ -31,7 +158,7 @@ export default function BossList() {
))}
- ) : bosses.length === 0 ? ( + ) : items.length === 0 ? (
⚔️

등록된 보스가 없습니다

@@ -40,38 +167,29 @@ export default function BossList() {
) : ( -
- {bosses.map((boss) => ( - -
-
- {boss.name} -
-
-

{boss.name}

-
- {DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => { - const bd = boss.difficulties.find((x) => x.difficulty === d.key) - return ( - - {d.label} - - ) - })} -
-
-
- - ))} -
+ setActiveId(e.active.id)} + onDragCancel={() => setActiveId(null)} + onDragEnd={handleDragEnd} + > + b.id)} strategy={rectSortingStrategy}> +
+ {items.map((boss) => ( + + ))} +
+
+ + {activeBoss ? : null} + +
)}
)