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

@@ -3,21 +3,22 @@ import { NavLink, Outlet } from 'react-router-dom'
const navItems = [
{ to: '/admin/games', label: 'Games' },
{ to: '/admin/pokemon', label: 'Pokemon' },
{ to: '/admin/evolutions', label: 'Evolutions' },
]
export function AdminLayout() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
<div className="flex gap-8">
<nav className="w-48 flex-shrink-0">
<ul className="space-y-1">
<div className="flex flex-col sm:flex-row gap-6 sm:gap-8">
<nav className="flex-shrink-0 sm:w-48">
<ul className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-visible">
{navItems.map((item) => (
<li key={item.to}>
<li key={item.to} className="flex-shrink-0">
<NavLink
to={item.to}
className={({ isActive }) =>
`block px-3 py-2 rounded-md text-sm font-medium ${
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'

View File

@@ -1,11 +1,14 @@
import { type ReactNode } from 'react'
import { type ReactNode, useMemo, useState } from 'react'
export interface Column<T> {
header: string
accessor: (row: T) => ReactNode
className?: string
sortKey?: (row: T) => string | number
}
type SortDir = 'asc' | 'desc'
interface AdminTableProps<T> {
columns: Column<T>[]
data: T[]
@@ -23,56 +26,127 @@ export function AdminTable<T>({
onRowClick,
keyFn,
}: AdminTableProps<T>) {
const [sortCol, setSortCol] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
const handleSort = (header: string) => {
if (sortCol === header) {
if (sortDir === 'asc') {
setSortDir('desc')
} else {
// Third click: clear sort
setSortCol(null)
setSortDir('asc')
}
} else {
setSortCol(header)
setSortDir('asc')
}
}
const sortedData = useMemo(() => {
if (!sortCol) return data
const col = columns.find((c) => c.header === sortCol)
if (!col?.sortKey) return data
const key = col.sortKey
const sorted = [...data].sort((a, b) => {
const va = key(a)
const vb = key(b)
if (va < vb) return -1
if (va > vb) return 1
return 0
})
return sortDir === 'desc' ? sorted.reverse() : sorted
}, [data, sortCol, sortDir, columns])
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Loading...
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{columns.map((col) => (
<th
key={col.header}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
if (data.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
{emptyMessage}
</div>
)
}
return (
<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>
{columns.map((col) => (
<th
key={col.header}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((row) => (
<tr
key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
>
{columns.map((col) => (
<td
key={col.header}
className={`px-4 py-3 text-sm whitespace-nowrap ${col.className ?? ''}`}
>
{col.accessor(row)}
</td>
))}
<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>
{columns.map((col) => {
const sortable = !!col.sortKey
const active = sortCol === col.header
return (
<th
key={col.header}
onClick={sortable ? () => handleSort(col.header) : undefined}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
>
<span className="inline-flex items-center gap-1">
{col.header}
{sortable && active && (
<span className="text-blue-500">
{sortDir === 'asc' ? '\u2191' : '\u2193'}
</span>
)}
</span>
</th>
)
})}
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{sortedData.map((row) => (
<tr
key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
>
{columns.map((col) => (
<td
key={col.header}
className={`px-4 py-3 text-sm whitespace-nowrap ${col.className ?? ''}`}
>
{col.accessor(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { PokemonSelector } from './PokemonSelector'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
interface EvolutionFormModalProps {
evolution?: EvolutionAdmin
onSubmit: (data: CreateEvolutionInput | UpdateEvolutionInput) => void
onClose: () => void
isSubmitting?: boolean
}
const TRIGGER_OPTIONS = ['level-up', 'trade', 'use-item', 'shed', 'other']
export function EvolutionFormModal({
evolution,
onSubmit,
onClose,
isSubmitting,
}: EvolutionFormModalProps) {
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
evolution?.fromPokemonId ?? null,
)
const [toPokemonId, setToPokemonId] = useState<number | null>(
evolution?.toPokemonId ?? null,
)
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
const [item, setItem] = useState(evolution?.item ?? '')
const [heldItem, setHeldItem] = useState(evolution?.heldItem ?? '')
const [condition, setCondition] = useState(evolution?.condition ?? '')
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (!fromPokemonId || !toPokemonId) return
onSubmit({
fromPokemonId,
toPokemonId,
trigger,
minLevel: minLevel ? Number(minLevel) : null,
item: item || null,
heldItem: heldItem || null,
condition: condition || null,
})
}
return (
<FormModal
title={evolution ? 'Edit Evolution' : 'Add Evolution'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<PokemonSelector
label="From Pokemon"
selectedId={fromPokemonId}
initialName={evolution?.fromPokemon.name}
onChange={setFromPokemonId}
/>
<PokemonSelector
label="To Pokemon"
selectedId={toPokemonId}
initialName={evolution?.toPokemon.name}
onChange={setToPokemonId}
/>
<div>
<label className="block text-sm font-medium mb-1">Trigger</label>
<select
value={trigger}
onChange={(e) => setTrigger(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
{TRIGGER_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Min Level</label>
<input
type="number"
min={1}
max={100}
value={minLevel}
onChange={(e) => setMinLevel(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Item</label>
<input
type="text"
value={item}
onChange={(e) => setItem(e.target.value)}
placeholder="e.g. thunder-stone"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Held Item</label>
<input
type="text"
value={heldItem}
onChange={(e) => setHeldItem(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Condition</label>
<input
type="text"
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="e.g. high-happiness, daytime"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</FormModal>
)
}

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>
)
}

View File

@@ -7,3 +7,5 @@ export { RouteFormModal } from './RouteFormModal'
export { PokemonFormModal } from './PokemonFormModal'
export { BulkImportModal } from './BulkImportModal'
export { RouteEncounterFormModal } from './RouteEncounterFormModal'
export { EvolutionFormModal } from './EvolutionFormModal'
export { PokemonSelector } from './PokemonSelector'