Some checks failed
Test and deploy staging / staging (push) Has been cancelled
355 lines
12 KiB
Svelte
355 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { resolve } from '$app/paths';
|
||
import { deserialize, enhance } from '$app/forms';
|
||
import { availabilityValues, type Availability } from '$lib/availability';
|
||
import { SvelteSet } from 'svelte/reactivity';
|
||
import type { PageProps } from './$types';
|
||
import type { Game } from '$lib/server/db/schema';
|
||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||
|
||
type Entry = { id: number; availability: Availability; note: string | null };
|
||
let { data, form }: PageProps = $props();
|
||
const caught = new SvelteSet<number>();
|
||
const pending = new SvelteSet<number>();
|
||
let saveError = $state('');
|
||
$effect(() => {
|
||
caught.clear();
|
||
for (const id of data.caughtIds) caught.add(id);
|
||
});
|
||
|
||
async function toggleCaught(entryId: number, checked: boolean) {
|
||
if (pending.has(entryId)) return;
|
||
pending.add(entryId);
|
||
saveError = '';
|
||
if (checked) caught.add(entryId);
|
||
else caught.delete(entryId);
|
||
const body = new FormData();
|
||
body.set('entryId', String(entryId));
|
||
if (checked) body.set('caught', 'on');
|
||
let failed: boolean;
|
||
try {
|
||
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
|
||
const result = deserialize(await response.text());
|
||
failed = result.type !== 'success';
|
||
} catch {
|
||
failed = true;
|
||
}
|
||
pending.delete(entryId);
|
||
if (failed) {
|
||
saveError = 'Could not save your catch. Please try again, or log in again.';
|
||
if (checked) caught.delete(entryId);
|
||
else caught.add(entryId);
|
||
}
|
||
}
|
||
|
||
const entryMap = $derived(
|
||
new Map(
|
||
data.pokemon.map((pokemon) => [
|
||
pokemon.id,
|
||
new Map(
|
||
pokemon.entries.map(([id, gameId, code, note]) => [
|
||
gameId,
|
||
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
|
||
])
|
||
)
|
||
])
|
||
)
|
||
);
|
||
const selectedGameIds = $derived(new Set(data.selectedGameIds));
|
||
const visibleGames = $derived(
|
||
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
|
||
);
|
||
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
|
||
let viewGameId = $state<number | null>(null);
|
||
$effect(() => {
|
||
if (!visibleGames.some((game) => game.id === viewGameId))
|
||
viewGameId = visibleGames[0]?.id ?? null;
|
||
});
|
||
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
|
||
|
||
const catchableEntries = $derived(
|
||
data.pokemon
|
||
.flatMap((pokemon) => pokemon.entries)
|
||
.filter(
|
||
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
|
||
)
|
||
);
|
||
const total = $derived(catchableEntries.length);
|
||
const caughtCount = $derived(
|
||
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
|
||
);
|
||
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(1) : '0.0');
|
||
|
||
let search = $state('');
|
||
let pageNumber = $state(1);
|
||
const pageSize = 25;
|
||
const filteredPokemon = $derived(
|
||
data.pokemon.filter((pokemon) =>
|
||
`${pokemon.number} ${pokemon.name}`.toLowerCase().includes(search.trim().toLowerCase())
|
||
)
|
||
);
|
||
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
|
||
$effect(() => {
|
||
if (pageNumber > pageCount) pageNumber = pageCount;
|
||
});
|
||
const visiblePokemon = $derived(
|
||
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
|
||
);
|
||
const gamesByGeneration = $derived(
|
||
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
|
||
);
|
||
|
||
let rulesDialog: HTMLDialogElement;
|
||
const draft = new SvelteSet<number>();
|
||
function openRules() {
|
||
draft.clear();
|
||
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
|
||
draft.add(id);
|
||
rulesDialog.showModal();
|
||
}
|
||
function tooltip(entry: Entry | undefined, game: Game): string | null {
|
||
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
|
||
const reason = {
|
||
catchable: null,
|
||
trade_only: 'Only obtainable by trading in this game',
|
||
transfer_only: 'Only obtainable by transferring from another game',
|
||
event_only: 'Only obtainable through an event distribution',
|
||
unknown: 'Availability has not been confirmed'
|
||
}[entry.availability];
|
||
if (reason === null) return null;
|
||
return entry.note ? `${reason} (${entry.note})` : reason;
|
||
}
|
||
</script>
|
||
|
||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||
<div class="flex flex-wrap items-end justify-between gap-5">
|
||
<div>
|
||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
|
||
<p class="mt-2 text-[var(--site-muted)]">
|
||
{data.pokemon.length} Pokémon · {data.games.length} games
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onclick={openRules}
|
||
class="rounded border border-[var(--site-border)] px-4 py-2 hover:bg-[var(--site-hover)]"
|
||
>set rules</button
|
||
>
|
||
</div>
|
||
|
||
{#if !data.user}<p class="mt-6">
|
||
<a
|
||
href={resolve('/login?returnTo=/projects/catch-em-all')}
|
||
class="underline underline-offset-4">sign in</a
|
||
> to save your progress.
|
||
</p>{/if}
|
||
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
|
||
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
|
||
the Pokémon catalog has not been loaded yet.
|
||
</p>{/if}
|
||
|
||
<div class="mt-8 border-y border-[var(--site-border)] py-6">
|
||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||
<p class="font-medium">overall progress</p>
|
||
<p class="text-2xl font-semibold tabular-nums">{percentage}%</p>
|
||
</div>
|
||
<div class="mt-3">
|
||
<ProgressBar value={caughtCount} max={total} label="Overall catch progress" />
|
||
</div>
|
||
<p class="mt-2 text-base text-[var(--site-muted)]">{caughtCount} of {total} entries caught</p>
|
||
</div>
|
||
|
||
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||
<label class="block"
|
||
>search Pokémon
|
||
<input
|
||
type="search"
|
||
bind:value={search}
|
||
oninput={() => (pageNumber = 1)}
|
||
placeholder="name or number"
|
||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||
/>
|
||
</label>
|
||
<label class="block"
|
||
>game
|
||
<select
|
||
bind:value={viewGameId}
|
||
disabled={!visibleGames.length}
|
||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||
>
|
||
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
{#if viewGame}
|
||
<div class="mt-8">
|
||
<table class="w-full table-fixed border-collapse">
|
||
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
|
||
<thead
|
||
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
|
||
><th scope="col" class="py-3 font-medium">Pokémon</th><th
|
||
scope="col"
|
||
class="py-3 text-center font-medium">caught</th
|
||
></tr
|
||
></thead
|
||
>
|
||
<tbody>
|
||
{#each visiblePokemon as pokemon (pokemon.id)}
|
||
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
|
||
{@const reason = tooltip(entry, viewGame)}
|
||
<tr class="border-b border-[var(--site-border)]">
|
||
<th scope="row" class="py-2 text-left font-normal">
|
||
<div class="flex min-w-0 items-center gap-3">
|
||
<img
|
||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
||
alt=""
|
||
loading="lazy"
|
||
width="40"
|
||
height="40"
|
||
class="h-10 w-10 shrink-0"
|
||
/>
|
||
<span class="min-w-0"
|
||
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
|
||
>{pokemon.name}</span
|
||
>
|
||
</div>
|
||
</th>
|
||
<td class="py-2 text-center">
|
||
{#if entry && reason === null}
|
||
<input
|
||
type="checkbox"
|
||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||
checked={caught.has(entry.id)}
|
||
disabled={!data.user || pending.has(entry.id)}
|
||
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
|
||
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
|
||
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
|
||
/>
|
||
{:else}
|
||
<span
|
||
title={reason ?? undefined}
|
||
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
|
||
class="text-[var(--site-muted)]"
|
||
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
|
||
>
|
||
{/if}
|
||
</td>
|
||
</tr>
|
||
{:else}
|
||
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||
<p class="text-[var(--site-muted)]">
|
||
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
|
||
pageNumber * pageSize,
|
||
filteredPokemon.length
|
||
)} of {filteredPokemon.length}
|
||
</p>
|
||
<div class="flex items-center gap-4">
|
||
<button
|
||
type="button"
|
||
disabled={pageNumber === 1}
|
||
onclick={() => (pageNumber -= 1)}
|
||
class="disabled:opacity-40">previous</button
|
||
>
|
||
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
|
||
<button
|
||
type="button"
|
||
disabled={pageNumber === pageCount}
|
||
onclick={() => (pageNumber += 1)}
|
||
class="disabled:opacity-40">next</button
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else if data.pokemon.length}
|
||
<p class="mt-8 text-[var(--site-muted)]">no games selected. use “set rules” to choose one.</p>
|
||
{/if}
|
||
|
||
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
|
||
<summary class="cursor-pointer">about availability</summary>
|
||
<p class="mt-3 max-w-3xl">
|
||
checkboxes represent confirmed encounters from PokéAPI. “?” means the Pokémon appears in a
|
||
game catalog but its acquisition method is unconfirmed. “—” means there is no source record.
|
||
Neither proves it is unobtainable.
|
||
</p>
|
||
</details>
|
||
</div>
|
||
|
||
<dialog
|
||
bind:this={rulesDialog}
|
||
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||
>
|
||
<h2 class="text-2xl font-semibold">set rules</h2>
|
||
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
|
||
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
|
||
<div class="mt-5 flex gap-5">
|
||
<button
|
||
type="button"
|
||
onclick={() => {
|
||
draft.clear();
|
||
for (const game of data.games) draft.add(game.id);
|
||
}}
|
||
class="underline underline-offset-4">all</button
|
||
>
|
||
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
|
||
>none</button
|
||
>
|
||
</div>
|
||
<form
|
||
method="POST"
|
||
action="?/setGames"
|
||
use:enhance={() => {
|
||
return async ({ result, update }) => {
|
||
await update();
|
||
if (result.type === 'success') rulesDialog.close();
|
||
};
|
||
}}
|
||
>
|
||
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
|
||
{#each gamesByGeneration as [generation, games] (generation)}
|
||
<fieldset>
|
||
<legend class="mb-2 font-semibold"
|
||
><label class="flex items-center gap-2"
|
||
><input
|
||
type="checkbox"
|
||
checked={games.every((game) => draft.has(game.id))}
|
||
onchange={(event) => {
|
||
for (const game of games) {
|
||
if (event.currentTarget.checked) draft.add(game.id);
|
||
else draft.delete(game.id);
|
||
}
|
||
}}
|
||
/>generation {generation}</label
|
||
></legend
|
||
>
|
||
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
|
||
><input
|
||
type="checkbox"
|
||
name="gameIds"
|
||
value={game.id}
|
||
checked={draft.has(game.id)}
|
||
onchange={(event) => {
|
||
if (event.currentTarget.checked) draft.add(game.id);
|
||
else draft.delete(game.id);
|
||
}}
|
||
/>{game.name}</label
|
||
>{/each}
|
||
</fieldset>
|
||
{/each}
|
||
</div>
|
||
<div class="mt-6 flex justify-end gap-5">
|
||
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
|
||
<button
|
||
disabled={!data.user}
|
||
title={data.user ? undefined : 'Sign in to save rules'}
|
||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
|
||
>save</button
|
||
>
|
||
</div>
|
||
</form>
|
||
</dialog>
|