330 lines
10 KiB
Svelte
330 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { deserialize, enhance } from '$app/forms';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
import type { PageProps } from './$types';
|
|
import type { Game } from '$lib/server/db/schema';
|
|
|
|
type Entry = { id: number; availability: Availability; note: string | null };
|
|
|
|
let { data }: PageProps = $props();
|
|
|
|
const caught = new SvelteSet(data.caughtIds);
|
|
|
|
// the full table is ~1000 rows x ~40 games; only render the rows inside
|
|
// the scroll viewport (plus some overscan) to keep the DOM small
|
|
const ROW_HEIGHT = 37; // keep in sync with h-[37px] on the rows
|
|
const OVERSCAN = 10;
|
|
let scrollEl = $state<HTMLDivElement>();
|
|
let scrollTop = $state(0);
|
|
let viewportHeight = $state(600);
|
|
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN));
|
|
const endIndex = $derived(
|
|
Math.min(data.pokemon.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN)
|
|
);
|
|
const visiblePokemon = $derived(data.pokemon.slice(startIndex, endIndex));
|
|
|
|
// optimistic toggle without a <form> per cell — one shared handler instead
|
|
// of ~10k use:enhance initializations
|
|
async function toggleCaught(entryId: number, checked: boolean) {
|
|
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 = false;
|
|
try {
|
|
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
|
|
const result = deserialize(await response.text());
|
|
failed = result.type === 'failure' || result.type === 'error';
|
|
} catch {
|
|
failed = true;
|
|
}
|
|
if (failed) {
|
|
// server rejected the toggle, roll back the optimistic update
|
|
if (checked) caught.delete(entryId);
|
|
else caught.add(entryId);
|
|
}
|
|
}
|
|
|
|
// pokemonId -> gameId -> entry, for quick cell lookup
|
|
const entryMap = $derived(
|
|
new Map(
|
|
data.pokemon.map((pkmn) => [
|
|
pkmn.id,
|
|
new Map(
|
|
pkmn.entries.map(([id, gameId, code, note]) => [
|
|
gameId,
|
|
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
|
|
])
|
|
)
|
|
])
|
|
)
|
|
);
|
|
|
|
// empty selection = user hasn't set rules yet, show everything
|
|
const selectedGameIds = $derived(new Set(data.selectedGameIds));
|
|
const visibleGames = $derived(
|
|
selectedGameIds.size > 0 ? data.games.filter((g) => selectedGameIds.has(g.id)) : data.games
|
|
);
|
|
const visibleGameIds = $derived(new Set(visibleGames.map((g) => g.id)));
|
|
|
|
const catchableEntries = $derived(
|
|
data.pokemon
|
|
.flatMap((pkmn) => pkmn.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(2) : '0.00');
|
|
|
|
const gamesByGeneration = $derived(
|
|
[...Map.groupBy(data.games, (g) => g.generation)].sort(([a], [b]) => a - b)
|
|
);
|
|
|
|
// rules modal
|
|
let rulesDialog: HTMLDialogElement;
|
|
let draft = $state(new SvelteSet<number>());
|
|
|
|
function openRules() {
|
|
draft = new SvelteSet(selectedGameIds.size > 0 ? selectedGameIds : data.games.map((g) => g.id));
|
|
rulesDialog.showModal();
|
|
}
|
|
|
|
function tooltip(entry: Entry | undefined, game: Game): string | null {
|
|
if (!data.user) return 'Log in to track your progress';
|
|
if (!entry) return `Not available in ${game.name}`;
|
|
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'
|
|
}[entry.availability];
|
|
if (reason === null) return null;
|
|
return entry.note ? `${reason} (${entry.note})` : reason;
|
|
}
|
|
|
|
const typeColors: Record<string, string> = {
|
|
Normal: '#A8A77A',
|
|
Fire: '#EE8130',
|
|
Water: '#6390F0',
|
|
Electric: '#F7D02C',
|
|
Grass: '#7AC74C',
|
|
Ice: '#96D9D6',
|
|
Fighting: '#C22E28',
|
|
Poison: '#A33EA1',
|
|
Ground: '#E2BF65',
|
|
Flying: '#A98FF3',
|
|
Psychic: '#F95587',
|
|
Bug: '#A6B91A',
|
|
Rock: '#B6A136',
|
|
Ghost: '#735797',
|
|
Dragon: '#6F35FC',
|
|
Dark: '#705746',
|
|
Steel: '#B7B7CE',
|
|
Fairy: '#D685AD'
|
|
};
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>catchemall</title>
|
|
</svelte:head>
|
|
|
|
<div class="flex flex-col">
|
|
<h1 class="py-4 text-center text-2xl font-bold">Gotta catch 'em all!</h1>
|
|
</div>
|
|
|
|
<div class="flex justify-center">
|
|
<p class="max-w-200 py-6 text-center">
|
|
Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations
|
|
and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to
|
|
complete.
|
|
</p>
|
|
</div>
|
|
|
|
{#if !data.user}
|
|
<p class="pb-4 text-center text-sm text-gray-500">
|
|
<a href="/login" class="font-bold text-blue-500 underline">Log in</a> to save your progress.
|
|
</p>
|
|
{/if}
|
|
|
|
<div class="flex items-center justify-center gap-4 py-4">
|
|
<button
|
|
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
|
|
onclick={openRules}>Set rules</button
|
|
>
|
|
|
|
<p>{caughtCount}/{total} entries caught ({percentage} %)</p>
|
|
<progress value={total > 0 ? caughtCount / total : 0}></progress>
|
|
</div>
|
|
|
|
<div
|
|
bind:this={scrollEl}
|
|
bind:clientHeight={viewportHeight}
|
|
onscroll={() => (scrollTop = scrollEl?.scrollTop ?? 0)}
|
|
class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300"
|
|
>
|
|
<table class="min-w-full table-fixed border-separate border-spacing-0 text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
class="sticky top-0 left-0 z-30 w-90 border-r border-b border-gray-300 bg-white px-2 text-left"
|
|
>Pokemon</th
|
|
>
|
|
{#each visibleGames as game (game.id)}
|
|
<th
|
|
class="sticky top-0 z-20 w-9 border-b border-gray-300 bg-white px-1 pt-2 pb-1 align-bottom"
|
|
>
|
|
<span class="mx-auto rotate-180 [writing-mode:vertical-rl]">{game.name}</span>
|
|
</th>
|
|
{/each}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#if startIndex > 0}
|
|
<tr aria-hidden="true">
|
|
<td colspan={visibleGames.length + 1} style="height: {startIndex * ROW_HEIGHT}px"></td>
|
|
</tr>
|
|
{/if}
|
|
{#each visiblePokemon as pokemon (pokemon.id)}
|
|
<tr class="h-[37px] hover:bg-blue-50">
|
|
<th
|
|
class="sticky left-0 z-10 border-r border-b border-gray-200 bg-white px-2 py-0.5 text-left font-normal whitespace-nowrap"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<img
|
|
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
|
alt=""
|
|
loading="lazy"
|
|
class="h-8 w-8"
|
|
/>
|
|
<span class="min-w-10 font-bold">{pokemon.number}</span>
|
|
<span class="min-w-30">{pokemon.name}</span>
|
|
<span
|
|
class="rounded-lg px-2 py-0.5 text-xs"
|
|
style:background-color={typeColors[pokemon.type1]}>{pokemon.type1}</span
|
|
>
|
|
{#if pokemon.type2}
|
|
<span
|
|
class="rounded-lg px-2 py-0.5 text-xs"
|
|
style:background-color={typeColors[pokemon.type2]}>{pokemon.type2}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
</th>
|
|
{#each visibleGames as game (game.id)}
|
|
{@const entry = entryMap.get(pokemon.id)?.get(game.id)}
|
|
{@const reason = tooltip(entry, game)}
|
|
<td class="border-b border-gray-200 text-center">
|
|
{#if entry && reason === null}
|
|
<input
|
|
type="checkbox"
|
|
class="cursor-pointer"
|
|
checked={caught.has(entry.id)}
|
|
onchange={(e) => toggleCaught(entry.id, e.currentTarget.checked)}
|
|
/>
|
|
{:else}
|
|
<span title={reason} class="cursor-help">
|
|
<input type="checkbox" disabled checked={entry ? caught.has(entry.id) : false} />
|
|
</span>
|
|
{/if}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
{/each}
|
|
{#if endIndex < data.pokemon.length}
|
|
<tr aria-hidden="true">
|
|
<td
|
|
colspan={visibleGames.length + 1}
|
|
style="height: {(data.pokemon.length - endIndex) * ROW_HEIGHT}px"
|
|
></td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<dialog bind:this={rulesDialog} class="m-auto w-full max-w-2xl rounded-xl p-6 backdrop:bg-black/50">
|
|
<h2 class="pb-2 text-xl font-bold">Set rules</h2>
|
|
<p class="pb-4 text-sm text-gray-600">
|
|
Pick the games you want to catch 'em all in. Only those show up in the table and count towards
|
|
your progress.
|
|
</p>
|
|
|
|
<div class="flex gap-2 pb-4">
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
|
onclick={() => (draft = new SvelteSet(data.games.map((g) => g.id)))}>All games</button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
|
onclick={() => (draft = new SvelteSet())}>None</button
|
|
>
|
|
</div>
|
|
|
|
<form
|
|
method="POST"
|
|
action="?/setGames"
|
|
use:enhance={() => {
|
|
return async ({ result, update }) => {
|
|
await update();
|
|
if (result.type === 'success') rulesDialog.close();
|
|
};
|
|
}}
|
|
>
|
|
<div class="grid max-h-96 grid-cols-2 gap-x-6 overflow-y-auto sm:grid-cols-3">
|
|
{#each gamesByGeneration as [generation, games] (generation)}
|
|
<fieldset class="pb-3">
|
|
<legend class="flex items-center gap-2 pb-1 font-bold">
|
|
<input
|
|
type="checkbox"
|
|
checked={games.every((g) => draft.has(g.id))}
|
|
onchange={(e) => {
|
|
for (const g of games) {
|
|
if (e.currentTarget.checked) draft.add(g.id);
|
|
else draft.delete(g.id);
|
|
}
|
|
}}
|
|
/>
|
|
Gen {generation}
|
|
</legend>
|
|
{#each games as game (game.id)}
|
|
<label class="flex items-center gap-2 py-0.5 pl-4 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
name="gameIds"
|
|
value={game.id}
|
|
checked={draft.has(game.id)}
|
|
onchange={(e) => {
|
|
if (e.currentTarget.checked) draft.add(game.id);
|
|
else draft.delete(game.id);
|
|
}}
|
|
/>
|
|
{game.name}
|
|
</label>
|
|
{/each}
|
|
</fieldset>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2 pt-4">
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-2 font-bold hover:bg-gray-300"
|
|
onclick={() => rulesDialog.close()}>Cancel</button
|
|
>
|
|
<button
|
|
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
|
|
disabled={!data.user}
|
|
title={data.user ? undefined : 'Log in to save rules'}>Save</button
|
|
>
|
|
</div>
|
|
</form>
|
|
</dialog>
|