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>
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
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 { exportGames } from '../../api/admin'
|
|
import { downloadJson } from '../../utils/download'
|
|
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
|
|
|
|
export function AdminGames() {
|
|
const { data: games = [], isLoading } = useGames()
|
|
const createGame = useCreateGame()
|
|
const updateGame = useUpdateGame()
|
|
const deleteGame = useDeleteGame()
|
|
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [editing, setEditing] = useState<Game | null>(null)
|
|
const [regionFilter, setRegionFilter] = useState('')
|
|
const [genFilter, setGenFilter] = useState('')
|
|
|
|
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]
|
|
)
|
|
|
|
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))
|
|
return result
|
|
}, [games, regionFilter, genFilter])
|
|
|
|
const columns: Column<Game>[] = [
|
|
{ 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,
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-semibold">Games</h2>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
const data = await exportGames()
|
|
downloadJson(data, 'games.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={() => setShowCreate(true)}
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500"
|
|
>
|
|
Add Game
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap items-center gap-4">
|
|
<select
|
|
aria-label="Filter by region"
|
|
value={regionFilter}
|
|
onChange={(e) => setRegionFilter(e.target.value)}
|
|
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
|
>
|
|
<option value="">All regions</option>
|
|
{regions.map((r) => (
|
|
<option key={r} value={r}>
|
|
{r}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
aria-label="Filter by generation"
|
|
value={genFilter}
|
|
onChange={(e) => setGenFilter(e.target.value)}
|
|
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
|
>
|
|
<option value="">All generations</option>
|
|
{generations.map((g) => (
|
|
<option key={g} value={g}>
|
|
Gen {g}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{(regionFilter || genFilter) && (
|
|
<button
|
|
onClick={() => {
|
|
setRegionFilter('')
|
|
setGenFilter('')
|
|
}}
|
|
className="text-sm text-text-tertiary hover:text-text-primary"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
<span className="text-sm text-text-tertiary whitespace-nowrap">
|
|
{filteredGames.length} games
|
|
</span>
|
|
</div>
|
|
|
|
<AdminTable
|
|
columns={columns}
|
|
data={filteredGames}
|
|
isLoading={isLoading}
|
|
emptyMessage="No games found."
|
|
onRowClick={(g) => setEditing(g)}
|
|
keyFn={(g) => g.id}
|
|
/>
|
|
|
|
{showCreate && (
|
|
<GameFormModal
|
|
onSubmit={(data) =>
|
|
createGame.mutate(data as CreateGameInput, {
|
|
onSuccess: () => setShowCreate(false),
|
|
})
|
|
}
|
|
onClose={() => setShowCreate(false)}
|
|
isSubmitting={createGame.isPending}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<GameFormModal
|
|
game={editing}
|
|
onSubmit={(data) =>
|
|
updateGame.mutate(
|
|
{ id: editing.id, data: data as UpdateGameInput },
|
|
{ onSuccess: () => setEditing(null) }
|
|
)
|
|
}
|
|
onClose={() => setEditing(null)}
|
|
isSubmitting={updateGame.isPending}
|
|
onDelete={() =>
|
|
deleteGame.mutate(editing.id, {
|
|
onSuccess: () => setEditing(null),
|
|
})
|
|
}
|
|
isDeleting={deleteGame.isPending}
|
|
detailUrl={`/admin/games/${editing.id}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|