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:
197
frontend/src/pages/admin/AdminEvolutions.tsx
Normal file
197
frontend/src/pages/admin/AdminEvolutions.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import {
|
||||
useEvolutionList,
|
||||
useCreateEvolution,
|
||||
useUpdateEvolution,
|
||||
useDeleteEvolution,
|
||||
} from '../../hooks/useAdmin'
|
||||
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function AdminEvolutions() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const offset = page * PAGE_SIZE
|
||||
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset)
|
||||
const evolutions = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
const createEvolution = useCreateEvolution()
|
||||
const updateEvolution = useUpdateEvolution()
|
||||
const deleteEvolution = useDeleteEvolution()
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
||||
const [deleting, setDeleting] = useState<EvolutionAdmin | null>(null)
|
||||
|
||||
const columns: Column<EvolutionAdmin>[] = [
|
||||
{
|
||||
header: 'From',
|
||||
accessor: (e) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{e.fromPokemon.spriteUrl && (
|
||||
<img src={e.fromPokemon.spriteUrl} alt="" className="w-6 h-6" />
|
||||
)}
|
||||
<span>{e.fromPokemon.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'To',
|
||||
accessor: (e) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{e.toPokemon.spriteUrl && (
|
||||
<img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />
|
||||
)}
|
||||
<span>{e.toPokemon.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ header: 'Trigger', accessor: (e) => e.trigger },
|
||||
{ header: 'Level', accessor: (e) => e.minLevel ?? '-' },
|
||||
{ header: 'Item', accessor: (e) => e.item ?? '-' },
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (e) => (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(e)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(e)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Evolutions</h2>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Evolution
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Search by pokemon name, trigger, or item..."
|
||||
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{total} evolutions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
data={evolutions}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No evolutions found."
|
||||
keyFn={(e) => e.id}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(0)}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 px-2">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(totalPages - 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<EvolutionFormModal
|
||||
onSubmit={(data) =>
|
||||
createEvolution.mutate(data as CreateEvolutionInput, {
|
||||
onSuccess: () => setShowCreate(false),
|
||||
})
|
||||
}
|
||||
onClose={() => setShowCreate(false)}
|
||||
isSubmitting={createEvolution.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EvolutionFormModal
|
||||
evolution={editing}
|
||||
onSubmit={(data) =>
|
||||
updateEvolution.mutate(
|
||||
{ id: editing.id, data: data as UpdateEvolutionInput },
|
||||
{ onSuccess: () => setEditing(null) },
|
||||
)
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateEvolution.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete evolution?`}
|
||||
message={`This will permanently delete the evolution from ${deleting.fromPokemon.name} to ${deleting.toPokemon.name}.`}
|
||||
onConfirm={() =>
|
||||
deleteEvolution.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deleteEvolution.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -21,9 +21,9 @@ export function AdminGames() {
|
||||
const columns: Column<Game>[] = [
|
||||
{ header: 'Name', accessor: (g) => g.name },
|
||||
{ header: 'Slug', accessor: (g) => g.slug },
|
||||
{ header: 'Region', accessor: (g) => g.region },
|
||||
{ header: 'Gen', accessor: (g) => g.generation },
|
||||
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-' },
|
||||
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
|
||||
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
|
||||
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 },
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (g) => (
|
||||
|
||||
@@ -2,3 +2,4 @@ export { AdminGames } from './AdminGames'
|
||||
export { AdminGameDetail } from './AdminGameDetail'
|
||||
export { AdminPokemon } from './AdminPokemon'
|
||||
export { AdminRouteDetail } from './AdminRouteDetail'
|
||||
export { AdminEvolutions } from './AdminEvolutions'
|
||||
|
||||
Reference in New Issue
Block a user