2026-02-08 20:02:59 +01:00
|
|
|
import { useMemo, useState } from 'react'
|
|
|
|
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
2026-02-05 18:36:19 +01:00
|
|
|
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
|
|
|
|
import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal'
|
2026-02-08 20:32:17 +01:00
|
|
|
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
|
|
|
|
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
2026-02-05 18:36:19 +01:00
|
|
|
import { useGame, useRoutePokemon } from '../../hooks/useGames'
|
|
|
|
|
import {
|
|
|
|
|
useAddRouteEncounter,
|
|
|
|
|
useUpdateRouteEncounter,
|
|
|
|
|
useRemoveRouteEncounter,
|
2026-02-08 20:32:17 +01:00
|
|
|
useCreateRoute,
|
|
|
|
|
useDeleteRoute,
|
2026-02-05 18:36:19 +01:00
|
|
|
} from '../../hooks/useAdmin'
|
|
|
|
|
import type {
|
2026-02-08 20:32:17 +01:00
|
|
|
Route,
|
2026-02-05 18:36:19 +01:00
|
|
|
RouteEncounterDetail,
|
|
|
|
|
CreateRouteEncounterInput,
|
|
|
|
|
UpdateRouteEncounterInput,
|
2026-02-08 20:32:17 +01:00
|
|
|
CreateRouteInput,
|
2026-02-05 18:36:19 +01:00
|
|
|
} from '../../types'
|
|
|
|
|
|
|
|
|
|
export function AdminRouteDetail() {
|
|
|
|
|
const { gameId, routeId } = useParams<{ gameId: string; routeId: string }>()
|
|
|
|
|
const gId = Number(gameId)
|
|
|
|
|
const rId = Number(routeId)
|
2026-02-08 20:02:59 +01:00
|
|
|
const navigate = useNavigate()
|
2026-02-05 18:36:19 +01:00
|
|
|
|
|
|
|
|
const { data: game } = useGame(gId)
|
2026-02-08 12:07:42 +01:00
|
|
|
const { data: encounters = [], isLoading } = useRoutePokemon(rId, gId)
|
2026-02-05 18:36:19 +01:00
|
|
|
|
|
|
|
|
const addEncounter = useAddRouteEncounter(rId)
|
|
|
|
|
const updateEncounter = useUpdateRouteEncounter(rId)
|
|
|
|
|
const removeEncounter = useRemoveRouteEncounter(rId)
|
2026-02-08 20:32:17 +01:00
|
|
|
const createRoute = useCreateRoute(gId)
|
|
|
|
|
const deleteRoute = useDeleteRoute(gId)
|
2026-02-05 18:36:19 +01:00
|
|
|
|
|
|
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
|
const [editing, setEditing] = useState<RouteEncounterDetail | null>(null)
|
2026-02-08 20:32:17 +01:00
|
|
|
const [showCreateChild, setShowCreateChild] = useState(false)
|
|
|
|
|
const [deletingChild, setDeletingChild] = useState<Route | null>(null)
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 20:02:59 +01:00
|
|
|
const sortedRoutes = useMemo(
|
|
|
|
|
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
|
2026-02-14 16:41:24 +01:00
|
|
|
[game?.routes]
|
2026-02-08 20:02:59 +01:00
|
|
|
)
|
|
|
|
|
const currentIndex = sortedRoutes.findIndex((r) => r.id === rId)
|
|
|
|
|
const route = currentIndex >= 0 ? sortedRoutes[currentIndex] : undefined
|
2026-02-14 16:41:24 +01:00
|
|
|
const prevRoute =
|
|
|
|
|
currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
|
2026-02-08 20:02:59 +01:00
|
|
|
const nextRoute =
|
|
|
|
|
currentIndex >= 0 && currentIndex < sortedRoutes.length - 1
|
|
|
|
|
? sortedRoutes[currentIndex + 1]
|
|
|
|
|
: undefined
|
2026-02-05 18:36:19 +01:00
|
|
|
|
2026-02-08 20:32:17 +01:00
|
|
|
const childRoutes = useMemo(
|
2026-02-14 16:41:24 +01:00
|
|
|
() =>
|
|
|
|
|
(game?.routes ?? [])
|
|
|
|
|
.filter((r) => r.parentRouteId === rId)
|
|
|
|
|
.sort((a, b) => a.order - b.order),
|
|
|
|
|
[game?.routes, rId]
|
2026-02-08 20:32:17 +01:00
|
|
|
)
|
|
|
|
|
|
2026-02-14 16:41:24 +01:00
|
|
|
const nextChildOrder =
|
|
|
|
|
childRoutes.length > 0
|
|
|
|
|
? Math.max(...childRoutes.map((r) => r.order)) + 1
|
|
|
|
|
: (route?.order ?? 0) * 10 + 1
|
2026-02-08 20:32:17 +01:00
|
|
|
|
2026-02-05 18:36:19 +01:00
|
|
|
const columns: Column<RouteEncounterDetail>[] = [
|
|
|
|
|
{
|
|
|
|
|
header: 'Pokemon',
|
|
|
|
|
accessor: (e) => (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{e.pokemon.spriteUrl ? (
|
2026-02-14 16:41:24 +01:00
|
|
|
<img
|
|
|
|
|
src={e.pokemon.spriteUrl}
|
|
|
|
|
alt={e.pokemon.name}
|
|
|
|
|
className="w-6 h-6"
|
|
|
|
|
/>
|
2026-02-05 18:36:19 +01:00
|
|
|
) : null}
|
|
|
|
|
<span>
|
|
|
|
|
#{e.pokemon.nationalDex} {e.pokemon.name}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{ header: 'Method', accessor: (e) => e.encounterMethod },
|
|
|
|
|
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
|
|
|
|
|
{
|
|
|
|
|
header: 'Levels',
|
|
|
|
|
accessor: (e) =>
|
2026-02-14 16:41:24 +01:00
|
|
|
e.minLevel === e.maxLevel
|
|
|
|
|
? `Lv ${e.minLevel}`
|
|
|
|
|
: `Lv ${e.minLevel}-${e.maxLevel}`,
|
2026-02-05 18:36:19 +01:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
|
|
|
|
<Link to="/admin/games" className="hover:underline">
|
|
|
|
|
Games
|
|
|
|
|
</Link>
|
|
|
|
|
{' / '}
|
|
|
|
|
<Link to={`/admin/games/${gId}`} className="hover:underline">
|
|
|
|
|
{game?.name ?? '...'}
|
|
|
|
|
</Link>
|
|
|
|
|
{' / '}
|
2026-02-08 20:02:59 +01:00
|
|
|
<select
|
|
|
|
|
className="text-gray-900 dark:text-gray-100 bg-transparent font-medium cursor-pointer hover:underline border-none p-0 text-sm"
|
|
|
|
|
value={rId}
|
2026-02-14 16:41:24 +01:00
|
|
|
onChange={(e) =>
|
|
|
|
|
navigate(`/admin/games/${gId}/routes/${e.target.value}`)
|
|
|
|
|
}
|
2026-02-08 20:02:59 +01:00
|
|
|
>
|
|
|
|
|
{sortedRoutes.map((r) => (
|
|
|
|
|
<option key={r.id} value={r.id}>
|
|
|
|
|
{r.name}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-02-05 18:36:19 +01:00
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-between items-center mb-4">
|
2026-02-08 20:02:59 +01:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<h2 className="text-xl font-semibold">
|
|
|
|
|
{route?.name ?? 'Route'} - Pokemon ({encounters.length})
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="flex items-center gap-1 ml-2">
|
|
|
|
|
{prevRoute ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/admin/games/${gId}/routes/${prevRoute.id}`}
|
|
|
|
|
className="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
title={prevRoute.name}
|
|
|
|
|
>
|
|
|
|
|
← Prev
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="px-2 py-1 text-sm rounded border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 cursor-not-allowed">
|
|
|
|
|
← Prev
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{nextRoute ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/admin/games/${gId}/routes/${nextRoute.id}`}
|
|
|
|
|
className="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
title={nextRoute.name}
|
|
|
|
|
>
|
|
|
|
|
Next →
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="px-2 py-1 text-sm rounded border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 cursor-not-allowed">
|
|
|
|
|
Next →
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-05 18:36:19 +01:00
|
|
|
<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 Pokemon
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AdminTable
|
|
|
|
|
columns={columns}
|
|
|
|
|
data={encounters}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
emptyMessage="No pokemon assigned to this route yet."
|
|
|
|
|
keyFn={(e) => e.id}
|
2026-02-08 13:44:38 +01:00
|
|
|
onRowClick={(e) => setEditing(e)}
|
2026-02-05 18:36:19 +01:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{showCreate && (
|
|
|
|
|
<RouteEncounterFormModal
|
|
|
|
|
onSubmit={(data) =>
|
2026-02-14 16:41:24 +01:00
|
|
|
addEncounter.mutate(
|
|
|
|
|
{ ...data, gameId: gId } as CreateRouteEncounterInput,
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => setShowCreate(false),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-02-05 18:36:19 +01:00
|
|
|
}
|
|
|
|
|
onClose={() => setShowCreate(false)}
|
|
|
|
|
isSubmitting={addEncounter.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editing && (
|
|
|
|
|
<RouteEncounterFormModal
|
|
|
|
|
encounter={editing}
|
|
|
|
|
onSubmit={(data) =>
|
|
|
|
|
updateEncounter.mutate(
|
2026-02-14 16:41:24 +01:00
|
|
|
{
|
|
|
|
|
encounterId: editing.id,
|
|
|
|
|
data: data as UpdateRouteEncounterInput,
|
|
|
|
|
},
|
|
|
|
|
{ onSuccess: () => setEditing(null) }
|
2026-02-05 18:36:19 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onClose={() => setEditing(null)}
|
|
|
|
|
isSubmitting={updateEncounter.isPending}
|
2026-02-08 13:44:38 +01:00
|
|
|
onDelete={() =>
|
|
|
|
|
removeEncounter.mutate(editing.id, {
|
|
|
|
|
onSuccess: () => setEditing(null),
|
2026-02-05 18:36:19 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
isDeleting={removeEncounter.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-08 20:32:17 +01:00
|
|
|
|
|
|
|
|
{/* Sub-areas */}
|
|
|
|
|
<div className="mt-8">
|
|
|
|
|
<div className="flex justify-between items-center mb-3">
|
2026-02-14 16:41:24 +01:00
|
|
|
<h3 className="text-lg font-semibold">
|
|
|
|
|
Sub-areas ({childRoutes.length})
|
|
|
|
|
</h3>
|
2026-02-08 20:32:17 +01:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowCreateChild(true)}
|
|
|
|
|
className="px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
|
|
|
|
>
|
|
|
|
|
Add Sub-area
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{childRoutes.length === 0 ? (
|
2026-02-14 16:41:24 +01:00
|
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
No sub-areas for this route.
|
|
|
|
|
</p>
|
2026-02-08 20:32:17 +01:00
|
|
|
) : (
|
|
|
|
|
<div className="border rounded-md dark:border-gray-700 divide-y dark:divide-gray-700">
|
|
|
|
|
{childRoutes.map((child) => (
|
2026-02-14 16:41:24 +01:00
|
|
|
<div
|
|
|
|
|
key={child.id}
|
|
|
|
|
className="flex items-center justify-between px-4 py-2"
|
|
|
|
|
>
|
2026-02-08 20:32:17 +01:00
|
|
|
<Link
|
|
|
|
|
to={`/admin/games/${gId}/routes/${child.id}`}
|
|
|
|
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
{child.name}
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setDeletingChild(child)}
|
|
|
|
|
className="text-sm text-red-600 dark:text-red-400 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showCreateChild && (
|
|
|
|
|
<RouteFormModal
|
|
|
|
|
nextOrder={nextChildOrder}
|
|
|
|
|
onSubmit={(data) =>
|
|
|
|
|
createRoute.mutate(
|
|
|
|
|
{ ...data, parentRouteId: rId } as CreateRouteInput,
|
2026-02-14 16:41:24 +01:00
|
|
|
{ onSuccess: () => setShowCreateChild(false) }
|
2026-02-08 20:32:17 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onClose={() => setShowCreateChild(false)}
|
|
|
|
|
isSubmitting={createRoute.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{deletingChild && (
|
|
|
|
|
<DeleteConfirmModal
|
|
|
|
|
title={`Delete "${deletingChild.name}"?`}
|
|
|
|
|
message="This will permanently delete this sub-area and all its encounters."
|
|
|
|
|
onConfirm={() =>
|
|
|
|
|
deleteRoute.mutate(deletingChild.id, {
|
|
|
|
|
onSuccess: () => setDeletingChild(null),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
onCancel={() => setDeletingChild(null)}
|
|
|
|
|
isDeleting={deleteRoute.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-05 18:36:19 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|