Add section field to boss battles for run progression dividers

Adds a nullable `section` column to boss battles (e.g. "Main Story",
"Endgame") with dividers rendered in the run view between sections.
Admin UI gets a Section column in the table and a text input in the form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 14:55:26 +01:00
parent a01d01c565
commit a4f814e66e
9 changed files with 199 additions and 24 deletions

View File

@@ -26,6 +26,7 @@ import {
useUpdateRoute,
useDeleteRoute,
useReorderRoutes,
useReorderBosses,
useCreateBossBattle,
useUpdateBossBattle,
useDeleteBossBattle,
@@ -82,6 +83,59 @@ function SortableRouteRow({
)
}
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>
<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>
)
}
export function AdminGameDetail() {
const { gameId } = useParams<{ gameId: string }>()
const id = Number(gameId)
@@ -95,6 +149,7 @@ export function AdminGameDetail() {
const createBoss = useCreateBossBattle(id)
const updateBoss = useUpdateBossBattle(id)
const deleteBoss = useDeleteBossBattle(id)
const reorderBosses = useReorderBosses(id)
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
const [showCreate, setShowCreate] = useState(false)
@@ -133,6 +188,26 @@ export function AdminGameDetail() {
reorderRoutes.mutate(newOrders)
}
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)
}
return (
<div>
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
@@ -304,6 +379,7 @@ export function AdminGameDetail() {
<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>
@@ -313,6 +389,9 @@ export function AdminGameDetail() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Section
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Location
</th>
@@ -324,24 +403,26 @@ export function AdminGameDetail() {
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{bosses.map((boss) => (
<tr
key={boss.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => setEditingBoss(boss)}
>
<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>
<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>
))}
</tbody>
<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>
</table>
</div>
</div>