Add pre-commit hooks for linting and formatting
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / frontend-lint (push) Successful in 33s

Set up pre-commit framework with ruff (backend) and ESLint/Prettier/tsc
(frontend) hooks to catch issues locally before CI. Auto-format all
frontend files with Prettier to comply with the new check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:41:24 +01:00
parent b05a75f7f2
commit 2963f16aa4
67 changed files with 1905 additions and 792 deletions

View File

@@ -11,7 +11,11 @@ import {
} from '../../hooks/useAdmin'
import { exportEvolutions } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
import type {
EvolutionAdmin,
CreateEvolutionInput,
UpdateEvolutionInput,
} from '../../types'
const PAGE_SIZE = 50
@@ -28,7 +32,12 @@ export function AdminEvolutions() {
const [triggerFilter, setTriggerFilter] = useState('')
const [page, setPage] = useState(0)
const offset = page * PAGE_SIZE
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset, triggerFilter || undefined)
const { data, isLoading } = useEvolutionList(
search || undefined,
PAGE_SIZE,
offset,
triggerFilter || undefined
)
const evolutions = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
@@ -120,12 +129,18 @@ export function AdminEvolutions() {
>
<option value="">All triggers</option>
{EVOLUTION_TRIGGERS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
{(search || triggerFilter) && (
<button
onClick={() => { setSearch(''); setTriggerFilter(''); setPage(0) }}
onClick={() => {
setSearch('')
setTriggerFilter('')
setPage(0)
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -148,7 +163,8 @@ export function AdminEvolutions() {
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
{total}
</div>
<div className="flex items-center gap-2">
<button
@@ -213,7 +229,7 @@ export function AdminEvolutions() {
onSubmit={(data) =>
updateEvolution.mutate(
{ id: editing.id, data: data as UpdateEvolutionInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -38,8 +38,17 @@ import {
} from '../../hooks/useAdmin'
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, RouteWithChildren, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
import type {
Route as GameRoute,
RouteWithChildren,
CreateRouteInput,
UpdateRouteInput,
BossBattle,
} from '../../types'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
/**
* Organize flat routes into hierarchical structure.
@@ -76,8 +85,14 @@ function SortableRouteGroup({
gameId: number
onClick: (r: GameRoute) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: group.id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: group.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -112,7 +127,9 @@ function SortableRouteGroup({
</svg>
</button>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{group.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">
{group.order}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{group.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
@@ -138,7 +155,9 @@ function SortableRouteGroup({
{child.order}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
<span className="text-gray-300 dark:text-gray-600 mr-1.5">{'\u2514'}</span>
<span className="text-gray-300 dark:text-gray-600 mr-1.5">
{'\u2514'}
</span>
{child.name}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
@@ -172,8 +191,14 @@ function SortableBossRow({
onPositionChange: (bossId: number, afterRouteId: number | null) => void
onClick: (b: BossBattle) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: boss.id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: boss.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -208,22 +233,29 @@ function SortableBossRow({
<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}
{boss.gameId != null && (() => {
const g = games.find((g) => g.id === boss.gameId)
return g ? (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
{g.name}
</span>
) : null
})()}
{boss.gameId != null &&
(() => {
const g = games.find((g) => g.id === boss.gameId)
return g ? (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
{g.name}
</span>
) : null
})()}
</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.specialtyType ? <TypeBadge type={boss.specialtyType} /> : '\u2014'}
{boss.specialtyType ? (
<TypeBadge type={boss.specialtyType} />
) : (
'\u2014'
)}
</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.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">
<select
@@ -244,7 +276,9 @@ function SortableBossRow({
</select>
</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">
{boss.pokemon.length}
</td>
</tr>
)
}
@@ -278,16 +312,18 @@ export function AdminGameDetail() {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
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>
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 routeGroups = organizeRoutes(routes)
const versionGroupGames = (allGames ?? []).filter(
(g) => g.versionGroupId === game.versionGroupId,
(g) => g.versionGroupId === game.versionGroupId
)
const handleDragEnd = (event: DragEndEvent) => {
@@ -347,7 +383,8 @@ export function AdminGameDetail() {
<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)} &middot; Gen {game.generation}
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} &middot;
Gen {game.generation}
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
</p>
</div>
@@ -463,7 +500,11 @@ export function AdminGameDetail() {
{showCreate && (
<RouteFormModal
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
nextOrder={
routes.length > 0
? Math.max(...routes.map((r) => r.order)) + 1
: 1
}
onSubmit={(data) =>
createRoute.mutate(data as CreateRouteInput, {
onSuccess: () => setShowCreate(false),
@@ -480,7 +521,7 @@ export function AdminGameDetail() {
onSubmit={(data) =>
updateRoute.mutate(
{ routeId: editing.id, data: data as UpdateRouteInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}
@@ -614,7 +655,9 @@ export function AdminGameDetail() {
<BossBattleFormModal
routes={routes}
games={versionGroupGames}
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
nextOrder={
bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1
}
onSubmit={(data) =>
createBoss.mutate(data as CreateBossBattleInput, {
onSuccess: () => setShowCreateBoss(false),
@@ -634,7 +677,7 @@ export function AdminGameDetail() {
onSubmit={(data) =>
updateBoss.mutate(
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
{ onSuccess: () => setEditingBoss(null) },
{ onSuccess: () => setEditingBoss(null) }
)
}
onClose={() => setEditingBoss(null)}
@@ -676,9 +719,7 @@ function BossTeamEditorWrapper({
return (
<BossTeamEditor
boss={boss}
onSave={(team) =>
setBossTeam.mutate(team, { onSuccess: onClose })
}
onSave={(team) => setBossTeam.mutate(team, { onSuccess: onClose })}
onClose={onClose}
isSaving={setBossTeam.isPending}
/>

View File

@@ -2,7 +2,11 @@ import { useState, useMemo } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { GameFormModal } from '../../components/admin/GameFormModal'
import { useGames } from '../../hooks/useGames'
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
import {
useCreateGame,
useUpdateGame,
useDeleteGame,
} from '../../hooks/useAdmin'
import { exportGames } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
@@ -20,17 +24,18 @@ export function AdminGames() {
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games],
[games]
)
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
[games],
[games]
)
const filteredGames = useMemo(() => {
let result = games
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
if (genFilter) result = result.filter((g) => g.generation === Number(genFilter))
if (genFilter)
result = result.filter((g) => g.generation === Number(genFilter))
return result
}, [games, regionFilter, genFilter])
@@ -38,8 +43,16 @@ export function AdminGames() {
{ header: 'Name', accessor: (g) => g.name },
{ header: 'Slug', accessor: (g) => g.slug },
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 },
{
header: 'Gen',
accessor: (g) => g.generation,
sortKey: (g) => g.generation,
},
{
header: 'Year',
accessor: (g) => g.releaseYear ?? '-',
sortKey: (g) => g.releaseYear ?? 0,
},
]
return (
@@ -73,7 +86,9 @@ export function AdminGames() {
>
<option value="">All regions</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
<option key={r} value={r}>
{r}
</option>
))}
</select>
<select
@@ -83,12 +98,17 @@ export function AdminGames() {
>
<option value="">All generations</option>
{generations.map((g) => (
<option key={g} value={g}>Gen {g}</option>
<option key={g} value={g}>
Gen {g}
</option>
))}
</select>
{(regionFilter || genFilter) && (
<button
onClick={() => { setRegionFilter(''); setGenFilter('') }}
onClick={() => {
setRegionFilter('')
setGenFilter('')
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -126,7 +146,7 @@ export function AdminGames() {
onSubmit={(data) =>
updateGame.mutate(
{ id: editing.id, data: data as UpdateGameInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -28,13 +28,18 @@ export function AdminGenlockeDetail() {
const [addingLeg, setAddingLeg] = useState(false)
const [selectedGameId, setSelectedGameId] = useState<number | ''>('')
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!genlocke) return <div className="py-8 text-center text-gray-500">Genlocke not found</div>
if (isLoading)
return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!genlocke)
return (
<div className="py-8 text-center text-gray-500">Genlocke not found</div>
)
const editName = name ?? genlocke.name
const editStatus = status ?? genlocke.status
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
const hasChanges =
editName !== genlocke.name || editStatus !== genlocke.status
const handleSave = () => {
const data: Record<string, string> = {}
@@ -48,7 +53,7 @@ export function AdminGenlockeDetail() {
setName(null)
setStatus(null)
},
},
}
)
}
@@ -61,7 +66,7 @@ export function AdminGenlockeDetail() {
setAddingLeg(false)
setSelectedGameId('')
},
},
}
)
}
@@ -72,7 +77,9 @@ export function AdminGenlockeDetail() {
Genlockes
</Link>
{' / '}
<span className="text-gray-900 dark:text-gray-100">{genlocke.name}</span>
<span className="text-gray-900 dark:text-gray-100">
{genlocke.name}
</span>
</nav>
{/* Header */}
@@ -124,16 +131,22 @@ export function AdminGenlockeDetail() {
{/* Rules (read-only) */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Rules</h3>
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Rules
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Genlocke rules:</span>
<span className="text-gray-500 dark:text-gray-400">
Genlocke rules:
</span>
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
{JSON.stringify(genlocke.genlockeRules, null, 2)}
</pre>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Nuzlocke rules:</span>
<span className="text-gray-500 dark:text-gray-400">
Nuzlocke rules:
</span>
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
</pre>
@@ -144,7 +157,9 @@ export function AdminGenlockeDetail() {
{/* Legs */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Legs ({genlocke.legs.length})</h3>
<h3 className="text-lg font-semibold">
Legs ({genlocke.legs.length})
</h3>
<button
onClick={() => setAddingLeg(!addingLeg)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -157,7 +172,9 @@ export function AdminGenlockeDetail() {
<div className="mb-4 flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<select
value={selectedGameId}
onChange={(e) => setSelectedGameId(e.target.value ? Number(e.target.value) : '')}
onChange={(e) =>
setSelectedGameId(e.target.value ? Number(e.target.value) : '')
}
className="flex-1 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Select a game...</option>
@@ -222,8 +239,12 @@ export function AdminGenlockeDetail() {
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{genlocke.legs.map((leg) => (
<tr key={leg.id}>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.legOrder}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.game.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.legOrder}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.game.name}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.runId ? (
<Link
@@ -253,13 +274,21 @@ export function AdminGenlockeDetail() {
<span className="text-gray-400">&mdash;</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.encounterCount}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.deathCount}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.encounterCount}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.deathCount}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
<button
onClick={() => deleteLeg.mutate(leg.id)}
disabled={leg.runId !== null || deleteLeg.isPending}
title={leg.runId !== null ? 'Cannot remove a leg with a linked run' : 'Remove leg'}
title={
leg.runId !== null
? 'Cannot remove a leg with a linked run'
: 'Remove leg'
}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
>
Remove
@@ -276,22 +305,32 @@ export function AdminGenlockeDetail() {
{/* Stats */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Stats</h3>
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Stats
</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Legs</span>
<p className="text-lg font-semibold">{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}</p>
<p className="text-lg font-semibold">
{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Encounters</span>
<p className="text-lg font-semibold">{genlocke.stats.totalEncounters}</p>
<p className="text-lg font-semibold">
{genlocke.stats.totalEncounters}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Deaths</span>
<p className="text-lg font-semibold">{genlocke.stats.totalDeaths}</p>
<p className="text-lg font-semibold">
{genlocke.stats.totalDeaths}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Survival Rate</span>
<span className="text-gray-500 dark:text-gray-400">
Survival Rate
</span>
<p className="text-lg font-semibold">
{genlocke.stats.totalEncounters > 0
? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%`

View File

@@ -11,14 +11,33 @@ import {
} from '../../hooks/useAdmin'
import { exportPokemon } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
import type {
Pokemon,
CreatePokemonInput,
UpdatePokemonInput,
} from '../../types'
const PAGE_SIZE = 50
const POKEMON_TYPES = [
'bug', 'dark', 'dragon', 'electric', 'fairy', 'fighting', 'fire', 'flying',
'ghost', 'grass', 'ground', 'ice', 'normal', 'poison', 'psychic', 'rock',
'steel', 'water',
'bug',
'dark',
'dragon',
'electric',
'fairy',
'fighting',
'fire',
'flying',
'ghost',
'grass',
'ground',
'ice',
'normal',
'poison',
'psychic',
'rock',
'steel',
'water',
]
export function AdminPokemon() {
@@ -26,7 +45,12 @@ export function AdminPokemon() {
const [typeFilter, setTypeFilter] = useState('')
const [page, setPage] = useState(0)
const offset = page * PAGE_SIZE
const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset, typeFilter || undefined)
const { data, isLoading } = usePokemonList(
search || undefined,
PAGE_SIZE,
offset,
typeFilter || undefined
)
const pokemon = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
@@ -105,12 +129,18 @@ export function AdminPokemon() {
>
<option value="">All types</option>
{POKEMON_TYPES.map((t) => (
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
{(search || typeFilter) && (
<button
onClick={() => { setSearch(''); setTypeFilter(''); setPage(0) }}
onClick={() => {
setSearch('')
setTypeFilter('')
setPage(0)
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -134,7 +164,8 @@ export function AdminPokemon() {
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
{total}
</div>
<div className="flex items-center gap-2">
<button
@@ -188,7 +219,11 @@ export function AdminPokemon() {
<BulkImportModal
title="Bulk Import Pokemon"
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
onSubmit={(items) => bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])}
onSubmit={(items) =>
bulkImport.mutateAsync(
items as Parameters<typeof bulkImport.mutateAsync>[0]
)
}
onClose={() => setShowBulkImport(false)}
/>
)}
@@ -199,7 +234,7 @@ export function AdminPokemon() {
onSubmit={(data) =>
updatePokemon.mutate(
{ id: editing.id, data: data as UpdatePokemonInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -42,24 +42,29 @@ export function AdminRouteDetail() {
const sortedRoutes = useMemo(
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
[game?.routes],
[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 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],
() =>
(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 nextChildOrder =
childRoutes.length > 0
? Math.max(...childRoutes.map((r) => r.order)) + 1
: (route?.order ?? 0) * 10 + 1
const columns: Column<RouteEncounterDetail>[] = [
{
@@ -67,7 +72,11 @@ export function AdminRouteDetail() {
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" />
<img
src={e.pokemon.spriteUrl}
alt={e.pokemon.name}
className="w-6 h-6"
/>
) : null}
<span>
#{e.pokemon.nationalDex} {e.pokemon.name}
@@ -80,7 +89,9 @@ export function AdminRouteDetail() {
{
header: 'Levels',
accessor: (e) =>
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
e.minLevel === e.maxLevel
? `Lv ${e.minLevel}`
: `Lv ${e.minLevel}-${e.maxLevel}`,
},
]
@@ -98,7 +109,9 @@ export function AdminRouteDetail() {
<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}`)}
onChange={(e) =>
navigate(`/admin/games/${gId}/routes/${e.target.value}`)
}
>
{sortedRoutes.map((r) => (
<option key={r.id} value={r.id}>
@@ -162,9 +175,12 @@ export function AdminRouteDetail() {
{showCreate && (
<RouteEncounterFormModal
onSubmit={(data) =>
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
onSuccess: () => setShowCreate(false),
})
addEncounter.mutate(
{ ...data, gameId: gId } as CreateRouteEncounterInput,
{
onSuccess: () => setShowCreate(false),
}
)
}
onClose={() => setShowCreate(false)}
isSubmitting={addEncounter.isPending}
@@ -176,8 +192,11 @@ export function AdminRouteDetail() {
encounter={editing}
onSubmit={(data) =>
updateEncounter.mutate(
{ encounterId: editing.id, data: data as UpdateRouteEncounterInput },
{ onSuccess: () => setEditing(null) },
{
encounterId: editing.id,
data: data as UpdateRouteEncounterInput,
},
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}
@@ -194,7 +213,9 @@ export function AdminRouteDetail() {
{/* 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>
<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"
@@ -203,11 +224,16 @@ export function AdminRouteDetail() {
</button>
</div>
{childRoutes.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No sub-areas for this route.</p>
<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">
<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"
@@ -232,7 +258,7 @@ export function AdminRouteDetail() {
onSubmit={(data) =>
createRoute.mutate(
{ ...data, parentRouteId: rId } as CreateRouteInput,
{ onSuccess: () => setShowCreateChild(false) },
{ onSuccess: () => setShowCreateChild(false) }
)
}
onClose={() => setShowCreateChild(false)}

View File

@@ -16,19 +16,28 @@ export function AdminRuns() {
const gameMap = useMemo(
() => new Map(games.map((g) => [g.id, g.name])),
[games],
[games]
)
const filteredRuns = useMemo(() => {
let result = runs
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
if (gameFilter)
result = result.filter((r) => r.gameId === Number(gameFilter))
return result
}, [runs, statusFilter, gameFilter])
const runGames = useMemo(
() => [...new Map(runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])).entries()].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap],
() =>
[
...new Map(
runs.map((r) => [
r.gameId,
gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
])
).entries(),
].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap]
)
const columns: Column<NuzlockeRun>[] = [
@@ -86,12 +95,17 @@ export function AdminRuns() {
>
<option value="">All games</option>
{runGames.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || gameFilter) && (
<button
onClick={() => { setStatusFilter(''); setGameFilter('') }}
onClick={() => {
setStatusFilter('')
setGameFilter('')
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -120,7 +134,10 @@ export function AdminRuns() {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => { setDeleting(null); deleteRun.reset() }}
onCancel={() => {
setDeleting(null)
deleteRun.reset()
}}
isDeleting={deleteRun.isPending}
error={deleteRun.error?.message ?? null}
/>