Align repo config with global development standards
Some checks failed
CI / backend-lint (push) Failing after 1m4s
CI / actions-lint (push) Failing after 6s
CI / frontend-lint (push) Successful in 59s

- Add missing tsconfig strictness flags (noUncheckedIndexedAccess,
  exactOptionalPropertyTypes, noImplicitOverride,
  noPropertyAccessFromIndexSignature) and fix all resulting type errors
- Replace ESLint/Prettier with oxlint 1.48.0 and oxfmt 0.33.0
- Pin all frontend and backend dependencies to exact versions
- Pin GitHub Actions to SHA hashes with persist-credentials: false
- Fix CI Python version mismatch (3.12 -> 3.14) and ruff target-version
- Add vitest 4.0.18 with jsdom environment for frontend testing
- Add ty 0.0.17 for Python type checking (non-blocking in CI)
- Add actionlint and zizmor CI job for workflow linting and security audit
- Add Dependabot config for npm, pip, and github-actions
- Update CLAUDE.md and pre-commit hooks to reflect new tooling
- Ignore Claude Code sandbox artifacts in gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 20:39:41 +01:00
parent e4814250db
commit 3a64661760
91 changed files with 2073 additions and 3215 deletions

View File

@@ -11,11 +11,7 @@ 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
@@ -67,9 +63,7 @@ export function AdminEvolutions() {
header: 'To',
accessor: (e) => (
<div className="flex items-center gap-2">
{e.toPokemon.spriteUrl && (
<img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />
)}
{e.toPokemon.spriteUrl && <img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />}
<span>{e.toPokemon.name}</span>
</div>
),
@@ -163,8 +157,7 @@ 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

View File

@@ -45,10 +45,7 @@ import type {
UpdateRouteInput,
BossBattle,
} from '../../types'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
/**
* Organize flat routes into hierarchical structure.
@@ -85,14 +82,9 @@ 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),
@@ -127,9 +119,7 @@ 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'}
@@ -155,9 +145,7 @@ 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">
@@ -191,14 +179,9 @@ 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),
@@ -247,15 +230,9 @@ function SortableBossRow({
{boss.bossType.replace('_', ' ')}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.specialtyType ? (
<TypeBadge type={boss.specialtyType} />
) : (
'\u2014'
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.section ?? '\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.location}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<select
@@ -276,9 +253,7 @@ 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>
)
}
@@ -315,16 +290,12 @@ export function AdminGameDetail() {
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
)
const versionGroupGames = (allGames ?? []).filter((g) => g.versionGroupId === game.versionGroupId)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
@@ -336,7 +307,7 @@ export function AdminGameDetail() {
const reordered = [...routeGroups]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
reordered.splice(newIndex, 0, moved!)
// Flatten groups back to individual routes with sequential order numbers
let order = 1
@@ -361,7 +332,7 @@ export function AdminGameDetail() {
const reordered = [...bosses]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
reordered.splice(newIndex, 0, moved!)
const newOrders = reordered.map((b, i) => ({
id: b.id,
@@ -383,8 +354,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>
@@ -500,11 +471,7 @@ 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),
@@ -655,9 +622,7 @@ 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),

View File

@@ -2,11 +2,7 @@ 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'
@@ -22,10 +18,7 @@ export function AdminGames() {
const [regionFilter, setRegionFilter] = useState('')
const [genFilter, setGenFilter] = useState('')
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games]
)
const regions = useMemo(() => [...new Set(games.map((g) => g.region))].sort(), [games])
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
[games]
@@ -34,8 +27,7 @@ export function AdminGames() {
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])

View File

@@ -28,23 +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> = {}
if (editName !== genlocke.name) data.name = editName
if (editStatus !== genlocke.status) data.status = editStatus
if (editName !== genlocke.name) data['name'] = editName
if (editStatus !== genlocke.status) data['status'] = editStatus
if (Object.keys(data).length === 0) return
updateGenlocke.mutate(
{ id, data },
@@ -77,9 +72,7 @@ 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 */}
@@ -131,22 +124,16 @@ 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>
@@ -157,9 +144,7 @@ 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"
@@ -172,9 +157,7 @@ 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>
@@ -239,12 +222,8 @@ 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
@@ -274,12 +253,8 @@ 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)}
@@ -305,9 +280,7 @@ 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>
@@ -317,20 +290,14 @@ export function AdminGenlockeDetail() {
</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,11 +11,7 @@ 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
@@ -164,8 +160,7 @@ 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
@@ -220,9 +215,7 @@ export function AdminPokemon() {
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]
)
bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])
}
onClose={() => setShowBulkImport(false)}
/>

View File

@@ -46,8 +46,7 @@ export function AdminRouteDetail() {
)
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]
@@ -55,9 +54,7 @@ export function AdminRouteDetail() {
const childRoutes = useMemo(
() =>
(game?.routes ?? [])
.filter((r) => r.parentRouteId === rId)
.sort((a, b) => a.order - b.order),
(game?.routes ?? []).filter((r) => r.parentRouteId === rId).sort((a, b) => a.order - b.order),
[game?.routes, rId]
)
@@ -72,11 +69,7 @@ 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}
@@ -89,9 +82,7 @@ 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}`,
},
]
@@ -109,9 +100,7 @@ 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}>
@@ -175,12 +164,9 @@ 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}
@@ -213,9 +199,7 @@ 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"
@@ -224,16 +208,11 @@ 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"
@@ -256,10 +235,9 @@ export function AdminRouteDetail() {
<RouteFormModal
nextOrder={nextChildOrder}
onSubmit={(data) =>
createRoute.mutate(
{ ...data, parentRouteId: rId } as CreateRouteInput,
{ onSuccess: () => setShowCreateChild(false) }
)
createRoute.mutate({ ...data, parentRouteId: rId } as CreateRouteInput, {
onSuccess: () => setShowCreateChild(false),
})
}
onClose={() => setShowCreateChild(false)}
isSubmitting={createRoute.isPending}

View File

@@ -14,16 +14,12 @@ export function AdminRuns() {
const [statusFilter, setStatusFilter] = useState('')
const [gameFilter, setGameFilter] = useState('')
const gameMap = useMemo(
() => new Map(games.map((g) => [g.id, g.name])),
[games]
)
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [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])
@@ -31,10 +27,7 @@ export function AdminRuns() {
() =>
[
...new Map(
runs.map((r) => [
r.gameId,
gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
])
runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])
).entries(),
].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap]