2026-02-05 18:36:19 +01:00
|
|
|
import { useState } from 'react'
|
2026-02-08 13:44:38 +01:00
|
|
|
import { useParams, Link } from 'react-router-dom'
|
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>
2026-02-07 13:09:27 +01:00
|
|
|
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'
|
2026-02-08 20:14:19 +01:00
|
|
|
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
2026-02-05 18:36:19 +01:00
|
|
|
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
2026-02-08 11:16:13 +01:00
|
|
|
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
|
|
|
|
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
2026-02-08 15:38:10 +01:00
|
|
|
import { TypeBadge } from '../../components/TypeBadge'
|
2026-02-05 18:36:19 +01:00
|
|
|
import { useGame } from '../../hooks/useGames'
|
2026-02-08 11:16:13 +01:00
|
|
|
import { useGameBosses } from '../../hooks/useBosses'
|
2026-02-05 18:36:19 +01:00
|
|
|
import {
|
|
|
|
|
useCreateRoute,
|
|
|
|
|
useUpdateRoute,
|
|
|
|
|
useDeleteRoute,
|
|
|
|
|
useReorderRoutes,
|
2026-02-08 20:14:19 +01:00
|
|
|
useBulkImportRoutes,
|
2026-02-08 14:55:26 +01:00
|
|
|
useReorderBosses,
|
2026-02-08 11:16:13 +01:00
|
|
|
useCreateBossBattle,
|
|
|
|
|
useUpdateBossBattle,
|
|
|
|
|
useDeleteBossBattle,
|
|
|
|
|
useSetBossTeam,
|
2026-02-08 20:14:19 +01:00
|
|
|
useBulkImportBosses,
|
2026-02-05 18:36:19 +01:00
|
|
|
} from '../../hooks/useAdmin'
|
2026-02-08 11:52:18 +01:00
|
|
|
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
2026-02-08 10:50:14 +01:00
|
|
|
import { downloadJson } from '../../utils/download'
|
2026-02-08 11:16:13 +01:00
|
|
|
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
|
|
|
|
|
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
2026-02-05 18:36:19 +01:00
|
|
|
|
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>
2026-02-07 13:09:27 +01:00
|
|
|
function SortableRouteRow({
|
|
|
|
|
route,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
route: GameRoute
|
|
|
|
|
onClick: (r: GameRoute) => void
|
|
|
|
|
}) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
|
|
|
useSortable({ id: route.id })
|
|
|
|
|
|
|
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
|
|
|
|
|
onClick={() => onClick(route)}
|
|
|
|
|
>
|
|
|
|
|
<td className="px-4 py-3 text-sm w-12">
|
|
|
|
|
<button
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
|
|
|
|
title="Drag to reorder"
|
|
|
|
|
>
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
|
|
|
<circle cx="5" cy="3" r="1.5" />
|
|
|
|
|
<circle cx="11" cy="3" r="1.5" />
|
|
|
|
|
<circle cx="5" cy="8" r="1.5" />
|
|
|
|
|
<circle cx="11" cy="8" r="1.5" />
|
|
|
|
|
<circle cx="5" cy="13" r="1.5" />
|
|
|
|
|
<circle cx="11" cy="13" r="1.5" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{route.order}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 14:55:26 +01:00
|
|
|
function SortableBossRow({
|
|
|
|
|
boss,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
boss: BossBattle
|
|
|
|
|
onClick: (b: BossBattle) => void
|
|
|
|
|
}) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
|
|
|
useSortable({ id: boss.id })
|
|
|
|
|
|
|
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
|
|
|
|
|
onClick={() => onClick(boss)}
|
|
|
|
|
>
|
|
|
|
|
<td className="px-4 py-3 text-sm w-12">
|
|
|
|
|
<button
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
|
|
|
|
title="Drag to reorder"
|
|
|
|
|
>
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
|
|
|
<circle cx="5" cy="3" r="1.5" />
|
|
|
|
|
<circle cx="11" cy="3" r="1.5" />
|
|
|
|
|
<circle cx="5" cy="8" r="1.5" />
|
|
|
|
|
<circle cx="11" cy="8" r="1.5" />
|
|
|
|
|
<circle cx="5" cy="13" r="1.5" />
|
|
|
|
|
<circle cx="11" cy="13" r="1.5" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
|
|
|
|
|
{boss.bossType.replace('_', ' ')}
|
|
|
|
|
</td>
|
2026-02-08 15:38:10 +01:00
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
|
|
|
{boss.specialtyType ? <TypeBadge type={boss.specialtyType} /> : '\u2014'}
|
|
|
|
|
</td>
|
2026-02-08 14:55:26 +01:00
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.section ?? '\u2014'}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 18:36:19 +01:00
|
|
|
export function AdminGameDetail() {
|
|
|
|
|
const { gameId } = useParams<{ gameId: string }>()
|
|
|
|
|
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)
|
2026-02-08 20:14:19 +01:00
|
|
|
const bulkImportRoutes = useBulkImportRoutes(id)
|
2026-02-08 11:16:13 +01:00
|
|
|
const { data: bosses } = useGameBosses(id)
|
|
|
|
|
const createBoss = useCreateBossBattle(id)
|
|
|
|
|
const updateBoss = useUpdateBossBattle(id)
|
|
|
|
|
const deleteBoss = useDeleteBossBattle(id)
|
2026-02-08 14:55:26 +01:00
|
|
|
const reorderBosses = useReorderBosses(id)
|
2026-02-08 20:14:19 +01:00
|
|
|
const bulkImportBosses = useBulkImportBosses(id)
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
2026-02-05 18:36:19 +01:00
|
|
|
const [showCreate, setShowCreate] = useState(false)
|
2026-02-08 20:14:19 +01:00
|
|
|
const [showBulkImportRoutes, setShowBulkImportRoutes] = useState(false)
|
2026-02-05 18:36:19 +01:00
|
|
|
const [editing, setEditing] = useState<GameRoute | null>(null)
|
2026-02-08 11:16:13 +01:00
|
|
|
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
2026-02-08 20:14:19 +01:00
|
|
|
const [showBulkImportBosses, setShowBulkImportBosses] = useState(false)
|
2026-02-08 11:16:13 +01:00
|
|
|
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
|
|
|
|
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
2026-02-05 18:36:19 +01:00
|
|
|
|
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>
2026-02-07 13:09:27 +01:00
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
|
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-05 18:36:19 +01:00
|
|
|
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 ?? []
|
|
|
|
|
|
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>
2026-02-07 13:09:27 +01:00
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
|
|
|
const { active, over } = event
|
|
|
|
|
if (!over || active.id === over.id) return
|
2026-02-05 18:36:19 +01:00
|
|
|
|
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>
2026-02-07 13:09:27 +01:00
|
|
|
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)
|
|
|
|
|
}
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 14:55:26 +01:00
|
|
|
const handleBossDragEnd = (event: DragEndEvent) => {
|
|
|
|
|
if (!bosses) return
|
|
|
|
|
const { active, over } = event
|
|
|
|
|
if (!over || active.id === over.id) return
|
|
|
|
|
|
|
|
|
|
const oldIndex = bosses.findIndex((b) => b.id === active.id)
|
|
|
|
|
const newIndex = bosses.findIndex((b) => b.id === over.id)
|
|
|
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
|
|
|
|
|
|
const reordered = [...bosses]
|
|
|
|
|
const [moved] = reordered.splice(oldIndex, 1)
|
|
|
|
|
reordered.splice(newIndex, 0, moved)
|
|
|
|
|
|
|
|
|
|
const newOrders = reordered.map((b, i) => ({
|
|
|
|
|
id: b.id,
|
|
|
|
|
order: i + 1,
|
|
|
|
|
}))
|
|
|
|
|
reorderBosses.mutate(newOrders)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 18:36:19 +01:00
|
|
|
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">
|
2026-02-07 21:29:14 +01:00
|
|
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} · Gen {game.generation}
|
2026-02-05 18:36:19 +01:00
|
|
|
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setTab('routes')}
|
|
|
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
|
|
|
tab === 'routes'
|
|
|
|
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
|
|
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Routes ({routes.length})
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setTab('bosses')}
|
|
|
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
|
|
|
tab === 'bosses'
|
|
|
|
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
|
|
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Boss Battles ({bosses?.length ?? 0})
|
|
|
|
|
</button>
|
2026-02-05 18:36:19 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
{tab === 'routes' && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex justify-end gap-2 mb-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
const result = await exportGameRoutes(id)
|
|
|
|
|
downloadJson(result.data, result.filename)
|
|
|
|
|
}}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
>
|
|
|
|
|
Export
|
|
|
|
|
</button>
|
2026-02-08 20:14:19 +01:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowBulkImportRoutes(true)}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
>
|
|
|
|
|
Bulk Import
|
|
|
|
|
</button>
|
2026-02-08 11:52:18 +01:00
|
|
|
<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>
|
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>
2026-02-07 13:09:27 +01:00
|
|
|
</div>
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 20:14:19 +01:00
|
|
|
{showBulkImportRoutes && (
|
|
|
|
|
<BulkImportModal
|
|
|
|
|
title="Bulk Import Routes"
|
|
|
|
|
example={`[\n { "name": "Route 1", "order": 1, "encounters": [...], "children": [...] }\n]`}
|
|
|
|
|
createdLabel="Routes"
|
|
|
|
|
updatedLabel="Encounters"
|
|
|
|
|
onSubmit={(items) => bulkImportRoutes.mutateAsync(items)}
|
|
|
|
|
onClose={() => setShowBulkImportRoutes(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
{routes.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
|
|
|
No routes yet. Add one to get started.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
|
|
|
<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>
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12" />
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
|
|
|
|
Order
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
|
|
|
Name
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={routes.map((r) => r.id)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
|
|
|
|
{routes.map((route) => (
|
|
|
|
|
<SortableRouteRow
|
|
|
|
|
key={route.id}
|
|
|
|
|
route={route}
|
2026-02-08 13:44:38 +01:00
|
|
|
onClick={(r) => setEditing(r)}
|
2026-02-08 11:52:18 +01:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
{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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
{editing && (
|
|
|
|
|
<RouteFormModal
|
|
|
|
|
route={editing}
|
|
|
|
|
onSubmit={(data) =>
|
|
|
|
|
updateRoute.mutate(
|
|
|
|
|
{ routeId: editing.id, data: data as UpdateRouteInput },
|
|
|
|
|
{ onSuccess: () => setEditing(null) },
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onClose={() => setEditing(null)}
|
|
|
|
|
isSubmitting={updateRoute.isPending}
|
2026-02-08 13:44:38 +01:00
|
|
|
onDelete={() =>
|
|
|
|
|
deleteRoute.mutate(editing.id, {
|
|
|
|
|
onSuccess: () => setEditing(null),
|
2026-02-08 11:52:18 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
isDeleting={deleteRoute.isPending}
|
2026-02-08 13:44:38 +01:00
|
|
|
detailUrl={`/admin/games/${id}/routes/${editing.id}`}
|
2026-02-08 11:52:18 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-02-08 11:16:13 +01:00
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
{tab === 'bosses' && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex justify-end gap-2 mb-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
const result = await exportGameBosses(id)
|
|
|
|
|
downloadJson(result.data, result.filename)
|
|
|
|
|
}}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
>
|
|
|
|
|
Export
|
|
|
|
|
</button>
|
2026-02-08 20:14:19 +01:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowBulkImportBosses(true)}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
|
|
|
>
|
|
|
|
|
Bulk Import
|
|
|
|
|
</button>
|
2026-02-08 11:52:18 +01:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowCreateBoss(true)}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
|
|
|
>
|
|
|
|
|
Add Boss Battle
|
|
|
|
|
</button>
|
2026-02-08 11:16:13 +01:00
|
|
|
</div>
|
2026-02-08 11:52:18 +01:00
|
|
|
|
2026-02-08 20:14:19 +01:00
|
|
|
{showBulkImportBosses && (
|
|
|
|
|
<BulkImportModal
|
|
|
|
|
title="Bulk Import Boss Battles"
|
|
|
|
|
example={`[\n { "name": "Brock", "boss_type": "gym_leader", "level_cap": 15, "order": 1, "location": "Pewter City", "pokemon": [...] }\n]`}
|
|
|
|
|
createdLabel="Bosses"
|
|
|
|
|
updatedLabel="Updated"
|
|
|
|
|
onSubmit={(items) => bulkImportBosses.mutateAsync(items)}
|
|
|
|
|
onClose={() => setShowBulkImportBosses(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-08 11:52:18 +01:00
|
|
|
{!bosses || bosses.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
|
|
|
No boss battles yet. Add one to get started.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
|
|
|
<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>
|
2026-02-08 14:55:26 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12" />
|
2026-02-08 11:52:18 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
|
|
|
|
Order
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
|
|
|
Name
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
|
|
|
Type
|
|
|
|
|
</th>
|
2026-02-08 15:38:10 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
|
|
|
Specialty
|
|
|
|
|
</th>
|
2026-02-08 14:55:26 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
|
|
|
Section
|
|
|
|
|
</th>
|
2026-02-08 11:52:18 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
|
|
|
Location
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
|
|
|
|
|
Lv Cap
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
|
|
|
|
Team
|
|
|
|
|
</th>
|
2026-02-08 11:16:13 +01:00
|
|
|
</tr>
|
2026-02-08 11:52:18 +01:00
|
|
|
</thead>
|
2026-02-08 14:55:26 +01:00
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
onDragEnd={handleBossDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={bosses.map((b) => b.id)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
|
|
|
|
{bosses.map((boss) => (
|
|
|
|
|
<SortableBossRow
|
|
|
|
|
key={boss.id}
|
|
|
|
|
boss={boss}
|
|
|
|
|
onClick={(b) => setEditingBoss(b)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
2026-02-08 11:52:18 +01:00
|
|
|
</table>
|
|
|
|
|
</div>
|
2026-02-08 11:16:13 +01:00
|
|
|
</div>
|
2026-02-08 11:52:18 +01:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-02-08 11:16:13 +01:00
|
|
|
|
|
|
|
|
{/* Boss Battle Modals */}
|
|
|
|
|
{showCreateBoss && (
|
|
|
|
|
<BossBattleFormModal
|
|
|
|
|
routes={routes}
|
|
|
|
|
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
|
|
|
|
|
onSubmit={(data) =>
|
|
|
|
|
createBoss.mutate(data as CreateBossBattleInput, {
|
|
|
|
|
onSuccess: () => setShowCreateBoss(false),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
onClose={() => setShowCreateBoss(false)}
|
|
|
|
|
isSubmitting={createBoss.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editingBoss && (
|
|
|
|
|
<BossBattleFormModal
|
|
|
|
|
boss={editingBoss}
|
|
|
|
|
routes={routes}
|
|
|
|
|
nextOrder={editingBoss.order}
|
|
|
|
|
onSubmit={(data) =>
|
|
|
|
|
updateBoss.mutate(
|
|
|
|
|
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
|
|
|
|
|
{ onSuccess: () => setEditingBoss(null) },
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onClose={() => setEditingBoss(null)}
|
|
|
|
|
isSubmitting={updateBoss.isPending}
|
2026-02-08 13:44:38 +01:00
|
|
|
onDelete={() =>
|
|
|
|
|
deleteBoss.mutate(editingBoss.id, {
|
|
|
|
|
onSuccess: () => setEditingBoss(null),
|
2026-02-08 11:16:13 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
isDeleting={deleteBoss.isPending}
|
2026-02-08 13:44:38 +01:00
|
|
|
onEditTeam={() => {
|
|
|
|
|
setEditingTeam(editingBoss)
|
|
|
|
|
setEditingBoss(null)
|
|
|
|
|
}}
|
2026-02-08 11:16:13 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editingTeam && (
|
|
|
|
|
<BossTeamEditorWrapper
|
|
|
|
|
gameId={id}
|
|
|
|
|
boss={editingTeam}
|
|
|
|
|
onClose={() => setEditingTeam(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-05 18:36:19 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-08 11:16:13 +01:00
|
|
|
|
|
|
|
|
function BossTeamEditorWrapper({
|
|
|
|
|
gameId,
|
|
|
|
|
boss,
|
|
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
gameId: number
|
|
|
|
|
boss: BossBattle
|
|
|
|
|
onClose: () => void
|
|
|
|
|
}) {
|
|
|
|
|
const setBossTeam = useSetBossTeam(gameId, boss.id)
|
|
|
|
|
return (
|
|
|
|
|
<BossTeamEditor
|
|
|
|
|
boss={boss}
|
|
|
|
|
onSave={(team) =>
|
|
|
|
|
setBossTeam.mutate(team, { onSuccess: onClose })
|
|
|
|
|
}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
isSaving={setBossTeam.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|