Introduces full boss battle system: data models (BossBattle, BossPokemon, BossResult), API endpoints for CRUD and per-run defeat tracking, and frontend UI including a sticky level cap bar with badge display on the run page, interleaved boss battle cards in the encounter list, and an admin panel section for managing boss battles and their pokemon teams. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
import { useState } from 'react'
|
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
|
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
|
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
|
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
|
import { useGame } from '../../hooks/useGames'
|
|
import { useGameBosses } from '../../hooks/useBosses'
|
|
import {
|
|
useCreateRoute,
|
|
useUpdateRoute,
|
|
useDeleteRoute,
|
|
useReorderRoutes,
|
|
useCreateBossBattle,
|
|
useUpdateBossBattle,
|
|
useDeleteBossBattle,
|
|
useSetBossTeam,
|
|
} from '../../hooks/useAdmin'
|
|
import { exportGameRoutes } from '../../api/admin'
|
|
import { downloadJson } from '../../utils/download'
|
|
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
|
|
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
|
|
|
function SortableRouteRow({
|
|
route,
|
|
onEdit,
|
|
onDelete,
|
|
onClick,
|
|
}: {
|
|
route: GameRoute
|
|
onEdit: (r: GameRoute) => void
|
|
onDelete: (r: GameRoute) => void
|
|
onClick: (r: GameRoute) => void
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
useSortable({ id: route.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(route)}
|
|
>
|
|
<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">{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 w-32">
|
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => onEdit(route)}
|
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(route)}
|
|
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
export function AdminGameDetail() {
|
|
const { gameId } = useParams<{ gameId: string }>()
|
|
const navigate = useNavigate()
|
|
const id = Number(gameId)
|
|
const { data: game, isLoading } = useGame(id)
|
|
|
|
const createRoute = useCreateRoute(id)
|
|
const updateRoute = useUpdateRoute(id)
|
|
const deleteRoute = useDeleteRoute(id)
|
|
const reorderRoutes = useReorderRoutes(id)
|
|
const { data: bosses } = useGameBosses(id)
|
|
const createBoss = useCreateBossBattle(id)
|
|
const updateBoss = useUpdateBossBattle(id)
|
|
const deleteBoss = useDeleteBossBattle(id)
|
|
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [editing, setEditing] = useState<GameRoute | null>(null)
|
|
const [deleting, setDeleting] = useState<GameRoute | null>(null)
|
|
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
|
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
|
const [deletingBoss, setDeletingBoss] = useState<BossBattle | null>(null)
|
|
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
)
|
|
|
|
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
|
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
|
|
|
|
const routes = game.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)
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
// Build new order assignments based on rearranged positions
|
|
const reordered = [...routes]
|
|
const [moved] = reordered.splice(oldIndex, 1)
|
|
reordered.splice(newIndex, 0, moved)
|
|
|
|
const newOrders = reordered.map((r, i) => ({
|
|
id: r.id,
|
|
order: i + 1,
|
|
}))
|
|
reorderRoutes.mutate(newOrders)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
|
<Link to="/admin/games" className="hover:underline">
|
|
Games
|
|
</Link>
|
|
{' / '}
|
|
<span className="text-gray-900 dark:text-gray-100">{game.name}</span>
|
|
</nav>
|
|
|
|
<div className="mb-6">
|
|
<h2 className="text-xl font-semibold">{game.name}</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} · Gen {game.generation}
|
|
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-medium">Routes ({routes.length})</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
const result = await exportGameRoutes(id)
|
|
downloadJson(result.data, result.filename)
|
|
}}
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
Export
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
>
|
|
Add Route
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{routes.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
No routes yet. Add one to get started.
|
|
</div>
|
|
) : (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<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>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Name
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-32">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={routes.map((r) => r.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}
|
|
onEdit={setEditing}
|
|
onDelete={setDeleting}
|
|
onClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<RouteFormModal
|
|
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
|
|
onSubmit={(data) =>
|
|
createRoute.mutate(data as CreateRouteInput, {
|
|
onSuccess: () => setShowCreate(false),
|
|
})
|
|
}
|
|
onClose={() => setShowCreate(false)}
|
|
isSubmitting={createRoute.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<RouteFormModal
|
|
route={editing}
|
|
onSubmit={(data) =>
|
|
updateRoute.mutate(
|
|
{ routeId: editing.id, data: data as UpdateRouteInput },
|
|
{ onSuccess: () => setEditing(null) },
|
|
)
|
|
}
|
|
onClose={() => setEditing(null)}
|
|
isSubmitting={updateRoute.isPending}
|
|
/>
|
|
)}
|
|
|
|
{deleting && (
|
|
<DeleteConfirmModal
|
|
title={`Delete ${deleting.name}?`}
|
|
message="This will permanently delete the route. Routes with existing encounters cannot be deleted."
|
|
onConfirm={() =>
|
|
deleteRoute.mutate(deleting.id, {
|
|
onSuccess: () => setDeleting(null),
|
|
})
|
|
}
|
|
onCancel={() => setDeleting(null)}
|
|
isDeleting={deleteRoute.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Boss Battles Section */}
|
|
<div className="mt-10">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-medium">Boss Battles ({bosses?.length ?? 0})</h3>
|
|
<button
|
|
onClick={() => setShowCreateBoss(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
>
|
|
Add Boss Battle
|
|
</button>
|
|
</div>
|
|
|
|
{!bosses || bosses.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
No boss battles yet. Add one to get started.
|
|
</div>
|
|
) : (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<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-16">
|
|
Order
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Name
|
|
</th>
|
|
<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">
|
|
Location
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
|
|
Lv Cap
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
|
Team
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-40">
|
|
Actions
|
|
</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">
|
|
<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>
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setEditingTeam(boss)}
|
|
className="text-green-600 hover:text-green-800 dark:text-green-400 text-sm"
|
|
>
|
|
Team
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingBoss(boss)}
|
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeletingBoss(boss)}
|
|
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Boss Battle Modals */}
|
|
{showCreateBoss && (
|
|
<BossBattleFormModal
|
|
routes={routes}
|
|
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
|
|
onSubmit={(data) =>
|
|
createBoss.mutate(data as CreateBossBattleInput, {
|
|
onSuccess: () => setShowCreateBoss(false),
|
|
})
|
|
}
|
|
onClose={() => setShowCreateBoss(false)}
|
|
isSubmitting={createBoss.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editingBoss && (
|
|
<BossBattleFormModal
|
|
boss={editingBoss}
|
|
routes={routes}
|
|
nextOrder={editingBoss.order}
|
|
onSubmit={(data) =>
|
|
updateBoss.mutate(
|
|
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
|
|
{ onSuccess: () => setEditingBoss(null) },
|
|
)
|
|
}
|
|
onClose={() => setEditingBoss(null)}
|
|
isSubmitting={updateBoss.isPending}
|
|
/>
|
|
)}
|
|
|
|
{deletingBoss && (
|
|
<DeleteConfirmModal
|
|
title={`Delete ${deletingBoss.name}?`}
|
|
message="This will permanently delete this boss battle and its pokemon team."
|
|
onConfirm={() =>
|
|
deleteBoss.mutate(deletingBoss.id, {
|
|
onSuccess: () => setDeletingBoss(null),
|
|
})
|
|
}
|
|
onCancel={() => setDeletingBoss(null)}
|
|
isDeleting={deleteBoss.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editingTeam && (
|
|
<BossTeamEditorWrapper
|
|
gameId={id}
|
|
boss={editingTeam}
|
|
onClose={() => setEditingTeam(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BossTeamEditorWrapper({
|
|
gameId,
|
|
boss,
|
|
onClose,
|
|
}: {
|
|
gameId: number
|
|
boss: BossBattle
|
|
onClose: () => void
|
|
}) {
|
|
const setBossTeam = useSetBossTeam(gameId, boss.id)
|
|
return (
|
|
<BossTeamEditor
|
|
boss={boss}
|
|
onSave={(team) =>
|
|
setBossTeam.mutate(team, { onSuccess: onClose })
|
|
}
|
|
onClose={onClose}
|
|
isSaving={setBossTeam.isPending}
|
|
/>
|
|
)
|
|
}
|