Improve UX with merged run view, method badges, grouped encounters, and mobile nav
Merges the run dashboard into the encounters page as a unified view at /runs/:runId, adds encounter method grouping in the modal and badges on route rows, improves the home page with recent runs, adds mobile hamburger nav, and polishes empty states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
99
frontend/src/components/EncounterMethodBadge.tsx
Normal file
99
frontend/src/components/EncounterMethodBadge.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
starter: {
|
||||
label: 'Starter',
|
||||
color:
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
},
|
||||
gift: {
|
||||
label: 'Gift',
|
||||
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
|
||||
},
|
||||
fossil: {
|
||||
label: 'Fossil',
|
||||
color:
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
},
|
||||
trade: {
|
||||
label: 'Trade',
|
||||
color:
|
||||
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
},
|
||||
walk: {
|
||||
label: 'Grass',
|
||||
color:
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
},
|
||||
headbutt: {
|
||||
label: 'Headbutt',
|
||||
color: 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
|
||||
},
|
||||
surf: {
|
||||
label: 'Surfing',
|
||||
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
},
|
||||
'rock-smash': {
|
||||
label: 'Rock Smash',
|
||||
color:
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
},
|
||||
'old-rod': {
|
||||
label: 'Old Rod',
|
||||
color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
},
|
||||
'good-rod': {
|
||||
label: 'Good Rod',
|
||||
color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
},
|
||||
'super-rod': {
|
||||
label: 'Super Rod',
|
||||
color:
|
||||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
},
|
||||
}
|
||||
|
||||
/** Display order for encounter method groups */
|
||||
export const METHOD_ORDER = [
|
||||
'starter',
|
||||
'gift',
|
||||
'fossil',
|
||||
'trade',
|
||||
'walk',
|
||||
'headbutt',
|
||||
'surf',
|
||||
'rock-smash',
|
||||
'old-rod',
|
||||
'good-rod',
|
||||
'super-rod',
|
||||
]
|
||||
|
||||
export function getMethodLabel(method: string): string {
|
||||
return (
|
||||
METHOD_CONFIG[method]?.label ??
|
||||
method
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
)
|
||||
}
|
||||
|
||||
export function getMethodColor(method: string): string {
|
||||
return METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
export function EncounterMethodBadge({
|
||||
method,
|
||||
size = 'sm',
|
||||
}: {
|
||||
method: string
|
||||
size?: 'sm' | 'xs'
|
||||
}) {
|
||||
const config = METHOD_CONFIG[method]
|
||||
if (!config) return null
|
||||
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
|
||||
return (
|
||||
<span
|
||||
className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useRoutePokemon } from '../hooks/useGames'
|
||||
import {
|
||||
EncounterMethodBadge,
|
||||
getMethodLabel,
|
||||
METHOD_ORDER,
|
||||
} from './EncounterMethodBadge'
|
||||
import type {
|
||||
Route,
|
||||
EncounterDetail,
|
||||
@@ -52,38 +57,22 @@ const statusOptions: { value: EncounterStatus; label: string; color: string }[]
|
||||
},
|
||||
]
|
||||
|
||||
const specialMethodStyles: Record<string, { label: string; color: string }> = {
|
||||
starter: {
|
||||
label: 'Starter',
|
||||
color:
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
},
|
||||
gift: {
|
||||
label: 'Gift',
|
||||
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
|
||||
},
|
||||
fossil: {
|
||||
label: 'Fossil',
|
||||
color:
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
},
|
||||
trade: {
|
||||
label: 'Trade',
|
||||
color:
|
||||
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
},
|
||||
}
|
||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||
|
||||
function EncounterMethodBadge({ method }: { method: string }) {
|
||||
const config = specialMethodStyles[method]
|
||||
if (!config) return null
|
||||
return (
|
||||
<span
|
||||
className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${config.color}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||
for (const rp of pokemon) {
|
||||
const list = groups.get(rp.encounterMethod) ?? []
|
||||
list.push(rp)
|
||||
groups.set(rp.encounterMethod, list)
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
const ai = METHOD_ORDER.indexOf(a)
|
||||
const bi = METHOD_ORDER.indexOf(b)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
.map(([method, pokemon]) => ({ method, pokemon }))
|
||||
}
|
||||
|
||||
export function EncounterModal({
|
||||
@@ -127,6 +116,12 @@ export function EncounterModal({
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
const groupedPokemon = useMemo(
|
||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||
[filteredPokemon],
|
||||
)
|
||||
const hasMultipleGroups = groupedPokemon.length > 1
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isEditing && onUpdate) {
|
||||
onUpdate({
|
||||
@@ -206,38 +201,54 @@ export function EncounterModal({
|
||||
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 className="max-h-64 overflow-y-auto space-y-3">
|
||||
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||||
<div key={method}>
|
||||
{groupIdx > 0 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
|
||||
)}
|
||||
{hasMultipleGroups && (
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
{getMethodLabel(method)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||
{rp.pokemon.name}
|
||||
</span>
|
||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Lv. {rp.minLevel}
|
||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||
</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{pokemon.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>
|
||||
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Lv. {rp.minLevel}
|
||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
|
||||
export function Layout() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow-sm">
|
||||
@@ -11,7 +14,8 @@ export function Layout() {
|
||||
Nuzlocke Tracker
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden sm:flex items-center space-x-4">
|
||||
<Link
|
||||
to="/runs/new"
|
||||
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -31,8 +35,68 @@ export function Layout() {
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
{/* Mobile hamburger */}
|
||||
<div className="flex items-center sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{menuOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile dropdown */}
|
||||
{menuOpen && (
|
||||
<div className="sm:hidden border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<Link
|
||||
to="/runs/new"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
New Run
|
||||
</Link>
|
||||
<Link
|
||||
to="/runs"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
My Runs
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<main>
|
||||
<Outlet />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||
export { EncounterModal } from './EncounterModal'
|
||||
export { EndRunModal } from './EndRunModal'
|
||||
export { GameCard } from './GameCard'
|
||||
|
||||
Reference in New Issue
Block a user