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:
@@ -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>
|
||||
|
||||
39
frontend/src/components/admin/AdminLayout.tsx
Normal file
39
frontend/src/components/admin/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/admin/AdminTable.tsx
Normal file
78
frontend/src/components/admin/AdminTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
frontend/src/components/admin/BulkImportModal.tsx
Normal file
106
frontend/src/components/admin/BulkImportModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/admin/DeleteConfirmModal.tsx
Normal file
48
frontend/src/components/admin/DeleteConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/admin/FormModal.tsx
Normal file
49
frontend/src/components/admin/FormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/components/admin/GameFormModal.tsx
Normal file
119
frontend/src/components/admin/GameFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
frontend/src/components/admin/PokemonFormModal.tsx
Normal file
83
frontend/src/components/admin/PokemonFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/admin/RouteEncounterFormModal.tsx
Normal file
134
frontend/src/components/admin/RouteEncounterFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/admin/RouteFormModal.tsx
Normal file
52
frontend/src/components/admin/RouteFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/components/admin/index.ts
Normal file
9
frontend/src/components/admin/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user