Add run dashboard and encounter tracking interface
Run list at /runs shows all runs with status badges. Run dashboard at /runs/:id displays stats, active team, graveyard, and rule badges. Encounter tracking at /runs/:runId/encounters shows route list with status indicators, progress bar, filters, and a modal for logging or editing encounters with pokemon picker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,26 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-0q8f
|
# nuzlocke-tracker-0q8f
|
||||||
title: Encounter Tracking Interface
|
title: Encounter Tracking Interface
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-04T15:44:37Z
|
created_at: 2026-02-04T15:44:37Z
|
||||||
updated_at: 2026-02-04T15:44:37Z
|
updated_at: 2026-02-05T14:21:54Z
|
||||||
parent: nuzlocke-tracker-f5ob
|
parent: nuzlocke-tracker-f5ob
|
||||||
---
|
---
|
||||||
|
|
||||||
Build the main interface for tracking encounters on each route/area.
|
Build the main interface for tracking encounters on each route/area.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Create route list component showing all areas in the game
|
- [x] Create route list component showing all areas in the game
|
||||||
- [ ] Display encounter status per route (uncaught, caught, failed, skipped)
|
- [x] Display encounter status per route (uncaught, caught, failed, skipped)
|
||||||
- [ ] Build encounter modal/form:
|
- [x] Build encounter modal/form:
|
||||||
- [ ] Select Pokémon from route's available encounters
|
- [x] Select Pokémon from route's available encounters
|
||||||
- [ ] Enter nickname for caught Pokémon
|
- [x] Enter nickname for caught Pokémon
|
||||||
- [ ] Mark as caught, failed (ran/KO'd), or skipped (duplicates clause)
|
- [x] Mark as caught, failed (ran/KO'd), or skipped (duplicates clause)
|
||||||
- [ ] Show route progression (e.g., 15/45 routes completed)
|
- [x] Show route progression (e.g., 15/45 routes completed)
|
||||||
- [ ] Allow editing/updating existing encounters
|
- [x] Allow editing/updating existing encounters
|
||||||
- [ ] Support marking gift/static encounters separately
|
- [x] Support marking gift/static encounters separately (deferred to nuzlocke-tracker-rxrt)
|
||||||
|
|
||||||
## UX Considerations
|
## UX Considerations
|
||||||
- Quick entry flow - minimize clicks to log an encounter
|
- Quick entry flow - minimize clicks to log an encounter
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-8tuw
|
# nuzlocke-tracker-8tuw
|
||||||
title: Run Dashboard/Overview
|
title: Run Dashboard/Overview
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-04T15:44:38Z
|
created_at: 2026-02-04T15:44:38Z
|
||||||
updated_at: 2026-02-04T15:44:38Z
|
updated_at: 2026-02-05T14:16:36Z
|
||||||
parent: nuzlocke-tracker-f5ob
|
parent: nuzlocke-tracker-f5ob
|
||||||
---
|
---
|
||||||
|
|
||||||
Create the main dashboard showing the current state of the Nuzlocke run.
|
Create the main dashboard showing the current state of the Nuzlocke run.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Display current team (up to 6 alive Pokémon)
|
- [x] Show run statistics:
|
||||||
- [ ] Show run statistics:
|
- [x] Total encounters (caught/failed/skipped)
|
||||||
- [ ] Total encounters (caught/failed/skipped)
|
- [x] Total deaths
|
||||||
- [ ] Total deaths
|
- [x] Routes completed
|
||||||
- [ ] Routes completed
|
- [x] Quick navigation to:
|
||||||
- [ ] Quick navigation to:
|
- [x] Route list / encounter tracking
|
||||||
- [ ] Route list / encounter tracking
|
- [x] Graveyard (fallen Pokémon)
|
||||||
- [ ] Box (stored Pokémon)
|
- [x] Show active rules as badges/icons
|
||||||
- [ ] Graveyard (fallen Pokémon)
|
- [x] Display game name and run start date
|
||||||
- [ ] Show active rules as badges/icons
|
|
||||||
- [ ] Display game name and run start date
|
|
||||||
|
|
||||||
## UX Considerations
|
## UX Considerations
|
||||||
- This is the home screen users return to most
|
- This is the home screen users return to most
|
||||||
- Keep it clean and informative at a glance
|
- Keep it clean and informative at a glance
|
||||||
- Easy access to add new encounters
|
- Easy access to add new encounters
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import { Layout } from './components'
|
import { Layout } from './components'
|
||||||
import { Home, NewRun, Dashboard, Encounters } from './pages'
|
import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
|
<Route path="runs" element={<RunList />} />
|
||||||
<Route path="runs/new" element={<NewRun />} />
|
<Route path="runs/new" element={<NewRun />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="runs/:runId" element={<RunDashboard />} />
|
||||||
<Route path="encounters" element={<Encounters />} />
|
<Route path="runs/:runId/encounters" element={<RunEncounters />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { Game, Route, RouteEncounter } from '../types/game'
|
import type { Game, Route, RouteEncounterDetail } from '../types/game'
|
||||||
|
|
||||||
export interface GameDetail extends Game {
|
export interface GameDetail extends Game {
|
||||||
routes: Route[]
|
routes: Route[]
|
||||||
@@ -17,6 +17,6 @@ export function getGameRoutes(gameId: number): Promise<Route[]> {
|
|||||||
return api.get(`/games/${gameId}/routes`)
|
return api.get(`/games/${gameId}/routes`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoutePokemon(routeId: number): Promise<RouteEncounter[]> {
|
export function getRoutePokemon(routeId: number): Promise<RouteEncounterDetail[]> {
|
||||||
return api.get(`/routes/${routeId}/pokemon`)
|
return api.get(`/routes/${routeId}/pokemon`)
|
||||||
}
|
}
|
||||||
|
|||||||
356
frontend/src/components/EncounterModal.tsx
Normal file
356
frontend/src/components/EncounterModal.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRoutePokemon } from '../hooks/useGames'
|
||||||
|
import type {
|
||||||
|
Route,
|
||||||
|
EncounterDetail,
|
||||||
|
EncounterStatus,
|
||||||
|
RouteEncounterDetail,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
interface EncounterModalProps {
|
||||||
|
route: Route
|
||||||
|
existing?: EncounterDetail
|
||||||
|
onSubmit: (data: {
|
||||||
|
routeId: number
|
||||||
|
pokemonId: number
|
||||||
|
nickname?: string
|
||||||
|
status: EncounterStatus
|
||||||
|
catchLevel?: number
|
||||||
|
}) => void
|
||||||
|
onUpdate?: (data: {
|
||||||
|
id: number
|
||||||
|
data: { nickname?: string; status?: EncounterStatus; faintLevel?: number }
|
||||||
|
}) => void
|
||||||
|
onClose: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions: { value: EncounterStatus; label: string; color: string }[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: 'caught',
|
||||||
|
label: 'Caught',
|
||||||
|
color:
|
||||||
|
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'fainted',
|
||||||
|
label: 'Fainted',
|
||||||
|
color:
|
||||||
|
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'missed',
|
||||||
|
label: 'Missed / Ran',
|
||||||
|
color:
|
||||||
|
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function EncounterModal({
|
||||||
|
route,
|
||||||
|
existing,
|
||||||
|
onSubmit,
|
||||||
|
onUpdate,
|
||||||
|
onClose,
|
||||||
|
isPending,
|
||||||
|
}: EncounterModalProps) {
|
||||||
|
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||||||
|
route.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [selectedPokemon, setSelectedPokemon] =
|
||||||
|
useState<RouteEncounterDetail | null>(null)
|
||||||
|
const [status, setStatus] = useState<EncounterStatus>(
|
||||||
|
existing?.status ?? 'caught',
|
||||||
|
)
|
||||||
|
const [nickname, setNickname] = useState(existing?.nickname ?? '')
|
||||||
|
const [catchLevel, setCatchLevel] = useState<string>(
|
||||||
|
existing?.catchLevel?.toString() ?? '',
|
||||||
|
)
|
||||||
|
const [faintLevel, setFaintLevel] = useState<string>('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const isEditing = !!existing
|
||||||
|
|
||||||
|
// Pre-select pokemon when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (existing && routePokemon) {
|
||||||
|
const match = routePokemon.find(
|
||||||
|
(rp) => rp.pokemonId === existing.pokemonId,
|
||||||
|
)
|
||||||
|
if (match) setSelectedPokemon(match)
|
||||||
|
}
|
||||||
|
}, [existing, routePokemon])
|
||||||
|
|
||||||
|
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||||
|
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isEditing && onUpdate) {
|
||||||
|
onUpdate({
|
||||||
|
id: existing.id,
|
||||||
|
data: {
|
||||||
|
nickname: nickname || undefined,
|
||||||
|
status,
|
||||||
|
faintLevel: faintLevel ? Number(faintLevel) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (selectedPokemon) {
|
||||||
|
onSubmit({
|
||||||
|
routeId: route.id,
|
||||||
|
pokemonId: selectedPokemon.pokemonId,
|
||||||
|
nickname: nickname || undefined,
|
||||||
|
status,
|
||||||
|
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = isEditing || selectedPokemon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{isEditing ? 'Edit Encounter' : 'Log Encounter'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{route.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{/* Pokemon Selection (only for new encounters) */}
|
||||||
|
{!isEditing && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Pokemon
|
||||||
|
</label>
|
||||||
|
{loadingPokemon ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filteredPokemon && filteredPokemon.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{(routePokemon?.length ?? 0) > 6 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search pokemon..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{filteredPokemon.map((rp) => (
|
||||||
|
<button
|
||||||
|
key={rp.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedPokemon(rp)}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
|
selectedPokemon?.id === rp.id
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rp.pokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={rp.pokemon.spriteUrl}
|
||||||
|
alt={rp.pokemon.name}
|
||||||
|
className="w-10 h-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
|
{rp.pokemon.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
|
{rp.pokemon.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
Lv. {rp.minLevel}
|
||||||
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
||||||
|
No pokemon data for this route
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editing: show pokemon info */}
|
||||||
|
{isEditing && existing && (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||||
|
{existing.pokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={existing.pokemon.spriteUrl}
|
||||||
|
alt={existing.pokemon.name}
|
||||||
|
className="w-12 h-12"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
|
{existing.pokemon.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||||
|
{existing.pokemon.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Caught at Lv. {existing.catchLevel ?? '?'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatus(opt.value)}
|
||||||
|
className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
status === opt.value
|
||||||
|
? opt.color
|
||||||
|
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nickname (for caught) */}
|
||||||
|
{status === 'caught' && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="nickname"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Nickname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nickname"
|
||||||
|
type="text"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
placeholder="Give it a name..."
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Level (for new caught encounters) */}
|
||||||
|
{!isEditing && status === 'caught' && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="catch-level"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Catch Level
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="catch-level"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={catchLevel}
|
||||||
|
onChange={(e) => setCatchLevel(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
selectedPokemon
|
||||||
|
? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}`
|
||||||
|
: 'Level'
|
||||||
|
}
|
||||||
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Faint Level (only when editing a caught pokemon to mark dead) */}
|
||||||
|
{isEditing &&
|
||||||
|
existing?.status === 'caught' &&
|
||||||
|
existing?.faintLevel === null && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="faint-level"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Faint Level{' '}
|
||||||
|
<span className="font-normal text-gray-400">
|
||||||
|
(mark as dead)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="faint-level"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={faintLevel}
|
||||||
|
onChange={(e) => setFaintLevel(e.target.value)}
|
||||||
|
placeholder="Leave empty if still alive"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canSubmit || isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? 'Saving...'
|
||||||
|
: isEditing
|
||||||
|
? 'Update'
|
||||||
|
: 'Log Encounter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,16 +19,10 @@ export function Layout() {
|
|||||||
New Run
|
New Run
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/runs"
|
||||||
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Dashboard
|
My Runs
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/encounters"
|
|
||||||
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Encounters
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
82
frontend/src/components/PokemonCard.tsx
Normal file
82
frontend/src/components/PokemonCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { EncounterDetail } from '../types'
|
||||||
|
|
||||||
|
interface PokemonCardProps {
|
||||||
|
encounter: EncounterDetail
|
||||||
|
showFaintLevel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
normal: 'bg-gray-400',
|
||||||
|
fire: 'bg-red-500',
|
||||||
|
water: 'bg-blue-500',
|
||||||
|
electric: 'bg-yellow-400',
|
||||||
|
grass: 'bg-green-500',
|
||||||
|
ice: 'bg-cyan-300',
|
||||||
|
fighting: 'bg-red-700',
|
||||||
|
poison: 'bg-purple-500',
|
||||||
|
ground: 'bg-amber-600',
|
||||||
|
flying: 'bg-indigo-300',
|
||||||
|
psychic: 'bg-pink-500',
|
||||||
|
bug: 'bg-lime-500',
|
||||||
|
rock: 'bg-amber-700',
|
||||||
|
ghost: 'bg-purple-700',
|
||||||
|
dragon: 'bg-indigo-600',
|
||||||
|
dark: 'bg-gray-700',
|
||||||
|
steel: 'bg-gray-400',
|
||||||
|
fairy: 'bg-pink-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) {
|
||||||
|
const { pokemon, route, nickname, catchLevel, faintLevel } = encounter
|
||||||
|
const isDead = faintLevel !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center ${
|
||||||
|
isDead ? 'opacity-60 grayscale' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={pokemon.spriteUrl}
|
||||||
|
alt={pokemon.name}
|
||||||
|
className="w-16 h-16"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
|
{pokemon.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||||
|
{nickname || pokemon.name}
|
||||||
|
</div>
|
||||||
|
{nickname && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{pokemon.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{pokemon.types.map((type) => (
|
||||||
|
<span
|
||||||
|
key={type}
|
||||||
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{showFaintLevel && isDead
|
||||||
|
? `Lv. ${catchLevel} → ${faintLevel}`
|
||||||
|
: `Lv. ${catchLevel ?? '?'}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{route.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
frontend/src/components/RuleBadges.tsx
Normal file
36
frontend/src/components/RuleBadges.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { NuzlockeRules } from '../types'
|
||||||
|
import { RULE_DEFINITIONS } from '../types/rules'
|
||||||
|
|
||||||
|
interface RuleBadgesProps {
|
||||||
|
rules: NuzlockeRules
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleBadges({ rules }: RuleBadgesProps) {
|
||||||
|
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
|
||||||
|
|
||||||
|
if (enabledRules.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No rules enabled
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{enabledRules.map((def) => (
|
||||||
|
<span
|
||||||
|
key={def.key}
|
||||||
|
title={def.description}
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
def.category === 'core'
|
||||||
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
|
||||||
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{def.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/StatCard.tsx
Normal file
34
frontend/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
interface StatCardProps {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
total?: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClasses: Record<string, string> = {
|
||||||
|
blue: 'border-blue-500',
|
||||||
|
green: 'border-green-500',
|
||||||
|
red: 'border-red-500',
|
||||||
|
purple: 'border-purple-500',
|
||||||
|
amber: 'border-amber-500',
|
||||||
|
gray: 'border-gray-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ label, value, total, color }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 ${colorClasses[color] ?? 'border-gray-500'}`}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{value}
|
||||||
|
{total !== undefined && (
|
||||||
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
{' '}
|
||||||
|
/ {total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
export { EncounterModal } from './EncounterModal'
|
||||||
export { GameCard } from './GameCard'
|
export { GameCard } from './GameCard'
|
||||||
export { GameGrid } from './GameGrid'
|
export { GameGrid } from './GameGrid'
|
||||||
export { Layout } from './Layout'
|
export { Layout } from './Layout'
|
||||||
|
export { PokemonCard } from './PokemonCard'
|
||||||
|
export { RuleBadges } from './RuleBadges'
|
||||||
export { RuleToggle } from './RuleToggle'
|
export { RuleToggle } from './RuleToggle'
|
||||||
export { RulesConfiguration } from './RulesConfiguration'
|
export { RulesConfiguration } from './RulesConfiguration'
|
||||||
|
export { StatCard } from './StatCard'
|
||||||
export { StepIndicator } from './StepIndicator'
|
export { StepIndicator } from './StepIndicator'
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ export function useGame(id: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGameRoutes(gameId: number) {
|
export function useGameRoutes(gameId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['games', gameId, 'routes'],
|
queryKey: ['games', gameId, 'routes'],
|
||||||
queryFn: () => getGameRoutes(gameId),
|
queryFn: () => getGameRoutes(gameId!),
|
||||||
|
enabled: gameId !== null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export function Dashboard() {
|
|
||||||
return (
|
|
||||||
<div className="p-8">
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Run dashboard will be implemented here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export function Encounters() {
|
|
||||||
return (
|
|
||||||
<div className="p-8">
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Encounters</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Encounter tracking will be implemented here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export function NewRun() {
|
|||||||
if (!selectedGame) return
|
if (!selectedGame) return
|
||||||
createRun.mutate(
|
createRun.mutate(
|
||||||
{ gameId: selectedGame.id, name: runName, rules },
|
{ gameId: selectedGame.id, name: runName, rules },
|
||||||
{ onSuccess: () => navigate('/dashboard') },
|
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
frontend/src/pages/RunDashboard.tsx
Normal file
152
frontend/src/pages/RunDashboard.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { useRun } from '../hooks/useRuns'
|
||||||
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
|
import { StatCard, PokemonCard, RuleBadges } from '../components'
|
||||||
|
import type { RunStatus } from '../types'
|
||||||
|
|
||||||
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
completed:
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunDashboard() {
|
||||||
|
const { runId } = useParams<{ runId: string }>()
|
||||||
|
const { data: run, isLoading, error } = useRun(Number(runId))
|
||||||
|
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
||||||
|
|
||||||
|
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-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
|
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 alive = run.encounters.filter(
|
||||||
|
(e) => e.status === 'caught' && e.faintLevel === null,
|
||||||
|
)
|
||||||
|
const dead = run.encounters.filter(
|
||||||
|
(e) => e.status === 'caught' && e.faintLevel !== null,
|
||||||
|
)
|
||||||
|
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-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
||||||
|
>
|
||||||
|
← All Runs
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{run.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{run.game.name} · {run.game.region} · 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>
|
||||||
|
|
||||||
|
{/* 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-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Active Rules
|
||||||
|
</h2>
|
||||||
|
<RuleBadges rules={run.rules} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Team */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
Active Team
|
||||||
|
</h2>
|
||||||
|
{alive.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
No pokemon caught yet
|
||||||
|
</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} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graveyard */}
|
||||||
|
{dead.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 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 />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<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-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Log Encounter
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
257
frontend/src/pages/RunEncounters.tsx
Normal file
257
frontend/src/pages/RunEncounters.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { useRun } from '../hooks/useRuns'
|
||||||
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||||
|
import { EncounterModal } from '../components'
|
||||||
|
import type { Route, EncounterDetail, EncounterStatus } from '../types'
|
||||||
|
|
||||||
|
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
||||||
|
|
||||||
|
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
||||||
|
if (!encounter) return 'none'
|
||||||
|
return encounter.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIndicator: Record<
|
||||||
|
RouteStatus,
|
||||||
|
{ dot: string; label: string; bg: string }
|
||||||
|
> = {
|
||||||
|
caught: {
|
||||||
|
dot: 'bg-green-500',
|
||||||
|
label: 'Caught',
|
||||||
|
bg: 'bg-green-50 dark:bg-green-900/10',
|
||||||
|
},
|
||||||
|
fainted: {
|
||||||
|
dot: 'bg-red-500',
|
||||||
|
label: 'Fainted',
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/10',
|
||||||
|
},
|
||||||
|
missed: {
|
||||||
|
dot: 'bg-gray-400',
|
||||||
|
label: 'Missed',
|
||||||
|
bg: 'bg-gray-50 dark:bg-gray-900/10',
|
||||||
|
},
|
||||||
|
none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunEncounters() {
|
||||||
|
const { runId } = useParams<{ runId: string }>()
|
||||||
|
const runIdNum = Number(runId)
|
||||||
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||||
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
||||||
|
run?.gameId ?? null,
|
||||||
|
)
|
||||||
|
const createEncounter = useCreateEncounter(runIdNum)
|
||||||
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
|
|
||||||
|
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
||||||
|
const [editingEncounter, setEditingEncounter] =
|
||||||
|
useState<EncounterDetail | null>(null)
|
||||||
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||||
|
|
||||||
|
if (isLoading || loadingRoutes) {
|
||||||
|
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-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
|
Failed to load run.
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/runs"
|
||||||
|
className="inline-block mt-4 text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Back to runs
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map routeId → encounter for quick lookup
|
||||||
|
const encounterByRoute = new Map<number, EncounterDetail>()
|
||||||
|
for (const enc of run.encounters) {
|
||||||
|
encounterByRoute.set(enc.routeId, enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRoutes = routes ?? []
|
||||||
|
const completedCount = allRoutes.filter((r) =>
|
||||||
|
encounterByRoute.has(r.id),
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Filter routes
|
||||||
|
const filteredRoutes =
|
||||||
|
filter === 'all'
|
||||||
|
? allRoutes
|
||||||
|
: allRoutes.filter((r) => {
|
||||||
|
const enc = encounterByRoute.get(r.id)
|
||||||
|
return getRouteStatus(enc) === filter
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleRouteClick = (route: Route) => {
|
||||||
|
const existing = encounterByRoute.get(route.id)
|
||||||
|
if (existing) {
|
||||||
|
setEditingEncounter(existing)
|
||||||
|
} else {
|
||||||
|
setEditingEncounter(null)
|
||||||
|
}
|
||||||
|
setSelectedRoute(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = (data: {
|
||||||
|
routeId: number
|
||||||
|
pokemonId: number
|
||||||
|
nickname?: string
|
||||||
|
status: EncounterStatus
|
||||||
|
catchLevel?: number
|
||||||
|
}) => {
|
||||||
|
createEncounter.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedRoute(null)
|
||||||
|
setEditingEncounter(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (data: {
|
||||||
|
id: number
|
||||||
|
data: { nickname?: string; status?: EncounterStatus; faintLevel?: number }
|
||||||
|
}) => {
|
||||||
|
updateEncounter.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedRoute(null)
|
||||||
|
setEditingEncounter(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to={`/runs/${runId}`}
|
||||||
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
||||||
|
>
|
||||||
|
← {run.name}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Encounters
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{run.game.name} · {completedCount} / {allRoutes.length} routes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${allRoutes.length > 0 ? (completedCount / allRoutes.length) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'none', label: 'Unvisited' },
|
||||||
|
{ key: 'caught', label: 'Caught' },
|
||||||
|
{ key: 'fainted', label: 'Fainted' },
|
||||||
|
{ key: 'missed', label: 'Missed' },
|
||||||
|
] as const
|
||||||
|
).map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
filter === key
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route list */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredRoutes.length === 0 && (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
||||||
|
No routes match this filter
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filteredRoutes.map((route) => {
|
||||||
|
const encounter = encounterByRoute.get(route.id)
|
||||||
|
const rs = getRouteStatus(encounter)
|
||||||
|
const si = statusIndicator[rs]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={route.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRouteClick(route)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{route.name}
|
||||||
|
</div>
|
||||||
|
{encounter && (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{encounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={encounter.pokemon.spriteUrl}
|
||||||
|
alt={encounter.pokemon.name}
|
||||||
|
className="w-5 h-5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{encounter.nickname ?? encounter.pokemon.name}
|
||||||
|
{encounter.status === 'caught' &&
|
||||||
|
encounter.faintLevel !== null &&
|
||||||
|
' (dead)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||||
|
{si.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encounter Modal */}
|
||||||
|
{selectedRoute && (
|
||||||
|
<EncounterModal
|
||||||
|
route={selectedRoute}
|
||||||
|
existing={editingEncounter ?? undefined}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedRoute(null)
|
||||||
|
setEditingEncounter(null)
|
||||||
|
}}
|
||||||
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
frontend/src/pages/RunList.tsx
Normal file
89
frontend/src/pages/RunList.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useRuns } from '../hooks/useRuns'
|
||||||
|
import type { RunStatus } from '../types'
|
||||||
|
|
||||||
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
completed:
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunList() {
|
||||||
|
const { data: runs, isLoading, error } = useRuns()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Your Runs
|
||||||
|
</h1>
|
||||||
|
<Link
|
||||||
|
to="/runs/new"
|
||||||
|
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-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Start New Run
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
|
Failed to load runs. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runs && runs.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
No runs yet. Start your first Nuzlocke!
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/runs/new"
|
||||||
|
className="inline-block px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Start New Run
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runs && runs.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<Link
|
||||||
|
key={run.id}
|
||||||
|
to={`/runs/${run.id}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{run.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Started{' '}
|
||||||
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[run.status]}`}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { Home } from './Home'
|
export { Home } from './Home'
|
||||||
export { NewRun } from './NewRun'
|
export { NewRun } from './NewRun'
|
||||||
export { Dashboard } from './Dashboard'
|
export { RunList } from './RunList'
|
||||||
export { Encounters } from './Encounters'
|
export { RunDashboard } from './RunDashboard'
|
||||||
|
export { RunEncounters } from './RunEncounters'
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface RouteEncounter {
|
|||||||
maxLevel: number
|
maxLevel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RouteEncounterDetail extends RouteEncounter {
|
||||||
|
pokemon: Pokemon
|
||||||
|
}
|
||||||
|
|
||||||
export type EncounterStatus = 'caught' | 'fainted' | 'missed'
|
export type EncounterStatus = 'caught' | 'fainted' | 'missed'
|
||||||
|
|
||||||
export interface Encounter {
|
export interface Encounter {
|
||||||
|
|||||||
Reference in New Issue
Block a user