update homepage draft and other pages

This commit is contained in:
2026-08-29 11:38:16 +00:00
parent 6452331ee7
commit 97ff9852cd
38 changed files with 2185 additions and 111 deletions

View File

@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return { user: locals.user ?? null };
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import './layout.css';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
</script>
<div class="flex min-h-screen flex-col">
<header class="flex justify-center border-b border-gray-200 px-6 py-3">
<div class="flex items-center gap-4">
{#if data.user}
<span class="text-sm text-gray-600">Logged in as {data.user.name}</span>
<form method="POST" action="/logout">
<button
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-1.5 text-sm font-bold hover:bg-gray-300"
>Log out</button
>
</form>
{:else}
<a
href="/login"
class="rounded-lg bg-blue-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-blue-600"
>Log in</a
>
{/if}
</div>
</header>
<main class="flex-1 p-4">{@render children()}</main>
</div>

View File

@@ -0,0 +1,110 @@
import { db } from '$lib/server/db';
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
import { availabilityValues } from '$lib/availability';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './catchemall/$types';
import { and, eq, inArray } from 'drizzle-orm';
// compact wire format for the ~19k game entries — full objects with repeated
// keys and availability strings add megabytes to the page payload
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => {
const pokemonRows = await db.query.pokemon.findMany({
with: {
entry: true
},
orderBy: (pokemon) => pokemon.number
});
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
...rest,
entries: entry.map((e): WireEntry => {
const code = availabilityValues.indexOf(e.availability);
return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
})
}));
const games = await db.query.game.findMany({
orderBy: (game) => [game.generation, game.id]
});
let caughtIds: number[] = [];
let selectedGameIds: number[] = [];
if (locals.user) {
const caughtRows = await db
.select({ id: userEntry.pokemonGameEntryId })
.from(userEntry)
.where(eq(userEntry.userId, locals.user.id));
caughtIds = caughtRows.map((r) => r.id);
const gameRows = await db
.select({ id: userGame.gameId })
.from(userGame)
.where(eq(userGame.userId, locals.user.id));
selectedGameIds = gameRows.map((r) => r.id);
}
return { pokemon, games, caughtIds, selectedGameIds };
};
export const actions = {
togglePokemonCaught: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const data = await request.formData();
const entryId = Number(data.get('entryId'));
if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' });
// only catchable entries can be toggled — the client disables these
// checkboxes but we can't trust that
const [entry] = await db
.select({ availability: pokemonGameEntry.availability })
.from(pokemonGameEntry)
.where(eq(pokemonGameEntry.id, entryId));
if (!entry) return fail(404, { message: 'Unknown entry' });
if (entry.availability !== 'catchable') {
return fail(400, { message: 'This pokemon is not catchable in this game' });
}
const caught = data.get('caught') === 'on';
if (caught) {
await db
.insert(userEntry)
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntry)
.where(
and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id))
);
}
},
setGames: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to set rules' });
const data = await request.formData();
const gameIds = data
.getAll('gameIds')
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0);
if (gameIds.length > 0) {
const validGames = await db
.select({ id: game.id })
.from(game)
.where(inArray(game.id, gameIds));
if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' });
}
const userId = locals.user.id;
await db.transaction(async (tx) => {
await tx.delete(userGame).where(eq(userGame.userId, userId));
if (gameIds.length > 0) {
await tx.insert(userGame).values(gameIds.map((gameId) => ({ userId, gameId })));
}
});
}
} satisfies Actions;

View File

@@ -0,0 +1,330 @@
<script lang="ts">
import { deserialize, enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import { availabilityValues, type Availability } from '$lib/availability';
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>

View File

@@ -0,0 +1 @@
@import 'tailwindcss';