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

@@ -24,6 +24,12 @@ export function Layout() {
>
My Runs
</Link>
<Link
to="/admin"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Admin
</Link>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { NavLink, Outlet } from 'react-router-dom'
const navItems = [
{ to: '/admin/games', label: 'Games' },
{ to: '/admin/pokemon', label: 'Pokemon' },
]
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">
{navItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
`block px-3 py-2 rounded-md text-sm font-medium ${
isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`
}
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { type ReactNode } from 'react'
export interface Column<T> {
header: string
accessor: (row: T) => ReactNode
className?: string
}
interface AdminTableProps<T> {
columns: Column<T>[]
data: T[]
isLoading?: boolean
emptyMessage?: string
onRowClick?: (row: T) => void
keyFn: (row: T) => string | number
}
export function AdminTable<T>({
columns,
data,
isLoading,
emptyMessage = 'No data found.',
onRowClick,
keyFn,
}: AdminTableProps<T>) {
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Loading...
</div>
)
}
if (data.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{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>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { type FormEvent, useState } from 'react'
import type { BulkImportResult } from '../../types'
interface BulkImportModalProps {
onSubmit: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise<BulkImportResult>
onClose: () => void
}
const EXAMPLE = `[
{ "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] },
{ "nationalDex": 4, "name": "Charmander", "types": ["Fire"] }
]`
export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
const [json, setJson] = useState('')
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BulkImportResult | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
setResult(null)
let items: unknown[]
try {
items = JSON.parse(json)
if (!Array.isArray(items)) throw new Error('Must be an array')
} catch {
setError('Invalid JSON. Must be an array of pokemon objects.')
return
}
setIsSubmitting(true)
try {
const res = await onSubmit(items as Array<{ nationalDex: number; name: string; types: string[] }>)
setResult(res)
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">Bulk Import Pokemon</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
JSON Data
</label>
<textarea
rows={12}
value={json}
onChange={(e) => setJson(e.target.value)}
placeholder={EXAMPLE}
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-md text-sm">
{error}
</div>
)}
{result && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
<p>Created: {result.created}, Updated: {result.updated}</p>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Close
</button>
<button
type="submit"
disabled={isSubmitting || !json.trim()}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Importing...' : 'Import'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
interface DeleteConfirmModalProps {
title: string
message: string
onConfirm: () => void
onCancel: () => void
isDeleting?: boolean
}
export function DeleteConfirmModal({
title,
message,
onConfirm,
onCancel,
isDeleting,
}: DeleteConfirmModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4">
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
{title}
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{message}
</p>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="px-4 py-2 text-sm font-medium rounded-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { type FormEvent, type ReactNode } from 'react'
interface FormModalProps {
title: string
onClose: () => void
onSubmit: (e: FormEvent) => void
children: ReactNode
submitLabel?: string
isSubmitting?: boolean
}
export function FormModal({
title,
onClose,
onSubmit,
children,
submitLabel = 'Save',
isSubmitting,
}: FormModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">{title}</h2>
</div>
<form onSubmit={onSubmit}>
<div className="px-6 py-4 space-y-4">{children}</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : submitLabel}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { type FormEvent, useState, useEffect } from 'react'
import { FormModal } from './FormModal'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
interface GameFormModalProps {
game?: Game
onSubmit: (data: CreateGameInput | UpdateGameInput) => void
onClose: () => void
isSubmitting?: boolean
}
function slugify(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function GameFormModal({ game, onSubmit, onClose, isSubmitting }: GameFormModalProps) {
const [name, setName] = useState(game?.name ?? '')
const [slug, setSlug] = useState(game?.slug ?? '')
const [generation, setGeneration] = useState(String(game?.generation ?? ''))
const [region, setRegion] = useState(game?.region ?? '')
const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '')
const [releaseYear, setReleaseYear] = useState(game?.releaseYear ? String(game.releaseYear) : '')
const [autoSlug, setAutoSlug] = useState(!game)
useEffect(() => {
if (autoSlug) setSlug(slugify(name))
}, [name, autoSlug])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
onSubmit({
name,
slug,
generation: Number(generation),
region,
boxArtUrl: boxArtUrl || null,
releaseYear: releaseYear ? Number(releaseYear) : null,
})
}
return (
<FormModal
title={game ? 'Edit Game' : 'Add Game'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
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">Slug</label>
<input
type="text"
required
value={slug}
onChange={(e) => {
setSlug(e.target.value)
setAutoSlug(false)
}}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Generation</label>
<input
type="number"
required
min={1}
value={generation}
onChange={(e) => setGeneration(e.target.value)}
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">Region</label>
<input
type="text"
required
value={region}
onChange={(e) => setRegion(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Box Art URL</label>
<input
type="text"
value={boxArtUrl}
onChange={(e) => setBoxArtUrl(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">Release Year</label>
<input
type="number"
value={releaseYear}
onChange={(e) => setReleaseYear(e.target.value)}
placeholder="Optional"
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,83 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
interface PokemonFormModalProps {
pokemon?: Pokemon
onSubmit: (data: CreatePokemonInput | UpdatePokemonInput) => void
onClose: () => void
isSubmitting?: boolean
}
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting }: PokemonFormModalProps) {
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
const [name, setName] = useState(pokemon?.name ?? '')
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const typesList = types
.split(',')
.map((t) => t.trim())
.filter(Boolean)
onSubmit({
nationalDex: Number(nationalDex),
name,
types: typesList,
spriteUrl: spriteUrl || null,
})
}
return (
<FormModal
title={pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<label className="block text-sm font-medium mb-1">National Dex #</label>
<input
type="number"
required
min={1}
value={nationalDex}
onChange={(e) => setNationalDex(e.target.value)}
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">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
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">Types (comma-separated)</label>
<input
type="text"
required
value={types}
onChange={(e) => setTypes(e.target.value)}
placeholder="Fire, Flying"
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">Sprite URL</label>
<input
type="text"
value={spriteUrl}
onChange={(e) => setSpriteUrl(e.target.value)}
placeholder="Optional"
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,134 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { usePokemonList } from '../../hooks/useAdmin'
import type { RouteEncounterDetail, CreateRouteEncounterInput, UpdateRouteEncounterInput } from '../../types'
interface RouteEncounterFormModalProps {
encounter?: RouteEncounterDetail
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
onClose: () => void
isSubmitting?: boolean
}
export function RouteEncounterFormModal({
encounter,
onSubmit,
onClose,
isSubmitting,
}: RouteEncounterFormModalProps) {
const [search, setSearch] = useState('')
const [pokemonId, setPokemonId] = useState(encounter?.pokemonId ?? 0)
const [encounterMethod, setEncounterMethod] = useState(encounter?.encounterMethod ?? '')
const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
const { data: pokemonOptions = [] } = usePokemonList(search || undefined)
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (encounter) {
onSubmit({
encounterMethod,
encounterRate: Number(encounterRate),
minLevel: Number(minLevel),
maxLevel: Number(maxLevel),
})
} else {
onSubmit({
pokemonId,
encounterMethod,
encounterRate: Number(encounterRate),
minLevel: Number(minLevel),
maxLevel: Number(maxLevel),
})
}
}
return (
<FormModal
title={encounter ? 'Edit Route Encounter' : 'Add Pokemon to Route'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
{!encounter && (
<div>
<label className="block text-sm font-medium mb-1">Pokemon</label>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search pokemon..."
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 mb-2"
/>
{pokemonOptions.length > 0 && (
<select
required
value={pokemonId || ''}
onChange={(e) => setPokemonId(Number(e.target.value))}
size={Math.min(pokemonOptions.length, 6)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
{!pokemonId && <option value="">Select a pokemon...</option>}
{pokemonOptions.map((p) => (
<option key={p.id} value={p.id}>
#{p.nationalDex} {p.name} ({p.types.join('/')})
</option>
))}
</select>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Encounter Method</label>
<input
type="text"
required
value={encounterMethod}
onChange={(e) => setEncounterMethod(e.target.value)}
placeholder="e.g. Walking, Surfing, Fishing"
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">Encounter Rate (%)</label>
<input
type="number"
required
min={1}
max={100}
value={encounterRate}
onChange={(e) => setEncounterRate(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Min Level</label>
<input
type="number"
required
min={1}
max={100}
value={minLevel}
onChange={(e) => setMinLevel(e.target.value)}
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">Max Level</label>
<input
type="number"
required
min={1}
max={100}
value={maxLevel}
onChange={(e) => setMaxLevel(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
</FormModal>
)
}

View File

@@ -0,0 +1,52 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import type { Route, CreateRouteInput, UpdateRouteInput } from '../../types'
interface RouteFormModalProps {
route?: Route
nextOrder?: number
onSubmit: (data: CreateRouteInput | UpdateRouteInput) => void
onClose: () => void
isSubmitting?: boolean
}
export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting }: RouteFormModalProps) {
const [name, setName] = useState(route?.name ?? '')
const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
onSubmit({ name, order: Number(order) })
}
return (
<FormModal
title={route ? 'Edit Route' : 'Add Route'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
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">Order</label>
<input
type="number"
required
min={0}
value={order}
onChange={(e) => setOrder(e.target.value)}
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,9 @@
export { AdminLayout } from './AdminLayout'
export { AdminTable, type Column } from './AdminTable'
export { FormModal } from './FormModal'
export { DeleteConfirmModal } from './DeleteConfirmModal'
export { GameFormModal } from './GameFormModal'
export { RouteFormModal } from './RouteFormModal'
export { PokemonFormModal } from './PokemonFormModal'
export { BulkImportModal } from './BulkImportModal'
export { RouteEncounterFormModal } from './RouteEncounterFormModal'