Files
website/src/routes/projects/game-tracker/pokemon/+page.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

193 lines
6.8 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 { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { chronologicalPokemon } from '$lib/game-tracker/game-art';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import PokemonJournal from '$lib/game-tracker/PokemonJournal.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let rulesOpen = $state(false);
let stripWidth = $state(1000);
const coverRows = $derived(visibleGames.length > Math.floor((stripWidth + 4) / 68) ? 2 : 1);
let saving = $state(false);
const draft = new SvelteSet<number>();
const visibleGames = $derived(
chronologicalPokemon(
data.gamesConfigured
? data.games.filter((g) => data.selectedGameIds.includes(g.id))
: data.games
)
);
const generations = $derived([...Map.groupBy(data.games, (g) => g.generation)]);
function openRules() {
draft.clear();
for (const game of data.games)
if (!data.gamesConfigured || data.selectedGameIds.includes(game.id)) draft.add(game.id);
rulesOpen = true;
}
</script>
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
<div
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 lg:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<div class="flex items-center gap-3">
{#if data.activeGame}<GameLogo
slug={`pokemon/${data.activeGame.slug}`}
name={data.activeGame.name}
class="h-12 w-12"
/>{/if}
<div>
<a
href={resolve('/projects/game-tracker')}
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
>
<h1 class="text-xl font-semibold">
Pokémon journal <span class="text-sm font-normal text-muted-foreground"
>· {data.activeGame?.name ?? 'Choose a game'}</span
>
</h1>
</div>
</div>
<div
class={`game-covers order-3 col-span-2 overflow-x-auto pb-1 ${visibleGames.length > 30 ? 'lg:col-span-3' : 'lg:order-none lg:col-span-1'}`}
bind:clientWidth={stripWidth}
role="group"
aria-label="Choose Pokémon game"
>
<div
class="grid gap-1"
style:grid-template-columns={`repeat(${Math.max(1, Math.ceil(visibleGames.length / coverRows))}, minmax(40px, 1fr))`}
style:max-width={`${Math.ceil(visibleGames.length / coverRows) * 64}px`}
>
{#each visibleGames as game (game.id)}
<Button
variant="ghost"
disabled={!hydrated}
aria-pressed={data.activeGame?.id === game.id}
aria-label={`Play ${game.name}`}
title={game.name}
class={`game-cover-button h-12 min-w-0 rounded-md border p-1 ${data.activeGame?.id === game.id ? 'border-amber-500 bg-amber-500/15 ring-1 ring-amber-500' : 'border-transparent opacity-65 hover:opacity-100'}`}
onclick={() =>
goto(resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`), {
noScroll: true,
keepFocus: true
})}
>
<GameLogo slug={`pokemon/${game.slug}`} name={game.name} class="h-10 w-full" />
</Button>
{/each}
</div>
</div>
<Button
class="justify-self-end lg:col-start-3"
variant="ghost"
size="sm"
disabled={!hydrated}
onclick={openRules}>set rules</Button
>
</div>
{#if !data.user}<p class="mt-4 text-sm">
<a href={resolve('/login?returnTo=/projects/game-tracker/pokemon')} class="underline"
>Sign in</a
> to save catches, teams and adventures.
</p>{/if}
{#if form?.message && !rulesOpen}<p role="alert" class="mt-4 text-destructive">
{form.message}
</p>{/if}
{#if data.activeGame}{#key data.activeGame.id}<PokemonJournal {data} />{/key}{:else}<p
class="mt-8 text-muted-foreground"
>
No games selected. Use “set rules” to choose your next adventure.
</p>{/if}
<p class="mt-4 text-[10px] leading-relaxed text-muted-foreground">
Species, regional Pokédexes, moves and encounters from <a
href="https://pokeapi.co/"
class="underline">PokéAPI</a
>. Encounter coverage varies by game; missing records do not prove that a Pokémon is
unavailable. Stats and move battle values describe the catalog’s default forms and current
snapshot. Maps identify their source and coverage.
</p>
</div>
<Dialog.Root bind:open={rulesOpen}>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header
><Dialog.Title>Set rules</Dialog.Title><Dialog.Description
>Choose the games in your collection. Your saved catches stay with each game.</Dialog.Description
></Dialog.Header
>
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
for (const game of data.games) draft.add(game.id);
}}>Select all</Button
><Button variant="ghost" size="sm" onclick={() => draft.clear()}>Clear selection</Button>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
await update();
saving = false;
if (result.type === 'success') rulesOpen = false;
};
}}
>
<div class="grid max-h-[55dvh] gap-6 overflow-auto pr-2 sm:grid-cols-2">
{#each generations as [generation, games] (generation)}
<fieldset>
<legend class="mb-3"
><label class="flex items-center gap-2 font-semibold"
><Checkbox
checked={games.every((g) => draft.has(g.id))}
indeterminate={games.some((g) => draft.has(g.id)) &&
!games.every((g) => draft.has(g.id))}
onCheckedChange={(checked) => {
for (const g of games)
if (checked) draft.add(g.id);
else draft.delete(g.id);
}}
/>Generation {generation}</label
></legend
>
<div class="space-y-2">
{#each games as g (g.id)}<label class="flex items-center gap-3 rounded-lg border p-2"
><Checkbox
name="gameIds"
value={String(g.id)}
checked={draft.has(g.id)}
onCheckedChange={(checked) => (checked ? draft.add(g.id) : draft.delete(g.id))}
/><GameLogo slug={`pokemon/${g.slug}`} name={g.name} class="h-8 w-16" /><span
class="text-sm">{g.name}</span
></label
>{/each}
</div>
</fieldset>{/each}
</div>
<Dialog.Footer class="mt-6"
><Button variant="outline" onclick={() => (rulesOpen = false)}>Cancel</Button><Button
type="submit"
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save rules'}</Button
></Dialog.Footer
>
</form>
</Dialog.Content>
</Dialog.Root>