Improve admin panel UX with toasts, evolution CRUD, sorting, drag-and-drop, and responsive layout

Add sonner toast notifications to all mutations, evolution management backend
(CRUD endpoints with search/pagination) and frontend (form modal with pokemon
selector, paginated list page), sortable AdminTable columns (Region/Gen/Year
on Games), drag-and-drop route reordering via @dnd-kit, skeleton loading states,
card-styled table wrappers, and responsive mobile nav in AdminLayout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:09:27 +01:00
parent 574e36ee22
commit 1f198aca4c
20 changed files with 1140 additions and 138 deletions

View File

@@ -1,6 +1,21 @@
import { useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { RouteFormModal } from '../../components/admin/RouteFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGame } from '../../hooks/useGames'
@@ -12,6 +27,72 @@ import {
} from '../../hooks/useAdmin'
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
function SortableRouteRow({
route,
onEdit,
onDelete,
onClick,
}: {
route: GameRoute
onEdit: (r: GameRoute) => void
onDelete: (r: GameRoute) => void
onClick: (r: GameRoute) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: route.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<tr
ref={setNodeRef}
style={style}
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
onClick={() => onClick(route)}
>
<td className="px-4 py-3 text-sm w-12">
<button
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
title="Drag to reorder"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
</button>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{route.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-32">
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => onEdit(route)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => onDelete(route)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Delete
</button>
</div>
</td>
</tr>
)
}
export function AdminGameDetail() {
const { gameId } = useParams<{ gameId: string }>()
const navigate = useNavigate()
@@ -27,69 +108,36 @@ export function AdminGameDetail() {
const [editing, setEditing] = useState<GameRoute | null>(null)
const [deleting, setDeleting] = useState<GameRoute | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
const routes = game.routes ?? []
const moveRoute = (route: GameRoute, direction: 'up' | 'down') => {
const idx = routes.findIndex((r) => r.id === route.id)
if (direction === 'up' && idx <= 0) return
if (direction === 'down' && idx >= routes.length - 1) return
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
const newRoutes = routes.map((r, i) => {
if (i === idx) return { id: r.id, order: routes[swapIdx].order }
if (i === swapIdx) return { id: r.id, order: routes[idx].order }
return { id: r.id, order: r.order }
})
reorderRoutes.mutate(newRoutes)
const oldIndex = routes.findIndex((r) => r.id === active.id)
const newIndex = routes.findIndex((r) => r.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
// Build new order assignments based on rearranged positions
const reordered = [...routes]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
const newOrders = reordered.map((r, i) => ({
id: r.id,
order: i + 1,
}))
reorderRoutes.mutate(newOrders)
}
const columns: Column<GameRoute>[] = [
{ header: 'Order', accessor: (r) => r.order, className: 'w-16' },
{ header: 'Name', accessor: (r) => r.name },
{
header: 'Actions',
className: 'w-48',
accessor: (r) => {
const idx = routes.findIndex((rt) => rt.id === r.id)
return (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => moveRoute(r, 'up')}
disabled={idx === 0}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 disabled:opacity-30 text-sm"
title="Move up"
>
Up
</button>
<button
onClick={() => moveRoute(r, 'down')}
disabled={idx === routes.length - 1}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 disabled:opacity-30 text-sm"
title="Move down"
>
Down
</button>
<button
onClick={() => setEditing(r)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => setDeleting(r)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Delete
</button>
</div>
)
},
},
]
return (
<div>
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
@@ -118,13 +166,54 @@ export function AdminGameDetail() {
</button>
</div>
<AdminTable
columns={columns}
data={routes}
emptyMessage="No routes yet. Add one to get started."
onRowClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
keyFn={(r) => r.id}
/>
{routes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
No routes yet. Add one to get started.
</div>
) : (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
Order
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-32">
Actions
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={routes.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{routes.map((route) => (
<SortableRouteRow
key={route.id}
route={route}
onEdit={setEditing}
onDelete={setDeleting}
onClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
</div>
)}
{showCreate && (
<RouteFormModal