Replace incorrect perceived-brightness formula in Stats progress bars with proper WCAG relative luminance calculation, and convert type bar colors to hex values for reliable contrast detection. Add light: variant classes to status badges, yellow/purple text, and admin nav links across 17 files. Darken light-mode status-active token and text-tertiary/muted tokens. Add aria-labels to admin filter selects and flex-wrap for mobile overflow on AdminEvolutions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
240 lines
8.1 KiB
TypeScript
240 lines
8.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
|
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
|
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
|
import {
|
|
useEvolutionList,
|
|
useCreateEvolution,
|
|
useUpdateEvolution,
|
|
useDeleteEvolution,
|
|
useBulkImportEvolutions,
|
|
} from '../../hooks/useAdmin'
|
|
import { exportEvolutions } from '../../api/admin'
|
|
import { downloadJson } from '../../utils/download'
|
|
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
const EVOLUTION_TRIGGERS = [
|
|
{ value: 'level-up', label: 'Level Up' },
|
|
{ value: 'trade', label: 'Trade' },
|
|
{ value: 'use-item', label: 'Use Item' },
|
|
{ value: 'shed', label: 'Shed' },
|
|
{ value: 'other', label: 'Other' },
|
|
]
|
|
|
|
export function AdminEvolutions() {
|
|
const [search, setSearch] = useState('')
|
|
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 evolutions = data?.items ?? []
|
|
const total = data?.total ?? 0
|
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
|
|
|
const createEvolution = useCreateEvolution()
|
|
const updateEvolution = useUpdateEvolution()
|
|
const deleteEvolution = useDeleteEvolution()
|
|
const bulkImport = useBulkImportEvolutions()
|
|
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [showBulkImport, setShowBulkImport] = useState(false)
|
|
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
|
|
|
const columns: Column<EvolutionAdmin>[] = [
|
|
{
|
|
header: 'From',
|
|
accessor: (e) => (
|
|
<div className="flex items-center gap-2">
|
|
{e.fromPokemon.spriteUrl && (
|
|
<img src={e.fromPokemon.spriteUrl} alt="" className="w-6 h-6" />
|
|
)}
|
|
<span>{e.fromPokemon.name}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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" />}
|
|
<span>{e.toPokemon.name}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{ header: 'Trigger', accessor: (e) => e.trigger },
|
|
{ header: 'Level', accessor: (e) => e.minLevel ?? '-' },
|
|
{ header: 'Item', accessor: (e) => e.item ?? '-' },
|
|
]
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap justify-between items-center gap-2 mb-4">
|
|
<h2 className="text-xl font-semibold">Evolutions</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
const data = await exportEvolutions()
|
|
downloadJson(data, 'evolutions.json')
|
|
}}
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default text-text-secondary hover:bg-surface-2"
|
|
>
|
|
Export
|
|
</button>
|
|
<button
|
|
onClick={() => setShowBulkImport(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default text-text-secondary hover:bg-surface-2"
|
|
>
|
|
Bulk Import
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500"
|
|
>
|
|
Add Evolution
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap items-center gap-4">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value)
|
|
setPage(0)
|
|
}}
|
|
placeholder="Search by pokemon name, trigger, or item..."
|
|
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
|
/>
|
|
<select
|
|
aria-label="Filter by trigger"
|
|
value={triggerFilter}
|
|
onChange={(e) => {
|
|
setTriggerFilter(e.target.value)
|
|
setPage(0)
|
|
}}
|
|
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
|
>
|
|
<option value="">All triggers</option>
|
|
{EVOLUTION_TRIGGERS.map((t) => (
|
|
<option key={t.value} value={t.value}>
|
|
{t.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{(search || triggerFilter) && (
|
|
<button
|
|
onClick={() => {
|
|
setSearch('')
|
|
setTriggerFilter('')
|
|
setPage(0)
|
|
}}
|
|
className="text-sm text-text-tertiary hover:text-text-primary"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
<span className="text-sm text-text-tertiary whitespace-nowrap">{total} evolutions</span>
|
|
</div>
|
|
|
|
<AdminTable
|
|
columns={columns}
|
|
data={evolutions}
|
|
isLoading={isLoading}
|
|
emptyMessage="No evolutions found."
|
|
keyFn={(e) => e.id}
|
|
onRowClick={(e) => setEditing(e)}
|
|
/>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<div className="text-sm text-text-tertiary">
|
|
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPage(0)}
|
|
disabled={page === 0}
|
|
className="px-3 py-1 text-sm rounded border border-border-default disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-2"
|
|
>
|
|
First
|
|
</button>
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
className="px-3 py-1 text-sm rounded border border-border-default disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-2"
|
|
>
|
|
Prev
|
|
</button>
|
|
<span className="text-sm text-text-secondary px-2">
|
|
Page {page + 1} of {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
disabled={page >= totalPages - 1}
|
|
className="px-3 py-1 text-sm rounded border border-border-default disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-2"
|
|
>
|
|
Next
|
|
</button>
|
|
<button
|
|
onClick={() => setPage(totalPages - 1)}
|
|
disabled={page >= totalPages - 1}
|
|
className="px-3 py-1 text-sm rounded border border-border-default disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-2"
|
|
>
|
|
Last
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showBulkImport && (
|
|
<BulkImportModal
|
|
title="Bulk Import Evolutions"
|
|
example={`[\n { "from_pokeapi_id": 1, "to_pokeapi_id": 2, "trigger": "level-up", "min_level": 16 }\n]`}
|
|
onSubmit={(items) => bulkImport.mutateAsync(items)}
|
|
onClose={() => setShowBulkImport(false)}
|
|
/>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<EvolutionFormModal
|
|
onSubmit={(data) =>
|
|
createEvolution.mutate(data as CreateEvolutionInput, {
|
|
onSuccess: () => setShowCreate(false),
|
|
})
|
|
}
|
|
onClose={() => setShowCreate(false)}
|
|
isSubmitting={createEvolution.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<EvolutionFormModal
|
|
evolution={editing}
|
|
onSubmit={(data) =>
|
|
updateEvolution.mutate(
|
|
{ id: editing.id, data: data as UpdateEvolutionInput },
|
|
{ onSuccess: () => setEditing(null) }
|
|
)
|
|
}
|
|
onClose={() => setEditing(null)}
|
|
isSubmitting={updateEvolution.isPending}
|
|
onDelete={() =>
|
|
deleteEvolution.mutate(editing.id, {
|
|
onSuccess: () => setEditing(null),
|
|
})
|
|
}
|
|
isDeleting={deleteEvolution.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|