2026-02-09 10:51:47 +01:00
|
|
|
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 | ''>('')
|
|
|
|
|
|
2026-02-16 20:39:41 +01:00
|
|
|
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>
|
2026-02-09 10:51:47 +01:00
|
|
|
|
|
|
|
|
const editName = name ?? genlocke.name
|
|
|
|
|
const editStatus = status ?? genlocke.status
|
|
|
|
|
|
2026-02-16 20:39:41 +01:00
|
|
|
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
|
2026-02-09 10:51:47 +01:00
|
|
|
|
|
|
|
|
const handleSave = () => {
|
|
|
|
|
const data: Record<string, string> = {}
|
2026-02-16 20:39:41 +01:00
|
|
|
if (editName !== genlocke.name) data['name'] = editName
|
|
|
|
|
if (editStatus !== genlocke.status) data['status'] = editStatus
|
2026-02-09 10:51:47 +01:00
|
|
|
if (Object.keys(data).length === 0) return
|
|
|
|
|
updateGenlocke.mutate(
|
|
|
|
|
{ id, data },
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setName(null)
|
|
|
|
|
setStatus(null)
|
|
|
|
|
},
|
2026-02-14 16:41:24 +01:00
|
|
|
}
|
2026-02-09 10:51:47 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleAddLeg = () => {
|
|
|
|
|
if (!selectedGameId) return
|
|
|
|
|
addLeg.mutate(
|
|
|
|
|
{ gameId: Number(selectedGameId) },
|
|
|
|
|
{
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setAddingLeg(false)
|
|
|
|
|
setSelectedGameId('')
|
|
|
|
|
},
|
2026-02-14 16:41:24 +01:00
|
|
|
}
|
2026-02-09 10:51:47 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<nav className="text-sm mb-4 text-text-tertiary">
|
2026-02-09 10:51:47 +01:00
|
|
|
<Link to="/admin/genlockes" className="hover:underline">
|
|
|
|
|
Genlockes
|
|
|
|
|
</Link>
|
|
|
|
|
{' / '}
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-primary">{genlocke.name}</span>
|
2026-02-09 10:51:47 +01:00
|
|
|
</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]">
|
2026-02-17 20:48:42 +01:00
|
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">Name</label>
|
2026-02-09 10:51:47 +01:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={editName}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-09 10:51:47 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">Status</label>
|
2026-02-09 10:51:47 +01:00
|
|
|
<select
|
|
|
|
|
value={editStatus}
|
|
|
|
|
onChange={(e) => setStatus(e.target.value)}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
<option value="active">Active</option>
|
|
|
|
|
<option value="completed">Completed</option>
|
|
|
|
|
<option value="failed">Failed</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={!hasChanges || updateGenlocke.isPending}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
{updateGenlocke.isPending ? 'Saving...' : 'Save'}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowDelete(true)}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-red-300 dark:border-red-600 text-status-failed hover:bg-red-50 dark:hover:bg-red-900/20"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<p className="text-sm text-text-tertiary">
|
2026-02-09 10:51:47 +01:00
|
|
|
Created {new Date(genlocke.createdAt).toLocaleDateString()}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Rules (read-only) */}
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="mb-6 p-4 bg-surface-1 rounded-lg">
|
|
|
|
|
<h3 className="text-sm font-semibold mb-2 text-text-secondary">Rules</h3>
|
2026-02-09 10:51:47 +01:00
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-tertiary">Genlocke rules:</span>
|
|
|
|
|
<pre className="mt-1 text-xs bg-surface-0 p-2 rounded border border-border-default overflow-x-auto">
|
2026-02-09 10:51:47 +01:00
|
|
|
{JSON.stringify(genlocke.genlockeRules, null, 2)}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-tertiary">Nuzlocke rules:</span>
|
|
|
|
|
<pre className="mt-1 text-xs bg-surface-0 p-2 rounded border border-border-default overflow-x-auto">
|
2026-02-09 10:51:47 +01:00
|
|
|
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Legs */}
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
2026-02-16 20:39:41 +01:00
|
|
|
<h3 className="text-lg font-semibold">Legs ({genlocke.legs.length})</h3>
|
2026-02-09 10:51:47 +01:00
|
|
|
<button
|
|
|
|
|
onClick={() => setAddingLeg(!addingLeg)}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
Add Leg
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{addingLeg && (
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="mb-4 flex items-center gap-3 p-3 bg-surface-1 rounded-lg">
|
2026-02-09 10:51:47 +01:00
|
|
|
<select
|
|
|
|
|
value={selectedGameId}
|
2026-02-16 20:39:41 +01:00
|
|
|
onChange={(e) => setSelectedGameId(e.target.value ? Number(e.target.value) : '')}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="flex-1 px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
<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('')
|
|
|
|
|
}}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{genlocke.legs.length === 0 ? (
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="text-center py-8 text-text-tertiary border border-border-default rounded-lg">
|
2026-02-09 10:51:47 +01:00
|
|
|
No legs yet. Add one to get started.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="border border-border-default rounded-lg overflow-hidden">
|
2026-02-09 10:51:47 +01:00
|
|
|
<div className="overflow-x-auto">
|
2026-02-17 20:48:42 +01:00
|
|
|
<table className="min-w-full divide-y divide-border-default">
|
|
|
|
|
<thead className="bg-surface-1">
|
2026-02-09 10:51:47 +01:00
|
|
|
<tr>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider w-16">
|
2026-02-09 10:51:47 +01:00
|
|
|
Order
|
|
|
|
|
</th>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
2026-02-09 10:51:47 +01:00
|
|
|
Game
|
|
|
|
|
</th>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
2026-02-09 10:51:47 +01:00
|
|
|
Run
|
|
|
|
|
</th>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
2026-02-09 10:51:47 +01:00
|
|
|
Status
|
|
|
|
|
</th>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider w-20">
|
2026-02-09 10:51:47 +01:00
|
|
|
Enc.
|
|
|
|
|
</th>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider w-20">
|
2026-02-09 10:51:47 +01:00
|
|
|
Deaths
|
|
|
|
|
</th>
|
2026-02-17 20:48:42 +01:00
|
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary uppercase tracking-wider w-20">
|
2026-02-09 10:51:47 +01:00
|
|
|
Actions
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
2026-02-17 20:48:42 +01:00
|
|
|
<tbody className="bg-surface-0 divide-y divide-border-default">
|
2026-02-09 10:51:47 +01:00
|
|
|
{genlocke.legs.map((leg) => (
|
|
|
|
|
<tr key={leg.id}>
|
2026-02-16 20:39:41 +01:00
|
|
|
<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>
|
2026-02-09 10:51:47 +01:00
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
|
|
|
{leg.runId ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/runs/${leg.runId}`}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="text-text-link hover:underline"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
Run #{leg.runId}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-gray-400">—</span>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
|
|
|
{leg.runStatus ? (
|
|
|
|
|
<span
|
|
|
|
|
className={
|
|
|
|
|
leg.runStatus === 'active'
|
2026-02-17 20:48:42 +01:00
|
|
|
? 'text-status-active'
|
2026-02-09 10:51:47 +01:00
|
|
|
: leg.runStatus === 'completed'
|
2026-02-17 20:48:42 +01:00
|
|
|
? 'text-text-link'
|
|
|
|
|
: 'text-status-failed'
|
2026-02-09 10:51:47 +01:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{leg.runStatus}
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-gray-400">—</span>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
2026-02-16 20:39:41 +01:00
|
|
|
<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>
|
2026-02-09 10:51:47 +01:00
|
|
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => deleteLeg.mutate(leg.id)}
|
|
|
|
|
disabled={leg.runId !== null || deleteLeg.isPending}
|
2026-02-14 16:41:24 +01:00
|
|
|
title={
|
|
|
|
|
leg.runId !== null
|
|
|
|
|
? 'Cannot remove a leg with a linked run'
|
|
|
|
|
: 'Remove leg'
|
|
|
|
|
}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="text-status-failed hover:text-red-800 dark:hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
2026-02-09 10:51:47 +01:00
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats */}
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="mb-6 p-4 bg-surface-1 rounded-lg">
|
|
|
|
|
<h3 className="text-sm font-semibold mb-2 text-text-secondary">Stats</h3>
|
2026-02-09 10:51:47 +01:00
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-tertiary">Legs</span>
|
2026-02-14 16:41:24 +01:00
|
|
|
<p className="text-lg font-semibold">
|
|
|
|
|
{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}
|
|
|
|
|
</p>
|
2026-02-09 10:51:47 +01:00
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-tertiary">Encounters</span>
|
2026-02-16 20:39:41 +01:00
|
|
|
<p className="text-lg font-semibold">{genlocke.stats.totalEncounters}</p>
|
2026-02-09 10:51:47 +01:00
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-tertiary">Deaths</span>
|
2026-02-16 20:39:41 +01:00
|
|
|
<p className="text-lg font-semibold">{genlocke.stats.totalDeaths}</p>
|
2026-02-09 10:51:47 +01:00
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-text-tertiary">Survival Rate</span>
|
2026-02-09 10:51:47 +01:00
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|