Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
341 lines
12 KiB
Svelte
341 lines
12 KiB
Svelte
<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}
|