add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add Call of Duty tracking for Black Ops through WWII. - Pokémon catalog is now owned: locations, encounters and per-game evolution methods are built from the pinned PokéAPI snapshot and seeded. - Pokémon you can evolve or breed in a game count as obtainable; mythicals without another route are marked event-only. - Detail dialog with evolution chain and where to find it, location pages with admin notes and pictures, and admin corrections that survive reseeds. - Call of Duty weapons, milestones and Steam achievements per game, with derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers. - New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and deploy preflight checks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
225
src/lib/catch-em-all/PokemonDetails.svelte
Normal file
225
src/lib/catch-em-all/PokemonDetails.svelte
Normal file
@@ -0,0 +1,225 @@
|
||||
<script lang="ts">
|
||||
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="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{species.number}.png"
|
||||
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>
|
||||
|
||||
{#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
|
||||
<select
|
||||
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}
|
||||
</select>
|
||||
</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}
|
||||
Reference in New Issue
Block a user