Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { useParams, Link } from 'react-router-dom'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
|
import { useGameRoutes } from '../hooks/useGames'
|
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
|
import {
|
|
CustomRulesDisplay,
|
|
StatCard,
|
|
PokemonCard,
|
|
RuleBadges,
|
|
StatusChangeModal,
|
|
EndRunModal,
|
|
} from '../components'
|
|
import type { RunStatus, EncounterDetail, RunVisibility } from '../types'
|
|
|
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
|
|
|
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
|
return [...encounters].sort((a, b) => {
|
|
switch (key) {
|
|
case 'route':
|
|
return a.route.order - b.route.order
|
|
case 'level':
|
|
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
|
|
case 'species': {
|
|
const nameA = (a.currentPokemon ?? a.pokemon).name
|
|
const nameB = (b.currentPokemon ?? b.pokemon).name
|
|
return nameA.localeCompare(nameB)
|
|
}
|
|
case 'dex':
|
|
return (
|
|
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
|
)
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
}
|
|
|
|
const statusStyles: Record<RunStatus, string> = {
|
|
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
|
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
|
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
|
}
|
|
|
|
function formatDuration(start: string, end: string) {
|
|
const ms = new Date(end).getTime() - new Date(start).getTime()
|
|
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
|
|
if (days === 0) return 'Less than a day'
|
|
if (days === 1) return '1 day'
|
|
return `${days} days`
|
|
}
|
|
|
|
export function RunDashboard() {
|
|
const { runId } = useParams<{ runId: string }>()
|
|
const runIdNum = Number(runId)
|
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
|
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
|
const { user } = useAuth()
|
|
const createEncounter = useCreateEncounter(runIdNum)
|
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
|
const updateRun = useUpdateRun(runIdNum)
|
|
const { data: namingCategories } = useNamingCategories()
|
|
const [selectedEncounter, setSelectedEncounter] = useState<EncounterDetail | null>(null)
|
|
const [showEndRun, setShowEndRun] = useState(false)
|
|
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
|
|
|
const isOwner = user && run?.owner?.id === user.id
|
|
const canEdit = isOwner || !run?.owner
|
|
|
|
const encounters = run?.encounters ?? []
|
|
const alive = useMemo(
|
|
() =>
|
|
sortEncounters(
|
|
encounters.filter((e) => e.status === 'caught' && e.faintLevel === null),
|
|
teamSort
|
|
),
|
|
[encounters, teamSort]
|
|
)
|
|
const dead = useMemo(
|
|
() =>
|
|
sortEncounters(
|
|
encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
|
|
teamSort
|
|
),
|
|
[encounters, teamSort]
|
|
)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !run) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
|
|
Failed to load run. It may not exist.
|
|
</div>
|
|
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
|
|
Back to runs
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isActive = run.status === 'active'
|
|
const visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size
|
|
const totalRoutes = routes?.length
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<Link
|
|
to="/runs"
|
|
className="text-sm text-text-tertiary hover:text-text-primary mb-2 inline-block"
|
|
>
|
|
← All Runs
|
|
</Link>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-text-primary">{run.name}</h1>
|
|
<p className="text-text-tertiary mt-1">
|
|
{run.game.name} ·{' '}
|
|
{run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
</p>
|
|
</div>
|
|
<span
|
|
className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${statusStyles[run.status]}`}
|
|
>
|
|
{run.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Banner */}
|
|
{!isActive && (
|
|
<div
|
|
className={`rounded-lg p-4 mb-6 ${
|
|
run.status === 'completed'
|
|
? 'bg-blue-900/20 border border-blue-800'
|
|
: 'bg-status-failed-bg border border-red-800'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">
|
|
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}
|
|
</span>
|
|
<div>
|
|
<p
|
|
className={`font-semibold ${
|
|
run.status === 'completed' ? 'text-blue-200' : 'text-red-200'
|
|
}`}
|
|
>
|
|
{run.status === 'completed' ? 'Victory!' : 'Defeat'}
|
|
</p>
|
|
<p
|
|
className={`text-sm ${
|
|
run.status === 'completed' ? 'text-text-link' : 'text-status-failed'
|
|
}`}
|
|
>
|
|
{run.completedAt && (
|
|
<>
|
|
Ended{' '}
|
|
{new Date(run.completedAt).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
{' \u00b7 '}
|
|
Duration: {formatDuration(run.startedAt, run.completedAt)}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
|
<StatCard label="Encounters" value={run.encounters.length} color="blue" />
|
|
<StatCard label="Alive" value={alive.length} color="green" />
|
|
<StatCard label="Deaths" value={dead.length} color="red" />
|
|
<StatCard label="Routes Visited" value={visitedRoutes} total={totalRoutes} color="purple" />
|
|
</div>
|
|
|
|
{/* Rules */}
|
|
<div className="mb-6">
|
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Active Rules</h2>
|
|
<RuleBadges rules={run.rules} />
|
|
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
|
|
</div>
|
|
|
|
{/* Visibility */}
|
|
{canEdit && (
|
|
<div className="mb-6">
|
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Visibility</h2>
|
|
<select
|
|
value={run.visibility}
|
|
onChange={(e) => updateRun.mutate({ visibility: e.target.value as RunVisibility })}
|
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
|
>
|
|
<option value="public">Public</option>
|
|
<option value="private">Private</option>
|
|
</select>
|
|
<p className="mt-1 text-xs text-text-tertiary">
|
|
{run.visibility === 'private'
|
|
? 'Only you can see this run'
|
|
: 'Anyone can view this run'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Naming Scheme */}
|
|
{namingCategories && namingCategories.length > 0 && (
|
|
<div className="mb-6">
|
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
|
|
{isActive && canEdit ? (
|
|
<select
|
|
value={run.namingScheme ?? ''}
|
|
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
|
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
|
>
|
|
<option value="">None</option>
|
|
{namingCategories.map((cat) => (
|
|
<option key={cat} value={cat}>
|
|
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<span className="text-sm text-text-primary">
|
|
{run.namingScheme
|
|
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
|
|
: 'None'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Active Team */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-lg font-semibold text-text-primary">
|
|
{isActive ? 'Active Team' : 'Final Team'}
|
|
</h2>
|
|
{alive.length > 1 && (
|
|
<select
|
|
value={teamSort}
|
|
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
|
>
|
|
<option value="route">Route Order</option>
|
|
<option value="level">Catch Level</option>
|
|
<option value="species">Species Name</option>
|
|
<option value="dex">National Dex</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
{alive.length === 0 ? (
|
|
<p className="text-text-tertiary text-sm">
|
|
No pokemon caught yet — head to encounters to start building your team!
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
{alive.map((enc) => (
|
|
<PokemonCard
|
|
key={enc.id}
|
|
encounter={enc}
|
|
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Graveyard */}
|
|
{dead.length > 0 && (
|
|
<div className="mb-6">
|
|
<h2 className="text-lg font-semibold text-text-primary mb-3">Graveyard</h2>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
{dead.map((enc) => (
|
|
<PokemonCard
|
|
key={enc.id}
|
|
encounter={enc}
|
|
showFaintLevel
|
|
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Actions */}
|
|
<div className="mt-8 flex gap-3">
|
|
{isActive && canEdit && (
|
|
<>
|
|
<Link
|
|
to={`/runs/${runId}/encounters`}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 transition-colors"
|
|
>
|
|
Log Encounter
|
|
</Link>
|
|
<button
|
|
onClick={() => setShowEndRun(true)}
|
|
className="px-4 py-2 border border-border-default rounded-lg font-medium hover:bg-surface-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 transition-colors"
|
|
>
|
|
End Run
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Change Modal */}
|
|
{selectedEncounter && (
|
|
<StatusChangeModal
|
|
encounter={selectedEncounter}
|
|
onUpdate={(data) => {
|
|
updateEncounter.mutate(data, {
|
|
onSuccess: () => setSelectedEncounter(null),
|
|
})
|
|
}}
|
|
onClose={() => setSelectedEncounter(null)}
|
|
isPending={updateEncounter.isPending}
|
|
region={run?.game.region}
|
|
onCreateEncounter={(data) => {
|
|
createEncounter.mutate(data)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* End Run Modal */}
|
|
{showEndRun && (
|
|
<EndRunModal
|
|
onConfirm={(status) => {
|
|
updateRun.mutate({ status }, { onSuccess: () => setShowEndRun(false) })
|
|
}}
|
|
onClose={() => setShowEndRun(false)}
|
|
isPending={updateRun.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|