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:
171
frontend/src/pages/admin/AdminGameDetail.tsx
Normal file
171
frontend/src/pages/admin/AdminGameDetail.tsx
Normal 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} · 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>
|
||||
)
|
||||
}
|
||||
110
frontend/src/pages/admin/AdminGames.tsx
Normal file
110
frontend/src/pages/admin/AdminGames.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { GameFormModal } from '../../components/admin/GameFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import { useGames } from '../../hooks/useGames'
|
||||
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
|
||||
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
|
||||
|
||||
export function AdminGames() {
|
||||
const navigate = useNavigate()
|
||||
const { data: games = [], isLoading } = useGames()
|
||||
const createGame = useCreateGame()
|
||||
const updateGame = useUpdateGame()
|
||||
const deleteGame = useDeleteGame()
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<Game | null>(null)
|
||||
const [deleting, setDeleting] = useState<Game | null>(null)
|
||||
|
||||
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: 'Actions',
|
||||
accessor: (g) => (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setEditing(g)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(g)}
|
||||
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">Games</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 Game
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
data={games}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No games yet. Add one to get started."
|
||||
onRowClick={(g) => navigate(`/admin/games/${g.id}`)}
|
||||
keyFn={(g) => g.id}
|
||||
/>
|
||||
|
||||
{showCreate && (
|
||||
<GameFormModal
|
||||
onSubmit={(data) =>
|
||||
createGame.mutate(data as CreateGameInput, {
|
||||
onSuccess: () => setShowCreate(false),
|
||||
})
|
||||
}
|
||||
onClose={() => setShowCreate(false)}
|
||||
isSubmitting={createGame.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<GameFormModal
|
||||
game={editing}
|
||||
onSubmit={(data) =>
|
||||
updateGame.mutate(
|
||||
{ id: editing.id, data: data as UpdateGameInput },
|
||||
{ onSuccess: () => setEditing(null) },
|
||||
)
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateGame.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deleting.name}?`}
|
||||
message="This will permanently delete the game and all its routes. Games with existing runs cannot be deleted."
|
||||
onConfirm={() =>
|
||||
deleteGame.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deleteGame.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
149
frontend/src/pages/admin/AdminPokemon.tsx
Normal file
149
frontend/src/pages/admin/AdminPokemon.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { PokemonFormModal } from '../../components/admin/PokemonFormModal'
|
||||
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import {
|
||||
usePokemonList,
|
||||
useCreatePokemon,
|
||||
useUpdatePokemon,
|
||||
useDeletePokemon,
|
||||
useBulkImportPokemon,
|
||||
} from '../../hooks/useAdmin'
|
||||
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
|
||||
|
||||
export function AdminPokemon() {
|
||||
const [search, setSearch] = useState('')
|
||||
const { data: pokemon = [], isLoading } = usePokemonList(search || undefined)
|
||||
const createPokemon = useCreatePokemon()
|
||||
const updatePokemon = useUpdatePokemon()
|
||||
const deletePokemon = useDeletePokemon()
|
||||
const bulkImport = useBulkImportPokemon()
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showBulkImport, setShowBulkImport] = useState(false)
|
||||
const [editing, setEditing] = useState<Pokemon | null>(null)
|
||||
const [deleting, setDeleting] = useState<Pokemon | null>(null)
|
||||
|
||||
const columns: Column<Pokemon>[] = [
|
||||
{ header: 'Dex #', accessor: (p) => p.nationalDex, className: 'w-16' },
|
||||
{
|
||||
header: 'Sprite',
|
||||
className: 'w-16',
|
||||
accessor: (p) =>
|
||||
p.spriteUrl ? (
|
||||
<img src={p.spriteUrl} alt={p.name} className="w-8 h-8" />
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
),
|
||||
},
|
||||
{ header: 'Name', accessor: (p) => p.name },
|
||||
{ header: 'Types', accessor: (p) => p.types.join(', ') },
|
||||
{
|
||||
header: 'Actions',
|
||||
accessor: (p) => (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(p)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleting(p)}
|
||||
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">Pokemon</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowBulkImport(true)}
|
||||
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"
|
||||
>
|
||||
Bulk Import
|
||||
</button>
|
||||
<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 Pokemon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name..."
|
||||
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
data={pokemon}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No pokemon found."
|
||||
keyFn={(p) => p.id}
|
||||
/>
|
||||
|
||||
{showCreate && (
|
||||
<PokemonFormModal
|
||||
onSubmit={(data) =>
|
||||
createPokemon.mutate(data as CreatePokemonInput, {
|
||||
onSuccess: () => setShowCreate(false),
|
||||
})
|
||||
}
|
||||
onClose={() => setShowCreate(false)}
|
||||
isSubmitting={createPokemon.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBulkImport && (
|
||||
<BulkImportModal
|
||||
onSubmit={(items) => bulkImport.mutateAsync(items)}
|
||||
onClose={() => setShowBulkImport(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<PokemonFormModal
|
||||
pokemon={editing}
|
||||
onSubmit={(data) =>
|
||||
updatePokemon.mutate(
|
||||
{ id: editing.id, data: data as UpdatePokemonInput },
|
||||
{ onSuccess: () => setEditing(null) },
|
||||
)
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updatePokemon.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Delete ${deleting.name}?`}
|
||||
message="This will permanently delete the pokemon. Pokemon with existing encounters cannot be deleted."
|
||||
onConfirm={() =>
|
||||
deletePokemon.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={deletePokemon.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
frontend/src/pages/admin/AdminRouteDetail.tsx
Normal file
155
frontend/src/pages/admin/AdminRouteDetail.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal'
|
||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||
import { useGame, useRoutePokemon } from '../../hooks/useGames'
|
||||
import {
|
||||
useAddRouteEncounter,
|
||||
useUpdateRouteEncounter,
|
||||
useRemoveRouteEncounter,
|
||||
} from '../../hooks/useAdmin'
|
||||
import type {
|
||||
RouteEncounterDetail,
|
||||
CreateRouteEncounterInput,
|
||||
UpdateRouteEncounterInput,
|
||||
} from '../../types'
|
||||
|
||||
export function AdminRouteDetail() {
|
||||
const { gameId, routeId } = useParams<{ gameId: string; routeId: string }>()
|
||||
const gId = Number(gameId)
|
||||
const rId = Number(routeId)
|
||||
|
||||
const { data: game } = useGame(gId)
|
||||
const { data: encounters = [], isLoading } = useRoutePokemon(rId)
|
||||
|
||||
const addEncounter = useAddRouteEncounter(rId)
|
||||
const updateEncounter = useUpdateRouteEncounter(rId)
|
||||
const removeEncounter = useRemoveRouteEncounter(rId)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editing, setEditing] = useState<RouteEncounterDetail | null>(null)
|
||||
const [deleting, setDeleting] = useState<RouteEncounterDetail | null>(null)
|
||||
|
||||
const route = game?.routes?.find((r) => r.id === rId)
|
||||
|
||||
const columns: Column<RouteEncounterDetail>[] = [
|
||||
{
|
||||
header: 'Pokemon',
|
||||
accessor: (e) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{e.pokemon.spriteUrl ? (
|
||||
<img src={e.pokemon.spriteUrl} alt={e.pokemon.name} className="w-6 h-6" />
|
||||
) : null}
|
||||
<span>
|
||||
#{e.pokemon.nationalDex} {e.pokemon.name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ header: 'Method', accessor: (e) => e.encounterMethod },
|
||||
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
|
||||
{
|
||||
header: 'Levels',
|
||||
accessor: (e) =>
|
||||
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
|
||||
},
|
||||
{
|
||||
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"
|
||||
>
|
||||
Remove
|
||||
</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>
|
||||
{' / '}
|
||||
<Link to={`/admin/games/${gId}`} className="hover:underline">
|
||||
{game?.name ?? '...'}
|
||||
</Link>
|
||||
{' / '}
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
{route?.name ?? '...'}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{route?.name ?? 'Route'} - Pokemon ({encounters.length})
|
||||
</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 Pokemon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
data={encounters}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No pokemon assigned to this route yet."
|
||||
keyFn={(e) => e.id}
|
||||
/>
|
||||
|
||||
{showCreate && (
|
||||
<RouteEncounterFormModal
|
||||
onSubmit={(data) =>
|
||||
addEncounter.mutate(data as CreateRouteEncounterInput, {
|
||||
onSuccess: () => setShowCreate(false),
|
||||
})
|
||||
}
|
||||
onClose={() => setShowCreate(false)}
|
||||
isSubmitting={addEncounter.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<RouteEncounterFormModal
|
||||
encounter={editing}
|
||||
onSubmit={(data) =>
|
||||
updateEncounter.mutate(
|
||||
{ encounterId: editing.id, data: data as UpdateRouteEncounterInput },
|
||||
{ onSuccess: () => setEditing(null) },
|
||||
)
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
isSubmitting={updateEncounter.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<DeleteConfirmModal
|
||||
title={`Remove ${deleting.pokemon.name}?`}
|
||||
message="This will remove this pokemon from the route's encounter table."
|
||||
onConfirm={() =>
|
||||
removeEncounter.mutate(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
isDeleting={removeEncounter.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
frontend/src/pages/admin/index.ts
Normal file
4
frontend/src/pages/admin/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AdminGames } from './AdminGames'
|
||||
export { AdminGameDetail } from './AdminGameDetail'
|
||||
export { AdminPokemon } from './AdminPokemon'
|
||||
export { AdminRouteDetail } from './AdminRouteDetail'
|
||||
Reference in New Issue
Block a user