Files
personal-website/src/lib/catch-em-all/PokemonDetails.svelte
Stan Runge c2ba8febb4
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Expand website and game trackers
2026-09-25 22:43:04 +00:00

341 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let moveSearch = $state('');
let moveMethod = $state('all');
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability
} from '$lib/availability';
import type { PokemonDetail } from '$lib/server/pokemon-detail';
let {
number,
gameId,
isAdmin = false,
onsaved
}: { number: number; gameId: number | null; isAdmin?: boolean; onsaved?: () => void } = $props();
let detail = $state<PokemonDetail | null>(null);
let loadError = $state('');
let formMessage = $state('');
let version = $state(0);
$effect(() => {
const controller = new AbortController();
const url = `/projects/game-tracker/pokemon/api/${number}${gameId ? `?game=${gameId}` : ''}`;
void version;
loadError = '';
fetch(url, { signal: controller.signal })
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((body: PokemonDetail) => (detail = body))
.catch((reason) => {
if (!controller.signal.aborted) loadError = `Could not load details (${reason}).`;
});
return () => controller.abort();
});
const current = $derived(detail?.pokemon.number === number ? detail : null);
const steps = $derived(
current
? current.chain
.map((member) => ({
member,
from: current.chain.find((other) => other.number === member.evolvesFrom)
}))
// without a game, show the whole chain; with one, only steps that exist in it
.filter(({ member, from }) => from && (!gameId || (member.inGame && from.inGame)))
.map(({ member, from }) => {
const methods = current.evolutions
.filter((evolution) => evolution.to === member.number)
.map((evolution) => evolution.method);
return { from: from?.name ?? '?', to: member.name, number: member.number, methods };
})
: []
);
const obtainableGames = $derived(
current?.games.filter((game) => obtainableAvailability.has(game.availability)) ?? []
);
const otherGames = $derived(
current?.games.filter((game) => !obtainableAvailability.has(game.availability)) ?? []
);
function levels(min: number | null, max: number | null) {
if (min === null) return '';
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
}
</script>
{#if loadError}
<p role="alert" class="text-red-500">{loadError}</p>
{:else if !current}
<p class="text-[var(--site-muted)]">loading…</p>
{:else}
{@const species = current.pokemon}
<div class="flex flex-wrap items-center gap-5">
<img
src={pokemonImage(species.number, 'artwork')}
alt=""
width="120"
height="120"
class="h-28 w-28 shrink-0"
/>
<div class="min-w-0">
<p class="text-[var(--site-muted)]">#{species.number} · generation {species.generation}</p>
<h2 class="text-3xl font-semibold">{species.name}</h2>
<p class="mt-1 text-[var(--site-muted)]">
{species.type1}{species.type2 ? ` / ${species.type2}` : ''}
{#if species.isMythical}· mythical{:else if species.isLegendary}· legendary{/if}
{#if species.isBaby}· baby{/if}
</p>
</div>
</div>
<section class="mt-6 rounded-xl border p-4">
<h3 class="font-semibold">Base stats</h3>
<div class="mt-3 grid gap-2">
{#each current.stats as stat (stat.name)}<div
class="grid grid-cols-[5rem_2rem_1fr] items-center gap-3 text-xs"
>
<span>{stat.name}</span><span class="font-semibold tabular-nums">{stat.value}</span>
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-emerald-500/70"
style:width={`${(stat.value / 255) * 100}%`}
></div>
</div>
</div>{/each}
</div>
<p class="mt-3 text-xs text-muted-foreground">
Total: {current.stats.reduce((sum, s) => sum + s.value, 0)} · default form, current catalog values
</p>
</section>
{#if current.facts}
<section class="mt-5">
<h3 class="font-semibold">Species research</h3>
<dl class="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-muted-foreground">Abilities</dt>
<dd>
{current.facts.abilities
.map((a) => `${a.name}${a.hidden ? ' (hidden)' : ''}`)
.join(', ')}
</dd>
</div>
<div>
<dt class="text-muted-foreground">Height / weight</dt>
<dd>{current.facts.height / 10} m · {current.facts.weight / 10} kg</dd>
</div>
<div>
<dt class="text-muted-foreground">Egg groups</dt>
<dd>{current.facts.eggGroups.join(', ')}</dd>
</div>
<div>
<dt class="text-muted-foreground">Growth rate</dt>
<dd>{current.facts.growthRate}</dd>
</div>
<div>
<dt class="text-muted-foreground">Catch rate / base friendship</dt>
<dd>{current.facts.captureRate} / {current.facts.baseHappiness ?? '—'}</dd>
</div>
<div>
<dt class="text-muted-foreground">EV yield</dt>
<dd>
{current.facts.evYield
.map((v, i) => (v ? `${v} ${current.stats[i]?.name}` : ''))
.filter(Boolean)
.join(', ') || 'None'}
</dd>
</div>
</dl>
</section>
{/if}
<section class="mt-6">
<h3 class="font-semibold">Moves in {current.entry?.game ?? 'this game'}</h3>
{#if current.learnset.length}
<div class="mt-3 grid grid-cols-2 gap-3">
<Input
type="search"
aria-label="Search moves"
placeholder="Search moves…"
bind:value={moveSearch}
/><NativeSelect aria-label="Learn method" class="w-full" bind:value={moveMethod}
><option value="all">All methods</option
>{#each [...new Set(current.learnset.map((m) => m.method))] as method (method)}<option
value={method}>{method}</option
>{/each}</NativeSelect
>
</div>
<div class="mt-3 max-h-72 overflow-auto">
<Table.Root
><Table.Header
><Table.Row
><Table.Head>Move</Table.Head><Table.Head>Learn</Table.Head><Table.Head
>Power</Table.Head
><Table.Head>Accuracy</Table.Head><Table.Head>PP</Table.Head></Table.Row
></Table.Header
><Table.Body
>{#each current.learnset.filter((m) => (moveMethod === 'all' || moveMethod === m.method) && `${m.name} ${m.type}`
.toLowerCase()
.includes(moveSearch.toLowerCase())) as move, i (`${move.id}-${move.method}-${move.level}-${i}`)}<Table.Row
><Table.Cell
><span class="font-medium">{move.name}</span><span
class="block text-xs text-muted-foreground">{move.type} · {move.category}</span
></Table.Cell
><Table.Cell>{move.method}{move.level ? ` · Lv. ${move.level}` : ''}</Table.Cell
><Table.Cell>{move.power ?? '—'}</Table.Cell><Table.Cell
>{move.accuracy === null ? '—' : `${move.accuracy}%`}</Table.Cell
><Table.Cell>{move.pp ?? '—'}</Table.Cell></Table.Row
>{/each}</Table.Body
></Table.Root
>
</div>
<p class="mt-2 text-xs text-muted-foreground">
Learning methods are game-specific. Move power, accuracy and PP use the current catalog
snapshot.
</p>
{:else}<p class="mt-2 text-sm text-muted-foreground">
No learnset is recorded for this game.
</p>{/if}
</section>
{#if current.entry}
<section class="mt-6">
<h3 class="font-semibold">in {current.entry.game}</h3>
<p class="mt-1">
<span class="font-medium">{availabilityLabels[current.entry.availability]}</span>
{#if current.entry.note}<span class="text-[var(--site-muted)]">
· {current.entry.note}</span
>{/if}
</p>
{#if current.entry.source === 'manual'}<p class="mt-1 text-sm text-[var(--site-muted)]">
corrected by hand
</p>{/if}
</section>
{:else if gameId}
<p class="mt-6 text-[var(--site-muted)]">no catalog record for this game.</p>
{/if}
{#if steps.length}
<section class="mt-6">
<h3 class="font-semibold">evolution</h3>
<ul class="mt-2">
{#each steps as step (step.number)}
<li class="border-t border-[var(--site-border)] py-2">
<span class:font-semibold={step.number === species.number}>{step.from} → {step.to}</span
>
<span class="block text-[var(--site-muted)]">
{step.methods.length ? step.methods.join(' or ') : 'not possible in this game'}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{#if current.encounters.length}
<section class="mt-6">
<h3 class="font-semibold">where to find it</h3>
<ul class="mt-2">
{#each current.encounters as encounter, index (index)}
<li class="border-t border-[var(--site-border)] py-2">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(encounter.locationId)
})}
class="underline underline-offset-4">{encounter.location}</a
>{#if encounter.area}<span class="text-[var(--site-muted)]">
· {encounter.area}</span
>{/if}
<span class="block text-[var(--site-muted)]">
{[
encounter.method,
levels(encounter.minLevel, encounter.maxLevel),
encounter.rarity ? `${encounter.rarity}%` : '',
encounter.form ?? '',
...encounter.conditions,
encounter.note ?? ''
]
.filter(Boolean)
.join(' · ')}
</span>
</li>
{/each}
</ul>
</section>
{:else if current.entry?.availability === 'catchable'}
<p class="mt-6 text-[var(--site-muted)]">no encounter details recorded.</p>
{/if}
<section class="mt-6">
<h3 class="font-semibold">other games</h3>
<p class="mt-1">
{obtainableGames.length
? `obtainable in ${obtainableGames.map((game) => game.game).join(', ')}`
: 'not obtainable in any game on record'}
</p>
{#if otherGames.length}<p class="mt-1 text-[var(--site-muted)]">
also present in {otherGames.map((game) => game.game).join(', ')}
</p>{/if}
</section>
{#if isAdmin && current.entry}
<form
method="POST"
action="?/saveEntry"
class="mt-8 border-t border-[var(--site-border)] pt-5"
use:enhance={() => {
formMessage = '';
return async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') {
formMessage = 'saved';
version++;
onsaved?.();
} else if (result.type === 'failure')
formMessage = String(result.data?.message ?? 'Could not save');
};
}}
>
<h3 class="font-semibold">correct this entry</h3>
<input type="hidden" name="entryId" value={current.entry.id} />
<div class="mt-3 grid gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
<label class="block"
>availability
<NativeSelect
name="availability"
value={current.entry.availability}
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each availabilityValues as value (value)}<option {value}
>{availabilityLabels[value]}</option
>{/each}
</NativeSelect>
</label>
<label class="block"
>note
<input
name="note"
value={current.entry.note ?? ''}
maxlength="500"
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
</div>
<label class="mt-3 flex items-center gap-2 text-[var(--site-muted)]"
><input type="checkbox" name="reset" /> let the catalog manage this entry again</label
>
<div class="mt-4 flex items-center gap-4">
<button class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>save</button
>
{#if formMessage}<p role="status" class="text-[var(--site-muted)]">{formMessage}</p>{/if}
</div>
</form>
{/if}
{/if}