Files
nuzlocke-tracker/frontend/src/pages/admin/AdminRouteDetail.tsx

273 lines
9.3 KiB
TypeScript
Raw Normal View History

import { useMemo, useState } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal'
import { RouteFormModal } from '../../components/admin/RouteFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGame, useRoutePokemon } from '../../hooks/useGames'
import {
useAddRouteEncounter,
useUpdateRouteEncounter,
useRemoveRouteEncounter,
useCreateRoute,
useDeleteRoute,
} from '../../hooks/useAdmin'
import type {
Route,
RouteEncounterDetail,
CreateRouteEncounterInput,
UpdateRouteEncounterInput,
CreateRouteInput,
} from '../../types'
export function AdminRouteDetail() {
const { gameId, routeId } = useParams<{ gameId: string; routeId: string }>()
const gId = Number(gameId)
const rId = Number(routeId)
const navigate = useNavigate()
const { data: game } = useGame(gId)
const { data: encounters = [], isLoading } = useRoutePokemon(rId, gId)
const addEncounter = useAddRouteEncounter(rId)
const updateEncounter = useUpdateRouteEncounter(rId)
const removeEncounter = useRemoveRouteEncounter(rId)
const createRoute = useCreateRoute(gId)
const deleteRoute = useDeleteRoute(gId)
const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<RouteEncounterDetail | null>(null)
const [showCreateChild, setShowCreateChild] = useState(false)
const [deletingChild, setDeletingChild] = useState<Route | null>(null)
const sortedRoutes = useMemo(
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
[game?.routes]
)
const currentIndex = sortedRoutes.findIndex((r) => r.id === rId)
const route = currentIndex >= 0 ? sortedRoutes[currentIndex] : undefined
const prevRoute = currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
const nextRoute =
currentIndex >= 0 && currentIndex < sortedRoutes.length - 1
? sortedRoutes[currentIndex + 1]
: undefined
const childRoutes = useMemo(
() =>
(game?.routes ?? []).filter((r) => r.parentRouteId === rId).sort((a, b) => a.order - b.order),
[game?.routes, rId]
)
const nextChildOrder =
childRoutes.length > 0
? Math.max(...childRoutes.map((r) => r.order)) + 1
: (route?.order ?? 0) * 10 + 1
const hasConditions = encounters.some((e) => e.condition !== '')
const columns: Column<RouteEncounterDetail>[] = [
{
header: 'Pokemon',
accessor: (e) => (
<div className="flex items-center gap-2">
{e.pokemon.spriteUrl ? (
<img src={e.pokemon.spriteUrl} alt={e.pokemon.name} className="w-6 h-6" />
) : null}
<span>
#{e.pokemon.nationalDex} {e.pokemon.name}
</span>
</div>
),
},
{ header: 'Method', accessor: (e) => e.encounterMethod },
{ header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
...(hasConditions
? [
{
header: 'Condition',
accessor: (e: RouteEncounterDetail) => e.condition || '\u2014',
} as Column<RouteEncounterDetail>,
]
: []),
{
header: 'Levels',
accessor: (e) =>
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
},
]
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>
{' / '}
<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}
onChange={(e) => navigate(`/admin/games/${gId}/routes/${e.target.value}`)}
>
{sortedRoutes.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</nav>
<div className="flex justify-between items-center mb-4">
<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}
>
&larr; 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">
&larr; 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 &rarr;
</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 &rarr;
</span>
)}
</div>
</div>
<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}
onRowClick={(e) => setEditing(e)}
/>
{showCreate && (
<RouteEncounterFormModal
onSubmit={(data) =>
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
onSuccess: () => setShowCreate(false),
})
}
onClose={() => setShowCreate(false)}
isSubmitting={addEncounter.isPending}
/>
)}
{editing && (
<RouteEncounterFormModal
encounter={editing}
onSubmit={(data) =>
updateEncounter.mutate(
{
encounterId: editing.id,
data: data as UpdateRouteEncounterInput,
},
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}
isSubmitting={updateEncounter.isPending}
onDelete={() =>
removeEncounter.mutate(editing.id, {
onSuccess: () => setEditing(null),
})
}
isDeleting={removeEncounter.isPending}
/>
)}
{/* Sub-areas */}
<div className="mt-8">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold">Sub-areas ({childRoutes.length})</h3>
<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 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No sub-areas for this route.</p>
) : (
<div className="border rounded-md dark:border-gray-700 divide-y dark:divide-gray-700">
{childRoutes.map((child) => (
<div key={child.id} className="flex items-center justify-between px-4 py-2">
<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, {
onSuccess: () => setShowCreateChild(false),
})
}
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}
/>
)}
</div>
)
}