Add admin panel with CRUD endpoints and management UI

Add admin API endpoints for games, routes, pokemon, and route encounters
with full CRUD operations including bulk import. Build admin frontend
with game/route/pokemon management pages, navigation, and data tables.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 18:36:19 +01:00
parent a911259ef5
commit 55e6650e0e
28 changed files with 2140 additions and 10 deletions

View File

@@ -0,0 +1,171 @@
import { useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { RouteFormModal } from '../../components/admin/RouteFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGame } from '../../hooks/useGames'
import {
useCreateRoute,
useUpdateRoute,
useDeleteRoute,
useReorderRoutes,
} from '../../hooks/useAdmin'
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
export function AdminGameDetail() {
const { gameId } = useParams<{ gameId: string }>()
const navigate = useNavigate()
const id = Number(gameId)
const { data: game, isLoading } = useGame(id)
const createRoute = useCreateRoute(id)
const updateRoute = useUpdateRoute(id)
const deleteRoute = useDeleteRoute(id)
const reorderRoutes = useReorderRoutes(id)
const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<GameRoute | null>(null)
const [deleting, setDeleting] = useState<GameRoute | null>(null)
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 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 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">
<Link to="/admin/games" className="hover:underline">
Games
</Link>
{' / '}
<span className="text-gray-900 dark:text-gray-100">{game.name}</span>
</nav>
<div className="mb-6">
<h2 className="text-xl font-semibold">{game.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{game.region} &middot; Gen {game.generation}
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
</p>
</div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Routes ({routes.length})</h3>
<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 Route
</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}
/>
{showCreate && (
<RouteFormModal
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
onSubmit={(data) =>
createRoute.mutate(data as CreateRouteInput, {
onSuccess: () => setShowCreate(false),
})
}
onClose={() => setShowCreate(false)}
isSubmitting={createRoute.isPending}
/>
)}
{editing && (
<RouteFormModal
route={editing}
onSubmit={(data) =>
updateRoute.mutate(
{ routeId: editing.id, data: data as UpdateRouteInput },
{ onSuccess: () => setEditing(null) },
)
}
onClose={() => setEditing(null)}
isSubmitting={updateRoute.isPending}
/>
)}
{deleting && (
<DeleteConfirmModal
title={`Delete ${deleting.name}?`}
message="This will permanently delete the route. Routes with existing encounters cannot be deleted."
onConfirm={() =>
deleteRoute.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => setDeleting(null)}
isDeleting={deleteRoute.isPending}
/>
)}
</div>
)
}