- Loading...
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col.header}
+ |
+ ))}
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ {columns.map((col) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
)
}
if (data.length === 0) {
return (
-
+
{emptyMessage}
)
}
return (
-
-
-
-
- {columns.map((col) => (
- |
- {col.header}
- |
- ))}
-
-
-
- {data.map((row) => (
- onRowClick(row) : undefined}
- className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
- >
- {columns.map((col) => (
- |
- {col.accessor(row)}
- |
- ))}
+
+
+
+
+
+ {columns.map((col) => {
+ const sortable = !!col.sortKey
+ const active = sortCol === col.header
+ return (
+ | handleSort(col.header) : undefined}
+ className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
+ >
+
+ {col.header}
+ {sortable && active && (
+
+ {sortDir === 'asc' ? '\u2191' : '\u2193'}
+
+ )}
+
+ |
+ )
+ })}
- ))}
-
-
+
+
+ {sortedData.map((row) => (
+ onRowClick(row) : undefined}
+ className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
+ >
+ {columns.map((col) => (
+ |
+ {col.accessor(row)}
+ |
+ ))}
+
+ ))}
+
+
+
)
}
diff --git a/frontend/src/components/admin/EvolutionFormModal.tsx b/frontend/src/components/admin/EvolutionFormModal.tsx
new file mode 100644
index 0000000..3c10023
--- /dev/null
+++ b/frontend/src/components/admin/EvolutionFormModal.tsx
@@ -0,0 +1,124 @@
+import { type FormEvent, useState } from 'react'
+import { FormModal } from './FormModal'
+import { PokemonSelector } from './PokemonSelector'
+import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
+
+interface EvolutionFormModalProps {
+ evolution?: EvolutionAdmin
+ onSubmit: (data: CreateEvolutionInput | UpdateEvolutionInput) => void
+ onClose: () => void
+ isSubmitting?: boolean
+}
+
+const TRIGGER_OPTIONS = ['level-up', 'trade', 'use-item', 'shed', 'other']
+
+export function EvolutionFormModal({
+ evolution,
+ onSubmit,
+ onClose,
+ isSubmitting,
+}: EvolutionFormModalProps) {
+ const [fromPokemonId, setFromPokemonId] = useState
(
+ evolution?.fromPokemonId ?? null,
+ )
+ const [toPokemonId, setToPokemonId] = useState(
+ evolution?.toPokemonId ?? null,
+ )
+ const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
+ const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
+ const [item, setItem] = useState(evolution?.item ?? '')
+ const [heldItem, setHeldItem] = useState(evolution?.heldItem ?? '')
+ const [condition, setCondition] = useState(evolution?.condition ?? '')
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault()
+ if (!fromPokemonId || !toPokemonId) return
+ onSubmit({
+ fromPokemonId,
+ toPokemonId,
+ trigger,
+ minLevel: minLevel ? Number(minLevel) : null,
+ item: item || null,
+ heldItem: heldItem || null,
+ condition: condition || null,
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ setMinLevel(e.target.value)}
+ placeholder="Optional"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setItem(e.target.value)}
+ placeholder="e.g. thunder-stone"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setHeldItem(e.target.value)}
+ placeholder="Optional"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setCondition(e.target.value)}
+ placeholder="e.g. high-happiness, daytime"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ )
+}
diff --git a/frontend/src/components/admin/PokemonSelector.tsx b/frontend/src/components/admin/PokemonSelector.tsx
new file mode 100644
index 0000000..fb0a5e9
--- /dev/null
+++ b/frontend/src/components/admin/PokemonSelector.tsx
@@ -0,0 +1,78 @@
+import { useState, useRef, useEffect } from 'react'
+import { usePokemonList } from '../../hooks/useAdmin'
+
+interface PokemonSelectorProps {
+ label: string
+ selectedId: number | null
+ initialName?: string
+ onChange: (id: number | null) => void
+}
+
+export function PokemonSelector({
+ label,
+ selectedId,
+ initialName,
+ onChange,
+}: PokemonSelectorProps) {
+ const [search, setSearch] = useState(initialName ?? '')
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ const { data } = usePokemonList(search || undefined, 20, 0)
+ const pokemon = data?.items ?? []
+
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [])
+
+ return (
+
+
+
{
+ setSearch(e.target.value)
+ setOpen(true)
+ if (!e.target.value) onChange(null)
+ }}
+ onFocus={() => setOpen(true)}
+ placeholder="Search pokemon..."
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+ {selectedId && (
+
+ )}
+ {open && pokemon.length > 0 && (
+
+ {pokemon.map((p) => (
+ - {
+ onChange(p.id)
+ setSearch(p.name)
+ setOpen(false)
+ }}
+ className={`px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2 ${
+ p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
+ }`}
+ >
+ {p.spriteUrl && (
+
+ )}
+
+ #{p.nationalDex} {p.name}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
index 4e0e6ef..b5aa0f5 100644
--- a/frontend/src/components/admin/index.ts
+++ b/frontend/src/components/admin/index.ts
@@ -7,3 +7,5 @@ export { RouteFormModal } from './RouteFormModal'
export { PokemonFormModal } from './PokemonFormModal'
export { BulkImportModal } from './BulkImportModal'
export { RouteEncounterFormModal } from './RouteEncounterFormModal'
+export { EvolutionFormModal } from './EvolutionFormModal'
+export { PokemonSelector } from './PokemonSelector'
diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts
index bdcff49..29ad5dd 100644
--- a/frontend/src/hooks/useAdmin.ts
+++ b/frontend/src/hooks/useAdmin.ts
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
import * as adminApi from '../api/admin'
import type {
CreateGameInput,
@@ -10,6 +11,8 @@ import type {
UpdatePokemonInput,
CreateRouteEncounterInput,
UpdateRouteEncounterInput,
+ CreateEvolutionInput,
+ UpdateEvolutionInput,
} from '../types'
// --- Queries ---
@@ -27,7 +30,11 @@ export function useCreateGame() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateGameInput) => adminApi.createGame(data),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games'] })
+ toast.success('Game created')
+ },
+ onError: (err) => toast.error(`Failed to create game: ${err.message}`),
})
}
@@ -36,7 +43,11 @@ export function useUpdateGame() {
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateGameInput }) =>
adminApi.updateGame(id, data),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games'] })
+ toast.success('Game updated')
+ },
+ onError: (err) => toast.error(`Failed to update game: ${err.message}`),
})
}
@@ -44,7 +55,11 @@ export function useDeleteGame() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => adminApi.deleteGame(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games'] })
+ toast.success('Game deleted')
+ },
+ onError: (err) => toast.error(`Failed to delete game: ${err.message}`),
})
}
@@ -57,7 +72,9 @@ export function useCreateRoute(gameId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ toast.success('Route created')
},
+ onError: (err) => toast.error(`Failed to create route: ${err.message}`),
})
}
@@ -69,7 +86,9 @@ export function useUpdateRoute(gameId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ toast.success('Route updated')
},
+ onError: (err) => toast.error(`Failed to update route: ${err.message}`),
})
}
@@ -80,7 +99,9 @@ export function useDeleteRoute(gameId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ toast.success('Route deleted')
},
+ onError: (err) => toast.error(`Failed to delete route: ${err.message}`),
})
}
@@ -91,7 +112,9 @@ export function useReorderRoutes(gameId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ toast.success('Routes reordered')
},
+ onError: (err) => toast.error(`Failed to reorder routes: ${err.message}`),
})
}
@@ -101,7 +124,11 @@ export function useCreatePokemon() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreatePokemonInput) => adminApi.createPokemon(data),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['pokemon'] })
+ toast.success('Pokemon created')
+ },
+ onError: (err) => toast.error(`Failed to create pokemon: ${err.message}`),
})
}
@@ -110,7 +137,11 @@ export function useUpdatePokemon() {
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePokemonInput }) =>
adminApi.updatePokemon(id, data),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['pokemon'] })
+ toast.success('Pokemon updated')
+ },
+ onError: (err) => toast.error(`Failed to update pokemon: ${err.message}`),
})
}
@@ -118,7 +149,11 @@ export function useDeletePokemon() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => adminApi.deletePokemon(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['pokemon'] })
+ toast.success('Pokemon deleted')
+ },
+ onError: (err) => toast.error(`Failed to delete pokemon: ${err.message}`),
})
}
@@ -127,7 +162,57 @@ export function useBulkImportPokemon() {
return useMutation({
mutationFn: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
adminApi.bulkImportPokemon(items),
- onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ onSuccess: (result) => {
+ qc.invalidateQueries({ queryKey: ['pokemon'] })
+ toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
+ },
+ onError: (err) => toast.error(`Import failed: ${err.message}`),
+ })
+}
+
+// --- Evolution Queries & Mutations ---
+
+export function useEvolutionList(search?: string, limit = 50, offset = 0) {
+ return useQuery({
+ queryKey: ['evolutions', { search, limit, offset }],
+ queryFn: () => adminApi.listEvolutions(search, limit, offset),
+ })
+}
+
+export function useCreateEvolution() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: CreateEvolutionInput) => adminApi.createEvolution(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['evolutions'] })
+ toast.success('Evolution created')
+ },
+ onError: (err) => toast.error(`Failed to create evolution: ${err.message}`),
+ })
+}
+
+export function useUpdateEvolution() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, data }: { id: number; data: UpdateEvolutionInput }) =>
+ adminApi.updateEvolution(id, data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['evolutions'] })
+ toast.success('Evolution updated')
+ },
+ onError: (err) => toast.error(`Failed to update evolution: ${err.message}`),
+ })
+}
+
+export function useDeleteEvolution() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: number) => adminApi.deleteEvolution(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['evolutions'] })
+ toast.success('Evolution deleted')
+ },
+ onError: (err) => toast.error(`Failed to delete evolution: ${err.message}`),
})
}
@@ -138,8 +223,11 @@ export function useAddRouteEncounter(routeId: number) {
return useMutation({
mutationFn: (data: CreateRouteEncounterInput) =>
adminApi.addRouteEncounter(routeId, data),
- onSuccess: () =>
- qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
+ toast.success('Encounter added')
+ },
+ onError: (err) => toast.error(`Failed to add encounter: ${err.message}`),
})
}
@@ -148,8 +236,11 @@ export function useUpdateRouteEncounter(routeId: number) {
return useMutation({
mutationFn: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
adminApi.updateRouteEncounter(routeId, encounterId, data),
- onSuccess: () =>
- qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
+ toast.success('Encounter updated')
+ },
+ onError: (err) => toast.error(`Failed to update encounter: ${err.message}`),
})
}
@@ -158,7 +249,10 @@ export function useRemoveRouteEncounter(routeId: number) {
return useMutation({
mutationFn: (encounterId: number) =>
adminApi.removeRouteEncounter(routeId, encounterId),
- onSuccess: () =>
- qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
+ toast.success('Encounter removed')
+ },
+ onError: (err) => toast.error(`Failed to remove encounter: ${err.message}`),
})
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 354ef4f..067f7df 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { Toaster } from 'sonner'
import './index.css'
import App from './App.tsx'
@@ -19,6 +20,7 @@ createRoot(document.getElementById('root')!).render(
+
,
diff --git a/frontend/src/pages/admin/AdminEvolutions.tsx b/frontend/src/pages/admin/AdminEvolutions.tsx
new file mode 100644
index 0000000..aef45ee
--- /dev/null
+++ b/frontend/src/pages/admin/AdminEvolutions.tsx
@@ -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(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const columns: Column[] = [
+ {
+ header: 'From',
+ accessor: (e) => (
+
+ {e.fromPokemon.spriteUrl && (
+

+ )}
+
{e.fromPokemon.name}
+
+ ),
+ },
+ {
+ header: 'To',
+ accessor: (e) => (
+
+ {e.toPokemon.spriteUrl && (
+

+ )}
+
{e.toPokemon.name}
+
+ ),
+ },
+ { header: 'Trigger', accessor: (e) => e.trigger },
+ { header: 'Level', accessor: (e) => e.minLevel ?? '-' },
+ { header: 'Item', accessor: (e) => e.item ?? '-' },
+ {
+ header: 'Actions',
+ accessor: (e) => (
+
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
Evolutions
+
+
+
+
+ {
+ 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"
+ />
+
+ {total} evolutions
+
+
+
+
e.id}
+ />
+
+ {totalPages > 1 && (
+
+
+ Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
+
+
+
+
+
+ Page {page + 1} of {totalPages}
+
+
+
+
+
+ )}
+
+ {showCreate && (
+
+ createEvolution.mutate(data as CreateEvolutionInput, {
+ onSuccess: () => setShowCreate(false),
+ })
+ }
+ onClose={() => setShowCreate(false)}
+ isSubmitting={createEvolution.isPending}
+ />
+ )}
+
+ {editing && (
+
+ updateEvolution.mutate(
+ { id: editing.id, data: data as UpdateEvolutionInput },
+ { onSuccess: () => setEditing(null) },
+ )
+ }
+ onClose={() => setEditing(null)}
+ isSubmitting={updateEvolution.isPending}
+ />
+ )}
+
+ {deleting && (
+
+ deleteEvolution.mutate(deleting.id, {
+ onSuccess: () => setDeleting(null),
+ })
+ }
+ onCancel={() => setDeleting(null)}
+ isDeleting={deleteEvolution.isPending}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx
index aa22baf..9a61775 100644
--- a/frontend/src/pages/admin/AdminGameDetail.tsx
+++ b/frontend/src/pages/admin/AdminGameDetail.tsx
@@ -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 (
+ onClick(route)}
+ >
+ |
+
+ |
+ {route.order} |
+ {route.name} |
+
+ e.stopPropagation()}>
+
+
+
+ |
+
+ )
+}
+
export function AdminGameDetail() {
const { gameId } = useParams<{ gameId: string }>()
const navigate = useNavigate()
@@ -27,69 +108,36 @@ export function AdminGameDetail() {
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ )
+
if (isLoading) return Loading...
if (!game) return Game not found
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[] = [
- { 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 (
- e.stopPropagation()}>
-
-
-
-
-
- )
- },
- },
- ]
-
return (
- navigate(`/admin/games/${id}/routes/${r.id}`)}
- keyFn={(r) => r.id}
- />
+ {routes.length === 0 ? (
+
+ No routes yet. Add one to get started.
+
+ ) : (
+
+
+
+
+
+ |
+
+ Order
+ |
+
+ Name
+ |
+
+ Actions
+ |
+
+
+
+ r.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {routes.map((route) => (
+ navigate(`/admin/games/${id}/routes/${r.id}`)}
+ />
+ ))}
+
+
+
+
+
+
+ )}
{showCreate && (
[] = [
{ 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) => (
diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts
index 4b62f5c..952af14 100644
--- a/frontend/src/pages/admin/index.ts
+++ b/frontend/src/pages/admin/index.ts
@@ -2,3 +2,4 @@ export { AdminGames } from './AdminGames'
export { AdminGameDetail } from './AdminGameDetail'
export { AdminPokemon } from './AdminPokemon'
export { AdminRouteDetail } from './AdminRouteDetail'
+export { AdminEvolutions } from './AdminEvolutions'
diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts
index d667bcf..6e5ee4e 100644
--- a/frontend/src/types/admin.ts
+++ b/frontend/src/types/admin.ts
@@ -72,3 +72,43 @@ export interface UpdateRouteEncounterInput {
minLevel?: number
maxLevel?: number
}
+
+export interface EvolutionAdmin {
+ id: number
+ fromPokemonId: number
+ toPokemonId: number
+ fromPokemon: import('./game').Pokemon
+ toPokemon: import('./game').Pokemon
+ trigger: string
+ minLevel: number | null
+ item: string | null
+ heldItem: string | null
+ condition: string | null
+}
+
+export interface PaginatedEvolutions {
+ items: EvolutionAdmin[]
+ total: number
+ limit: number
+ offset: number
+}
+
+export interface CreateEvolutionInput {
+ fromPokemonId: number
+ toPokemonId: number
+ trigger: string
+ minLevel?: number | null
+ item?: string | null
+ heldItem?: string | null
+ condition?: string | null
+}
+
+export interface UpdateEvolutionInput {
+ fromPokemonId?: number
+ toPokemonId?: number
+ trigger?: string
+ minLevel?: number | null
+ item?: string | null
+ heldItem?: string | null
+ condition?: string | null
+}