Files
nuzlocke-tracker/frontend/src/pages/admin/AdminGameDetail.tsx
Julian Tabel 73d4a1831c Sort seed data routes by game progression instead of alphabetically
Add ROUTE_ORDER maps for Kanto (FRLG), Johto (HGSS), and Hoenn (Emerald)
progressions with aliases for related version groups. Add Export Order
button to admin game detail page for iterating on route orderings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:27:51 +01:00

274 lines
9.4 KiB
TypeScript

import { useState } from 'react'
import { toast } from 'sonner'
import { useParams, useNavigate, Link } from 'react-router-dom'
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'
import {
useCreateRoute,
useUpdateRoute,
useDeleteRoute,
useReorderRoutes,
} 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 (
<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>
<td className="px-4 py-3 text-sm whitespace-nowrap w-32">
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => onEdit(route)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => onDelete(route)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Delete
</button>
</div>
</td>
</tr>
)
}
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)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
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 handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
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)
}
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} &middot; 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>
<div className="flex gap-2">
<button
onClick={() => {
const names = routes.map((r) => r.name)
navigator.clipboard.writeText(JSON.stringify(names, null, 2))
toast.success('Route order copied to clipboard')
}}
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 Order
</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 Route
</button>
</div>
</div>
{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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-32">
Actions
</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}
onEdit={setEditing}
onDelete={setDeleting}
onClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
</div>
)}
{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>
)
}