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

@@ -0,0 +1,78 @@
import { useState, useRef, useEffect } from 'react'
import { usePokemonList } from '../../hooks/useAdmin'
interface PokemonSelectorProps {
label: string
selectedId: number | null
initialName?: string
onChange: (id: number | null) => void
}
export function PokemonSelector({
label,
selectedId,
initialName,
onChange,
}: PokemonSelectorProps) {
const [search, setSearch] = useState(initialName ?? '')
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { data } = usePokemonList(search || undefined, 20, 0)
const pokemon = data?.items ?? []
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
return (
<div ref={ref} className="relative">
<label className="block text-sm font-medium mb-1">{label}</label>
<input
type="text"
required={!selectedId}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setOpen(true)
if (!e.target.value) onChange(null)
}}
onFocus={() => setOpen(true)}
placeholder="Search pokemon..."
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
{selectedId && (
<input type="hidden" name={label} value={selectedId} required />
)}
{open && pokemon.length > 0 && (
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
{pokemon.map((p) => (
<li
key={p.id}
onClick={() => {
onChange(p.id)
setSearch(p.name)
setOpen(false)
}}
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2 ${
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
}`}
>
{p.spriteUrl && (
<img src={p.spriteUrl} alt="" className="w-6 h-6" />
)}
<span>
#{p.nationalDex} {p.name}
</span>
</li>
))}
</ul>
)}
</div>
)
}