develop #21

Merged
TheFurya merged 8 commits from develop into main 2026-02-14 10:01:44 +01:00
2 changed files with 139 additions and 59 deletions
Showing only changes of commit 1b6970a982 - Show all commits

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-hit0
title: Group parent/child routes in admin route table
status: completed
type: feature
priority: normal
created_at: 2026-02-13T13:33:36Z
updated_at: 2026-02-13T13:34:42Z
---
Visually indent child routes under their parent in the admin route table. Dragging a parent route moves all its children with it. Children cannot be independently dragged to a new position.
## Checklist
- [x] Add organizeRoutes() function to AdminGameDetail.tsx
- [x] Replace SortableRouteRow with SortableRouteGroup using multiple tbody elements
- [x] Update SortableContext to only track group IDs
- [x] Update handleDragEnd for group-aware reordering
- [x] Handle edge cases (standalone routes, orphan children)
- [x] Verify frontend build passes

View File

@@ -38,20 +38,46 @@ import {
} from '../../hooks/useAdmin'
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { Route as GameRoute, RouteWithChildren, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
function SortableRouteRow({
route,
/**
* Organize flat routes into hierarchical structure.
* Routes with parentRouteId are grouped under their parent.
* Orphan children (parent missing) are treated as top-level.
*/
function organizeRoutes(routes: GameRoute[]): RouteWithChildren[] {
const childrenByParent = new Map<number, GameRoute[]>()
const topLevel: GameRoute[] = []
const parentIds = new Set(routes.map((r) => r.id))
for (const route of routes) {
if (route.parentRouteId === null || !parentIds.has(route.parentRouteId)) {
topLevel.push(route)
} else {
const children = childrenByParent.get(route.parentRouteId) ?? []
children.push(route)
childrenByParent.set(route.parentRouteId, children)
}
}
return topLevel.map((route) => ({
...route,
children: childrenByParent.get(route.id) ?? [],
}))
}
function SortableRouteGroup({
group,
gameId,
onClick,
}: {
route: GameRoute
group: RouteWithChildren
gameId: number
onClick: (r: GameRoute) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: route.id })
useSortable({ id: group.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -59,45 +85,77 @@ function SortableRouteRow({
}
return (
<tr
<tbody
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)}
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} divide-y divide-gray-200 dark:divide-gray-700`}
>
<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"
<tr
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => onClick(group)}
>
<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">{group.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{group.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<Link
to={`/admin/games/${gameId}/routes/${group.id}`}
onClick={(e) => e.stopPropagation()}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Encounters
</Link>
</td>
</tr>
{group.children.map((child) => (
<tr
key={child.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => onClick(child)}
>
<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 text-center">
{route.pinwheelZone != null ? route.pinwheelZone : '\u2014'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<Link
to={`/admin/games/${gameId}/routes/${route.id}`}
onClick={(e) => e.stopPropagation()}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Encounters
</Link>
</td>
</tr>
<td className="px-4 py-3 text-sm w-12" />
<td className="px-4 py-3 text-sm whitespace-nowrap w-16 text-gray-400 dark:text-gray-500">
{child.order}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
<span className="text-gray-300 dark:text-gray-600 mr-1.5">{'\u2514'}</span>
{child.name}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
{child.pinwheelZone != null ? child.pinwheelZone : '\u2014'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<Link
to={`/admin/games/${gameId}/routes/${child.id}`}
onClick={(e) => e.stopPropagation()}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Encounters
</Link>
</td>
</tr>
))}
</tbody>
)
}
@@ -214,24 +272,29 @@ export function AdminGameDetail() {
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
const routes = game.routes ?? []
const routeGroups = organizeRoutes(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)
const oldIndex = routeGroups.findIndex((g) => g.id === active.id)
const newIndex = routeGroups.findIndex((g) => g.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
// Build new order assignments based on rearranged positions
const reordered = [...routes]
const reordered = [...routeGroups]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
const newOrders = reordered.map((r, i) => ({
id: r.id,
order: i + 1,
}))
// Flatten groups back to individual routes with sequential order numbers
let order = 1
const newOrders: { id: number; order: number }[] = []
for (const group of reordered) {
newOrders.push({ id: group.id, order: order++ })
for (const child of group.children) {
newOrders.push({ id: child.id, order: order++ })
}
}
reorderRoutes.mutate(newOrders)
}
@@ -364,19 +427,17 @@ export function AdminGameDetail() {
onDragEnd={handleDragEnd}
>
<SortableContext
items={routes.map((r) => r.id)}
items={routeGroups.map((g) => g.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}
gameId={id}
onClick={(r) => setEditing(r)}
/>
))}
</tbody>
{routeGroups.map((group) => (
<SortableRouteGroup
key={group.id}
group={group}
gameId={id}
onClick={(r) => setEditing(r)}
/>
))}
</SortableContext>
</DndContext>
</table>