보스 목록 드래그 앤 드롭 정렬 추가
- @dnd-kit으로 드래그앤드롭 정렬 구현 - DragOverlay 패턴으로 부드러운 드래그 애니메이션 - 드롭 시 즉시 UI 반영 + reorder API 호출 - React 19 peer dep 충돌 해결을 위해 npm install --legacy-peer-deps 사용 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b885f464c3
commit
88c6d5d5f3
4 changed files with 214 additions and 39 deletions
|
|
@ -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:
|
||||
|
|
|
|||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
|
||||
dragging
|
||||
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
|
||||
: 'border-white/5'
|
||||
}`}>
|
||||
{/* 핸들 자리 */}
|
||||
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
|
||||
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
|
||||
<circle cx="4" cy="4" r="1.5" />
|
||||
<circle cx="10" cy="4" r="1.5" />
|
||||
<circle cx="4" cy="10" r="1.5" />
|
||||
<circle cx="10" cy="10" r="1.5" />
|
||||
<circle cx="4" cy="16" r="1.5" />
|
||||
<circle cx="10" cy="16" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
|
||||
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="font-semibold truncate">{boss.name}</h3>
|
||||
<span className="text-xs text-gray-500 shrink-0">최대 {boss.max_party_size}인</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
||||
const bd = boss.difficulties.find((x) => x.difficulty === d.key)
|
||||
return (
|
||||
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={formatMeso(bd.crystal_price)}>
|
||||
{d.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`relative ${isDragging ? 'opacity-30' : ''}`}
|
||||
>
|
||||
{/* 드래그 핸들 (좌측) */}
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
|
||||
aria-label="순서 변경"
|
||||
/>
|
||||
{/* 카드 본체 - Link */}
|
||||
<Link to={`bosses/${boss.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
|
||||
<BossCardContent boss={boss} />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||
|
|
@ -31,7 +158,7 @@ export default function BossList() {
|
|||
<div key={i} className="h-32 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : bosses.length === 0 ? (
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||
<div className="text-5xl mb-3 opacity-30">⚔️</div>
|
||||
<p className="text-gray-400 mb-4">등록된 보스가 없습니다</p>
|
||||
|
|
@ -40,38 +167,29 @@ export default function BossList() {
|
|||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bosses.map((boss) => (
|
||||
<Link
|
||||
key={boss.id}
|
||||
to={`bosses/${boss.id}`}
|
||||
className="group rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-4 hover:border-emerald-500/30 transition"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={boss.image_url || '/default.png'}
|
||||
alt={boss.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold group-hover:text-emerald-300 transition truncate">{boss.name}</h3>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
||||
const bd = boss.difficulties.find((x) => x.difficulty === d.key)
|
||||
return (
|
||||
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={`${formatMeso(bd.crystal_price)} / ${boss.max_party_size}인`}>
|
||||
{d.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(e) => setActiveId(e.active.id)}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={items.map((b) => b.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((boss) => (
|
||||
<SortableBossCard key={boss.id} boss={boss} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
}}
|
||||
>
|
||||
{activeBoss ? <BossCardContent boss={activeBoss} dragging /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue