update homepage draft and other pages
This commit is contained in:
5
src/routes/projects/catch-em-all/+layout.server.ts
Normal file
5
src/routes/projects/catch-em-all/+layout.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user ?? null };
|
||||
};
|
||||
30
src/routes/projects/catch-em-all/+layout.svelte
Normal file
30
src/routes/projects/catch-em-all/+layout.svelte
Normal 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>
|
||||
110
src/routes/projects/catch-em-all/+page.server.ts
Normal file
110
src/routes/projects/catch-em-all/+page.server.ts
Normal 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;
|
||||
330
src/routes/projects/catch-em-all/+page.svelte
Normal file
330
src/routes/projects/catch-em-all/+page.svelte
Normal 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>
|
||||
1
src/routes/projects/catch-em-all/layout.css
Normal file
1
src/routes/projects/catch-em-all/layout.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
Reference in New Issue
Block a user