Files
website/src/lib/catch-em-all/PokemonDetails.svelte
Stan Runge 54924b39f0
Some checks failed
Test and deploy staging / staging (push) Failing after 43s
feat: upgrade to SvelteKit 3 and adapter-bun
Run the sveltekit-3 migration and finish its manual tasks:

- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path

Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:30 +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.js';
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.js';
import type { PokemonDetail } from '#lib/server/pokemon-detail.js';
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}