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

@@ -43,6 +43,7 @@ export function BossBattleFormModal({
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
const [location, setLocation] = useState(boss?.location ?? '')
const [section, setSection] = useState(boss?.section ?? '')
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
const handleSubmit = (e: FormEvent) => {
@@ -56,6 +57,7 @@ export function BossBattleFormModal({
order: Number(order),
afterRouteId: afterRouteId ? Number(afterRouteId) : null,
location,
section: section || null,
spriteUrl: spriteUrl || null,
})
}
@@ -146,6 +148,17 @@ export function BossBattleFormModal({
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Section</label>
<input
type="text"
value={section}
onChange={(e) => setSection(e.target.value)}
placeholder="e.g. Main Story, Endgame"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Position After Route</label>
<select

View File

@@ -459,6 +459,20 @@ export function RunEncounters() {
return nextBoss.levelCap
}, [nextBoss, sortedBosses])
// Pre-compute which bosses get a section divider rendered AFTER them
// (when the next boss in order has a different section)
const sectionDividerAfterBoss = useMemo(() => {
const map = new Map<number, string>()
for (let i = 0; i < sortedBosses.length - 1; i++) {
const current = sortedBosses[i]
const next = sortedBosses[i + 1]
if (next.section != null && current.section !== next.section) {
map.set(current.id, next.section)
}
}
return map
}, [sortedBosses])
// Map afterRouteId → BossBattle[] for interleaving
const bossesAfterRoute = useMemo(() => {
const map = new Map<number, BossBattle[]>()
@@ -1023,6 +1037,7 @@ export function RunEncounters() {
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
@@ -1051,12 +1066,12 @@ export function RunEncounters() {
}
return (
<div
key={`boss-${boss.id}`}
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
} px-4 py-3`}
>
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
@@ -1122,6 +1137,14 @@ export function RunEncounters() {
))}
</div>
)}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
</div>
)}
</div>
)
})}

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>

View File

@@ -147,6 +147,7 @@ export interface CreateBossBattleInput {
order: number
afterRouteId?: number | null
location: string
section?: string | null
spriteUrl?: string | null
}
@@ -159,9 +160,15 @@ export interface UpdateBossBattleInput {
order?: number
afterRouteId?: number | null
location?: string
section?: string | null
spriteUrl?: string | null
}
export interface BossReorderItem {
id: number
order: number
}
export interface BossPokemonInput {
pokemonId: number
level: number

View File

@@ -151,6 +151,7 @@ export interface BossBattle {
order: number
afterRouteId: number | null
location: string
section: string | null
spriteUrl: string | null
pokemon: BossPokemon[]
}