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:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
124
frontend/src/components/admin/EvolutionFormModal.tsx
Normal file
124
frontend/src/components/admin/EvolutionFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/admin/PokemonSelector.tsx
Normal file
78
frontend/src/components/admin/PokemonSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user