diff --git a/.beans/nuzlocke-tracker-hit0--group-parentchild-routes-in-admin-route-table.md b/.beans/nuzlocke-tracker-hit0--group-parentchild-routes-in-admin-route-table.md new file mode 100644 index 0000000..760432c --- /dev/null +++ b/.beans/nuzlocke-tracker-hit0--group-parentchild-routes-in-admin-route-table.md @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx index 7fc09ab..3fe58a5 100644 --- a/frontend/src/pages/admin/AdminGameDetail.tsx +++ b/frontend/src/pages/admin/AdminGameDetail.tsx @@ -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() + 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 ( - onClick(route)} + className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} divide-y divide-gray-200 dark:divide-gray-700`} > - - + + {group.order} + {group.name} + + {group.pinwheelZone != null ? group.pinwheelZone : '\u2014'} + + + e.stopPropagation()} + className="text-blue-600 dark:text-blue-400 hover:underline" + > + Encounters + + + + {group.children.map((child) => ( + onClick(child)} > - - - - - - - - - - - {route.order} - {route.name} - - {route.pinwheelZone != null ? route.pinwheelZone : '\u2014'} - - - e.stopPropagation()} - className="text-blue-600 dark:text-blue-400 hover:underline" - > - Encounters - - - + + + {child.order} + + + {'\u2514'} + {child.name} + + + {child.pinwheelZone != null ? child.pinwheelZone : '\u2014'} + + + e.stopPropagation()} + className="text-blue-600 dark:text-blue-400 hover:underline" + > + Encounters + + + + ))} + ) } @@ -214,24 +272,29 @@ export function AdminGameDetail() { if (!game) return
Game not found
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} > r.id)} + items={routeGroups.map((g) => g.id)} strategy={verticalListSortingStrategy} > - - {routes.map((route) => ( - setEditing(r)} - /> - ))} - + {routeGroups.map((group) => ( + setEditing(r)} + /> + ))}