Add genlocke admin panel with CRUD endpoints and UI

Backend: PATCH/DELETE genlocke, POST/DELETE legs with order
re-numbering. Frontend: admin list page with status filter,
detail page with inline editing, legs table, and stats display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 10:51:47 +01:00
parent 08f6857451
commit a81a17c485
11 changed files with 685 additions and 12 deletions

View File

@@ -9,6 +9,8 @@ import {
AdminRouteDetail,
AdminEvolutions,
AdminRuns,
AdminGenlockes,
AdminGenlockeDetail,
} from './pages/admin'
function App() {
@@ -32,6 +34,8 @@ function App() {
<Route path="pokemon" element={<AdminPokemon />} />
<Route path="evolutions" element={<AdminEvolutions />} />
<Route path="runs" element={<AdminRuns />} />
<Route path="genlockes" element={<AdminGenlockes />} />
<Route path="genlockes/:genlockeId" element={<AdminGenlockeDetail />} />
</Route>
</Route>
</Routes>

View File

@@ -24,7 +24,10 @@ import type {
UpdateBossBattleInput,
BossPokemonInput,
BossReorderItem,
UpdateGenlockeInput,
AddGenlockeLegInput,
} from '../types'
import type { Genlocke } from '../types/game'
// Games
export const createGame = (data: CreateGameInput) =>
@@ -140,3 +143,16 @@ export const reorderBosses = (gameId: number, bosses: BossReorderItem[]) =>
export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) =>
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
// Genlockes
export const updateGenlocke = (id: number, data: UpdateGenlockeInput) =>
api.patch<Genlocke>(`/genlockes/${id}`, data)
export const deleteGenlocke = (id: number) =>
api.del(`/genlockes/${id}`)
export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) =>
api.post<Genlocke>(`/genlockes/${genlockeId}/legs`, data)
export const deleteGenlockeLeg = (genlockeId: number, legId: number) =>
api.del(`/genlockes/${genlockeId}/legs/${legId}`)

View File

@@ -5,6 +5,7 @@ const navItems = [
{ to: '/admin/pokemon', label: 'Pokemon' },
{ to: '/admin/evolutions', label: 'Evolutions' },
{ to: '/admin/runs', label: 'Runs' },
{ to: '/admin/genlockes', label: 'Genlockes' },
]
export function AdminLayout() {

View File

@@ -17,6 +17,8 @@ import type {
UpdateBossBattleInput,
BossPokemonInput,
BossReorderItem,
UpdateGenlockeInput,
AddGenlockeLegInput,
} from '../types'
// --- Queries ---
@@ -360,3 +362,56 @@ export function useSetBossTeam(gameId: number, bossId: number) {
onError: (err) => toast.error(`Failed to update boss team: ${err.message}`),
})
}
// --- Genlocke Mutations ---
export function useUpdateGenlocke() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateGenlockeInput }) =>
adminApi.updateGenlocke(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['genlockes'] })
toast.success('Genlocke updated')
},
onError: (err) => toast.error(`Failed to update genlocke: ${err.message}`),
})
}
export function useDeleteGenlocke() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => adminApi.deleteGenlocke(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['genlockes'] })
toast.success('Genlocke deleted')
},
onError: (err) => toast.error(`Failed to delete genlocke: ${err.message}`),
})
}
export function useAddGenlockeLeg(genlockeId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: AddGenlockeLegInput) => adminApi.addGenlockeLeg(genlockeId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['genlockes'] })
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
toast.success('Leg added')
},
onError: (err) => toast.error(`Failed to add leg: ${err.message}`),
})
}
export function useDeleteGenlockeLeg(genlockeId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (legId: number) => adminApi.deleteGenlockeLeg(genlockeId, legId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['genlockes'] })
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
toast.success('Leg removed')
},
onError: (err) => toast.error(`Failed to remove leg: ${err.message}`),
})
}

View File

@@ -0,0 +1,319 @@
import { useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useGenlocke } from '../../hooks/useGenlockes'
import { useGames } from '../../hooks/useGames'
import {
useUpdateGenlocke,
useDeleteGenlocke,
useAddGenlockeLeg,
useDeleteGenlockeLeg,
} from '../../hooks/useAdmin'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
export function AdminGenlockeDetail() {
const { genlockeId } = useParams<{ genlockeId: string }>()
const id = Number(genlockeId)
const navigate = useNavigate()
const { data: genlocke, isLoading } = useGenlocke(id)
const { data: games = [] } = useGames()
const updateGenlocke = useUpdateGenlocke()
const deleteGenlocke = useDeleteGenlocke()
const addLeg = useAddGenlockeLeg(id)
const deleteLeg = useDeleteGenlockeLeg(id)
const [name, setName] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [showDelete, setShowDelete] = useState(false)
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>
const editName = name ?? genlocke.name
const editStatus = status ?? 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 (Object.keys(data).length === 0) return
updateGenlocke.mutate(
{ id, data },
{
onSuccess: () => {
setName(null)
setStatus(null)
},
},
)
}
const handleAddLeg = () => {
if (!selectedGameId) return
addLeg.mutate(
{ gameId: Number(selectedGameId) },
{
onSuccess: () => {
setAddingLeg(false)
setSelectedGameId('')
},
},
)
}
return (
<div>
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
<Link to="/admin/genlockes" className="hover:underline">
Genlockes
</Link>
{' / '}
<span className="text-gray-900 dark:text-gray-100">{genlocke.name}</span>
</nav>
{/* Header */}
<div className="mb-6 space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Name
</label>
<input
type="text"
value={editName}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Status
</label>
<select
value={editStatus}
onChange={(e) => setStatus(e.target.value)}
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<button
onClick={handleSave}
disabled={!hasChanges || updateGenlocke.isPending}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{updateGenlocke.isPending ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => setShowDelete(true)}
className="px-4 py-2 text-sm font-medium rounded-md border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Delete
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Created {new Date(genlocke.createdAt).toLocaleDateString()}
</p>
</div>
{/* 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>
<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>
<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>
<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>
</div>
</div>
</div>
{/* 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>
<button
onClick={() => setAddingLeg(!addingLeg)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
>
Add Leg
</button>
</div>
{addingLeg && (
<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) : '')}
className="flex-1 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Select a game...</option>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button
onClick={handleAddLeg}
disabled={!selectedGameId || addLeg.isPending}
className="px-4 py-2 text-sm font-medium rounded-md bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
>
{addLeg.isPending ? 'Adding...' : 'Add'}
</button>
<button
onClick={() => {
setAddingLeg(false)
setSelectedGameId('')
}}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
)}
{genlocke.legs.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 legs 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">
Game
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Run
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</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">
Enc.
</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">
Deaths
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
Actions
</th>
</tr>
</thead>
<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.runId ? (
<Link
to={`/runs/${leg.runId}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Run #{leg.runId}
</Link>
) : (
<span className="text-gray-400">&mdash;</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.runStatus ? (
<span
className={
leg.runStatus === 'active'
? 'text-green-600 dark:text-green-400'
: leg.runStatus === 'completed'
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400'
}
>
{leg.runStatus}
</span>
) : (
<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 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'}
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
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* 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>
<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>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Encounters</span>
<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>
</div>
<div>
<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)}%`
: '\u2014'}
</p>
</div>
</div>
</div>
{showDelete && (
<DeleteConfirmModal
title={`Delete "${genlocke.name}"?`}
message="This will permanently delete the genlocke. Linked runs will be preserved but detached."
onConfirm={() =>
deleteGenlocke.mutate(id, {
onSuccess: () => navigate('/admin/genlockes'),
})
}
onCancel={() => setShowDelete(false)}
isDeleting={deleteGenlocke.isPending}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGenlockes } from '../../hooks/useGenlockes'
import { useDeleteGenlocke } from '../../hooks/useAdmin'
import type { GenlockeListItem } from '../../types/game'
export function AdminGenlockes() {
const { data: genlockes = [], isLoading } = useGenlockes()
const deleteGenlocke = useDeleteGenlocke()
const navigate = useNavigate()
const [deleting, setDeleting] = useState<GenlockeListItem | null>(null)
const [statusFilter, setStatusFilter] = useState('')
const filtered = useMemo(() => {
if (!statusFilter) return genlockes
return genlockes.filter((g) => g.status === statusFilter)
}, [genlockes, statusFilter])
const columns: Column<GenlockeListItem>[] = [
{ header: 'Name', accessor: (g) => g.name, sortKey: (g) => g.name },
{
header: 'Status',
accessor: (g) => (
<span
className={
g.status === 'active'
? 'text-green-600 dark:text-green-400'
: g.status === 'completed'
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400'
}
>
{g.status}
</span>
),
sortKey: (g) => g.status,
},
{
header: 'Legs',
accessor: (g) => `${g.completedLegs} / ${g.totalLegs}`,
sortKey: (g) => g.totalLegs,
},
{
header: 'Created',
accessor: (g) => new Date(g.createdAt).toLocaleDateString(),
sortKey: (g) => g.createdAt,
},
]
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Genlockes</h2>
</div>
<div className="mb-4 flex items-center gap-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
</button>
)}
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{filtered.length} genlockes
</span>
</div>
<AdminTable
columns={columns}
data={filtered}
isLoading={isLoading}
emptyMessage="No genlockes found."
keyFn={(g) => g.id}
onRowClick={(g) => navigate(`/admin/genlockes/${g.id}`)}
/>
{deleting && (
<DeleteConfirmModal
title={`Delete "${deleting.name}"?`}
message="This will permanently delete the genlocke. Linked runs will be preserved but detached."
onConfirm={() =>
deleteGenlocke.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => setDeleting(null)}
isDeleting={deleteGenlocke.isPending}
/>
)}
</div>
)
}

View File

@@ -4,3 +4,5 @@ export { AdminPokemon } from './AdminPokemon'
export { AdminRouteDetail } from './AdminRouteDetail'
export { AdminEvolutions } from './AdminEvolutions'
export { AdminRuns } from './AdminRuns'
export { AdminGenlockes } from './AdminGenlockes'
export { AdminGenlockeDetail } from './AdminGenlockeDetail'

View File

@@ -178,3 +178,13 @@ export interface BossPokemonInput {
order: number
conditionLabel?: string | null
}
// Genlocke admin
export interface UpdateGenlockeInput {
name?: string
status?: string
}
export interface AddGenlockeLegInput {
gameId: number
}