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