Improve admin panel UX with toasts, evolution CRUD, sorting, drag-and-drop, and responsive layout

Add sonner toast notifications to all mutations, evolution management backend
(CRUD endpoints with search/pagination) and frontend (form modal with pokemon
selector, paginated list page), sortable AdminTable columns (Region/Gen/Year
on Games), drag-and-drop route reordering via @dnd-kit, skeleton loading states,
card-styled table wrappers, and responsive mobile nav in AdminLayout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:09:27 +01:00
parent 574e36ee22
commit 1f198aca4c
20 changed files with 1140 additions and 138 deletions

View File

@@ -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}`),
})
}