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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,6 +151,7 @@ export interface BossBattle {
|
||||
order: number
|
||||
afterRouteId: number | null
|
||||
location: string
|
||||
section: string | null
|
||||
spriteUrl: string | null
|
||||
pokemon: BossPokemon[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user