add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add Call of Duty tracking for Black Ops through WWII. - Pokémon catalog is now owned: locations, encounters and per-game evolution methods are built from the pinned PokéAPI snapshot and seeded. - Pokémon you can evolve or breed in a game count as obtainable; mythicals without another route are marked event-only. - Detail dialog with evolution chain and where to find it, location pages with admin notes and pictures, and admin corrections that survive reseeds. - Call of Duty weapons, milestones and Steam achievements per game, with derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers. - New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and deploy preflight checks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
116
src/routes/projects/game-tracker/+page.server.ts
Normal file
116
src/routes/projects/game-tracker/+page.server.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
catchEmAllSettings,
|
||||
pokemonGameEntries,
|
||||
trackerGames,
|
||||
trackerItems,
|
||||
trackerProgress,
|
||||
userEntries,
|
||||
userGames
|
||||
} from '$lib/server/db/schema';
|
||||
import { summarizeGame } from '$lib/game-tracker/mastery';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
|
||||
|
||||
async function pokemonSummary(userId: string | undefined) {
|
||||
let gameIds: number[] | null = null;
|
||||
if (userId) {
|
||||
const [settings] = await db
|
||||
.select({ id: catchEmAllSettings.id })
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, userId));
|
||||
const selected = await db
|
||||
.select({ id: userGames.gameId })
|
||||
.from(userGames)
|
||||
.where(eq(userGames.userId, userId));
|
||||
if (settings || selected.length) gameIds = selected.map((row) => row.id);
|
||||
}
|
||||
const scope =
|
||||
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
|
||||
const [totals] = await db
|
||||
.select({
|
||||
entries: sql<number>`count(*)`.mapWith(Number),
|
||||
species: countDistinct(pokemonGameEntries.pokemonId),
|
||||
games: countDistinct(pokemonGameEntries.gameId)
|
||||
})
|
||||
.from(pokemonGameEntries)
|
||||
.where(scope);
|
||||
const [caught] = userId
|
||||
? await db
|
||||
.select({
|
||||
entries: sql<number>`count(*)`.mapWith(Number),
|
||||
species: countDistinct(pokemonGameEntries.pokemonId)
|
||||
})
|
||||
.from(userEntries)
|
||||
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
|
||||
.where(and(eq(userEntries.userId, userId), scope))
|
||||
: [{ entries: 0, species: 0 }];
|
||||
return { totals, caught };
|
||||
}
|
||||
|
||||
async function callOfDutySummary(userId: string | undefined) {
|
||||
const [gamesList, items, progress] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(trackerGames)
|
||||
.where(eq(trackerGames.franchise, 'call-of-duty'))
|
||||
.orderBy(asc(trackerGames.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
id: trackerItems.id,
|
||||
gameId: trackerItems.gameId,
|
||||
kind: trackerItems.kind,
|
||||
name: trackerItems.name,
|
||||
category: trackerItems.category,
|
||||
postLaunch: trackerItems.postLaunch
|
||||
})
|
||||
.from(trackerItems),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
|
||||
.from(trackerProgress)
|
||||
.where(eq(trackerProgress.userId, userId))
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
|
||||
const byGame = Map.groupBy(items, (item) => item.gameId);
|
||||
return gamesList.map((game) => {
|
||||
const list = byGame.get(game.id) ?? [];
|
||||
const summary = summarizeGame(
|
||||
game.rules,
|
||||
list.filter((item) => item.kind === 'weapon'),
|
||||
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
|
||||
(id, milestone) => done.has(`${id}:${milestone}`)
|
||||
);
|
||||
return {
|
||||
slug: game.slug,
|
||||
name: game.shortName,
|
||||
releaseYear: game.releaseYear,
|
||||
achievements: summary.achievements,
|
||||
milestones: summary.milestones.map(({ key, label, value, total }) => ({
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
total
|
||||
})),
|
||||
tiers: summary.tiers.map(({ key, label, scope, value, total, achieved }) => ({
|
||||
key,
|
||||
label,
|
||||
scope,
|
||||
value,
|
||||
total,
|
||||
achieved
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const [pokemon, callOfDuty] = await Promise.all([
|
||||
pokemonSummary(locals.user?.id),
|
||||
callOfDutySummary(locals.user?.id)
|
||||
]);
|
||||
return { pokemon, callOfDuty };
|
||||
};
|
||||
101
src/routes/projects/game-tracker/+page.svelte
Normal file
101
src/routes/projects/game-tracker/+page.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
|
||||
Call of Duty.
|
||||
</p>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
href={resolve('/login?returnTo=/projects/game-tracker')}
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-semibold">Pokémon</h2>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon')}
|
||||
class="mt-4 block rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
|
||||
>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<p class="text-xl font-semibold">catchemall</p>
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar
|
||||
value={data.pokemon.caught.entries}
|
||||
max={data.pokemon.totals.entries}
|
||||
label="Pokémon caught"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
|
||||
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
|
||||
.totals.games} games
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon/locations')}
|
||||
class="mt-3 inline-block underline underline-offset-4">browse Pokémon locations</a
|
||||
>
|
||||
</section>
|
||||
|
||||
<section class="mt-12">
|
||||
<h2 class="text-2xl font-semibold">Call of Duty</h2>
|
||||
<ul class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{#each data.callOfDuty as game (game.slug)}
|
||||
<li>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
|
||||
class="block h-full rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
|
||||
>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<p class="text-xl font-semibold">{game.name}</p>
|
||||
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
|
||||
</div>
|
||||
<dl class="mt-4 grid gap-3">
|
||||
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
|
||||
<div>
|
||||
<div class="flex justify-between gap-3 text-[var(--site-muted)]">
|
||||
<dt>{row.label}</dt>
|
||||
<dd class="tabular-nums">{row.value}/{row.total}</dd>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<ProgressBar value={row.value} max={row.total} label={row.label} small />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if game.tiers.length}
|
||||
<p class="mt-4 text-[var(--site-muted)]">
|
||||
{game.tiers
|
||||
.map((tier) =>
|
||||
tier.scope === 'class'
|
||||
? `${tier.label} ${tier.value}/${tier.total} classes`
|
||||
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
|
||||
)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
|
||||
import { appliesTo } from '$lib/game-tracker/mastery';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function findGame(slug: string) {
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(trackerGames)
|
||||
.where(and(eq(trackerGames.slug, slug), eq(trackerGames.franchise, 'call-of-duty')));
|
||||
if (!game) error(404, 'Game not found');
|
||||
return game;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const game = await findGame(params.slug);
|
||||
const items = await db
|
||||
.select({
|
||||
id: trackerItems.id,
|
||||
kind: trackerItems.kind,
|
||||
category: trackerItems.category,
|
||||
name: trackerItems.name,
|
||||
description: trackerItems.description,
|
||||
icon: trackerItems.icon,
|
||||
postLaunch: trackerItems.postLaunch
|
||||
})
|
||||
.from(trackerItems)
|
||||
.where(eq(trackerItems.gameId, game.id))
|
||||
.orderBy(asc(trackerItems.kind), asc(trackerItems.sortOrder));
|
||||
const progress = locals.user
|
||||
? await db
|
||||
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
|
||||
.from(trackerProgress)
|
||||
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
|
||||
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
|
||||
: [];
|
||||
return {
|
||||
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
|
||||
items,
|
||||
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
|
||||
seo: {
|
||||
title: `${game.shortName} · Game Tracker`,
|
||||
description: `Track ${game.name} achievements, weapon levels and camo mastery.`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
toggle: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
|
||||
const game = await findGame(params.slug);
|
||||
const data = await request.formData();
|
||||
const itemId = Number(data.get('itemId'));
|
||||
const milestone = String(data.get('milestone') ?? '');
|
||||
if (!Number.isSafeInteger(itemId) || itemId <= 0 || itemId > 2147483647)
|
||||
return fail(400, { message: 'Invalid item' });
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(trackerItems)
|
||||
.where(and(eq(trackerItems.id, itemId), eq(trackerItems.gameId, game.id)));
|
||||
if (!item) return fail(404, { message: 'Unknown item' });
|
||||
const valid =
|
||||
item.kind === 'achievement'
|
||||
? milestone === 'done'
|
||||
: game.rules.milestones.some(
|
||||
(rule) =>
|
||||
rule.key === milestone &&
|
||||
appliesTo(rule, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
postLaunch: item.postLaunch
|
||||
})
|
||||
);
|
||||
if (!valid) return fail(400, { message: 'That milestone does not apply here' });
|
||||
if (data.has('done') && data.get('done') !== 'on')
|
||||
return fail(400, { message: 'Invalid state' });
|
||||
|
||||
if (data.get('done') === 'on') {
|
||||
await db
|
||||
.insert(trackerProgress)
|
||||
.values({ userId: locals.user.id, itemId, milestone })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(trackerProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerProgress.userId, locals.user.id),
|
||||
eq(trackerProgress.itemId, itemId),
|
||||
eq(trackerProgress.milestone, milestone)
|
||||
)
|
||||
);
|
||||
}
|
||||
return { saved: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
// rebuilt whenever the page data changes, so the server render already shows progress
|
||||
const done = $derived(new SvelteSet(data.done));
|
||||
const pending = new SvelteSet<string>();
|
||||
let saveError = $state('');
|
||||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||||
let hydrated = $state(false);
|
||||
onMount(() => (hydrated = true));
|
||||
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
|
||||
|
||||
async function toggle(itemId: number, milestone: string, checked: boolean) {
|
||||
const key = `${itemId}:${milestone}`;
|
||||
if (pending.has(key)) return;
|
||||
pending.add(key);
|
||||
saveError = '';
|
||||
if (checked) done.add(key);
|
||||
else done.delete(key);
|
||||
const body = new FormData();
|
||||
body.set('itemId', String(itemId));
|
||||
body.set('milestone', milestone);
|
||||
if (checked) body.set('done', 'on');
|
||||
let failed: boolean;
|
||||
try {
|
||||
const response = await fetch('?/toggle', { method: 'POST', body });
|
||||
failed = deserialize(await response.text()).type !== 'success';
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
pending.delete(key);
|
||||
if (failed) {
|
||||
saveError = 'Could not save your progress. Please try again, or log in again.';
|
||||
if (checked) done.delete(key);
|
||||
else done.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const rules = $derived(data.game.rules);
|
||||
const weapons = $derived(data.items.filter((item) => item.kind === 'weapon'));
|
||||
const achievements = $derived(data.items.filter((item) => item.kind === 'achievement'));
|
||||
const summary = $derived(
|
||||
summarizeGame(
|
||||
rules,
|
||||
weapons,
|
||||
achievements.map((item) => item.id),
|
||||
isDone
|
||||
)
|
||||
);
|
||||
type Bar = {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: number;
|
||||
total: number;
|
||||
achieved: boolean;
|
||||
};
|
||||
const bars = $derived<Bar[]>([
|
||||
{ label: 'achievements', ...summary.achievements },
|
||||
...summary.milestones,
|
||||
...summary.tiers.map((tier) => ({
|
||||
...tier,
|
||||
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
|
||||
}))
|
||||
]);
|
||||
const classes = $derived(
|
||||
rules.classes
|
||||
.map((name) => {
|
||||
const list = weapons.filter((weapon) => weapon.category === name);
|
||||
const milestones = rules.milestones.filter((milestone) =>
|
||||
list.some((weapon) => appliesTo(milestone, weapon))
|
||||
);
|
||||
const tiers = rules.tiers
|
||||
.filter((tier) => tier.scope === 'class')
|
||||
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
|
||||
.filter((entry) => entry.count);
|
||||
return { name, list, milestones, tiers };
|
||||
})
|
||||
.filter((entry) => entry.list.length)
|
||||
);
|
||||
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
|
||||
|
||||
const tabs = ['weapons', 'achievements'] as const;
|
||||
let tab = $state<(typeof tabs)[number]>('weapons');
|
||||
let hideDone = $state(false);
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
|
||||
>
|
||||
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{weapons.length} weapons · {achievements.length} achievements
|
||||
</p>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
href={resolve(`/login?returnTo=/projects/game-tracker/call-of-duty/${data.game.slug}`)}
|
||||
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}
|
||||
|
||||
<div
|
||||
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{#each bars as bar (bar.label)}
|
||||
<div class="min-w-0" title={bar.description}>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">{bar.label}</p>
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
|
||||
</div>
|
||||
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<div role="tablist" class="flex gap-6 text-lg">
|
||||
{#each tabs as name (name)}
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={tab === name}
|
||||
onclick={() => (tab = name)}
|
||||
class={tab === name
|
||||
? 'underline underline-offset-8'
|
||||
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{name}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
|
||||
hide finished
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if tab === 'weapons'}
|
||||
{#if rules.tiers.length || rules.milestones.length}
|
||||
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
|
||||
{[...rules.milestones, ...rules.tiers]
|
||||
.filter((rule) => rule.description)
|
||||
.map((rule) => `${rule.label}: ${rule.description}`)
|
||||
.join('. ')}. DLC weapons don’t count toward class or overall camos.
|
||||
</p>
|
||||
{/if}
|
||||
{#each classes as group (group.name)}
|
||||
{@const rows = group.list.filter(
|
||||
(weapon) =>
|
||||
!hideDone ||
|
||||
group.milestones.some(
|
||||
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
|
||||
)
|
||||
)}
|
||||
<section class="mt-10">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class="text-2xl font-semibold">{group.name}</h2>
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{#each group.tiers as { tier, count } (tier.key)}
|
||||
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
|
||||
>{tier.label}
|
||||
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
{#if rows.length}
|
||||
<table class="mt-3 w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
|
||||
<th scope="col" class="py-2 font-medium">weapon</th>
|
||||
{#each group.milestones as milestone (milestone.key)}<th
|
||||
scope="col"
|
||||
class="w-20 py-2 text-center font-medium sm:w-28"
|
||||
title={milestone.description}>{milestone.label}</th
|
||||
>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as weapon (weapon.id)}
|
||||
{@const tiers = summary.mastery.weaponTiers(weapon)}
|
||||
<tr class="border-b border-[var(--site-border)]">
|
||||
<th scope="row" class="py-2 text-left font-normal">
|
||||
{weapon.name}
|
||||
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
|
||||
>DLC</span
|
||||
>{/if}
|
||||
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
|
||||
>{tiers.map((tier) => tier.label).join(' · ')}</span
|
||||
>{/if}
|
||||
</th>
|
||||
{#each group.milestones as milestone (milestone.key)}
|
||||
<td class="py-2 text-center">
|
||||
{#if appliesTo(milestone, weapon)}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={isDone(weapon.id, milestone.key)}
|
||||
disabled={!hydrated ||
|
||||
!data.user ||
|
||||
pending.has(`${weapon.id}:${milestone.key}`)}
|
||||
aria-label={`${weapon.name}: ${milestone.label}`}
|
||||
title={data.user
|
||||
? milestone.description
|
||||
: 'Sign in to save your progress'}
|
||||
onchange={(event) =>
|
||||
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
|
||||
/>
|
||||
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each categories as [category, list] (category)}
|
||||
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
|
||||
<section class="mt-10">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class="text-2xl font-semibold">{category}</h2>
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="mt-3">
|
||||
{#each rows as item (item.id)}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
<label class="flex cursor-pointer items-center gap-4 py-3">
|
||||
{#if item.icon}<img
|
||||
src={item.icon}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="48"
|
||||
height="48"
|
||||
class="h-12 w-12 shrink-0 rounded"
|
||||
class:opacity-40={!isDone(item.id, 'done')}
|
||||
/>{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block font-medium">{item.name}</span>
|
||||
{#if item.description}<span class="block text-[var(--site-muted)]"
|
||||
>{item.description}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={isDone(item.id, 'done')}
|
||||
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
|
||||
aria-label={`Unlocked ${item.name}`}
|
||||
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="border-t border-[var(--site-border)] py-3 text-[var(--site-muted)]">
|
||||
all unlocked.
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
184
src/routes/projects/game-tracker/pokemon/+page.server.ts
Normal file
184
src/routes/projects/game-tracker/pokemon/+page.server.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
games,
|
||||
pokemon as pokemonTable,
|
||||
pokemonGameEntries,
|
||||
userEntries,
|
||||
userGames,
|
||||
catchEmAllSettings
|
||||
} from '$lib/server/db/schema';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { availabilityValues, obtainableAvailability, type Availability } from '$lib/availability';
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const [pokemonRows, entryRows, gameRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: pokemonTable.id,
|
||||
number: pokemonTable.number,
|
||||
name: pokemonTable.name
|
||||
})
|
||||
.from(pokemonTable)
|
||||
.orderBy(asc(pokemonTable.number)),
|
||||
db
|
||||
.select({
|
||||
id: pokemonGameEntries.id,
|
||||
pokemonId: pokemonGameEntries.pokemonId,
|
||||
gameId: pokemonGameEntries.gameId,
|
||||
availability: pokemonGameEntries.availability,
|
||||
note: pokemonGameEntries.note
|
||||
})
|
||||
.from(pokemonGameEntries),
|
||||
db
|
||||
.select({ id: games.id, name: games.name, generation: games.generation })
|
||||
.from(games)
|
||||
.orderBy(asc(games.generation), asc(games.id))
|
||||
]);
|
||||
const entries = Map.groupBy(entryRows, (entry) => entry.pokemonId);
|
||||
const pokemon = pokemonRows.map((species) => ({
|
||||
...species,
|
||||
entries: (entries.get(species.id) ?? []).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];
|
||||
})
|
||||
}));
|
||||
|
||||
let caughtIds: number[] = [];
|
||||
let selectedGameIds: number[] = [];
|
||||
let gamesConfigured = false;
|
||||
if (locals.user) {
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, locals.user.id));
|
||||
gamesConfigured = !!settings;
|
||||
const caughtRows = await db
|
||||
.select({ id: userEntries.pokemonGameEntryId })
|
||||
.from(userEntries)
|
||||
.where(eq(userEntries.userId, locals.user.id));
|
||||
caughtIds = caughtRows.map((r) => r.id);
|
||||
|
||||
const gameRows = await db
|
||||
.select({ id: userGames.gameId })
|
||||
.from(userGames)
|
||||
.where(eq(userGames.userId, locals.user.id));
|
||||
selectedGameIds = gameRows.map((r) => r.id);
|
||||
}
|
||||
|
||||
return {
|
||||
pokemon,
|
||||
games: gameRows,
|
||||
caughtIds,
|
||||
selectedGameIds,
|
||||
gamesConfigured: gamesConfigured || selectedGameIds.length > 0
|
||||
};
|
||||
};
|
||||
|
||||
function entryId(data: FormData) {
|
||||
const id = Number(data.get('entryId'));
|
||||
return Number.isSafeInteger(id) && id > 0 && id <= 2147483647 ? id : null;
|
||||
}
|
||||
|
||||
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 id = entryId(data);
|
||||
if (!id) return fail(400, { message: 'Invalid entry' });
|
||||
|
||||
// only obtainable entries can be toggled — the client disables the others
|
||||
// but we can't trust that
|
||||
const [entry] = await db
|
||||
.select({ availability: pokemonGameEntries.availability })
|
||||
.from(pokemonGameEntries)
|
||||
.where(eq(pokemonGameEntries.id, id));
|
||||
if (!entry) return fail(404, { message: 'Unknown entry' });
|
||||
if (!obtainableAvailability.has(entry.availability)) {
|
||||
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
|
||||
}
|
||||
|
||||
if (data.has('caught') && data.get('caught') !== 'on')
|
||||
return fail(400, { message: 'Invalid caught state' });
|
||||
const caught = data.get('caught') === 'on';
|
||||
|
||||
if (caught) {
|
||||
await db
|
||||
.insert(userEntries)
|
||||
.values({ pokemonGameEntryId: id, userId: locals.user.id })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(userEntries)
|
||||
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
|
||||
}
|
||||
return { saved: true };
|
||||
},
|
||||
|
||||
setGames: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to set rules' });
|
||||
|
||||
const data = await request.formData();
|
||||
const submittedIds = data.getAll('gameIds').map(Number);
|
||||
if (
|
||||
submittedIds.length > 200 ||
|
||||
submittedIds.some((id) => !Number.isSafeInteger(id) || id <= 0)
|
||||
) {
|
||||
return fail(400, { message: 'Invalid game selection' });
|
||||
}
|
||||
const gameIds = [...new Set(submittedIds)];
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
const validGames = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(inArray(games.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
|
||||
.insert(catchEmAllSettings)
|
||||
.values({ userId })
|
||||
.onConflictDoUpdate({ target: catchEmAllSettings.userId, set: { userId } });
|
||||
await tx.delete(userGames).where(eq(userGames.userId, userId));
|
||||
if (gameIds.length > 0) {
|
||||
await tx.insert(userGames).values(gameIds.map((gameId) => ({ userId, gameId })));
|
||||
}
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
|
||||
// Admin correction of how a Pokémon is obtained in one game. Manual entries
|
||||
// survive reseeding; "reset" hands the entry back to the catalog.
|
||||
saveEntry: async (event) => {
|
||||
requireAdmin(event);
|
||||
const data = await event.request.formData();
|
||||
const id = entryId(data);
|
||||
if (!id) return fail(400, { message: 'Invalid entry' });
|
||||
if (data.get('reset') === 'on') {
|
||||
await db
|
||||
.update(pokemonGameEntries)
|
||||
.set({ source: 'pokeapi' })
|
||||
.where(eq(pokemonGameEntries.id, id));
|
||||
return { saved: true, message: 'The catalog will restore this entry on the next seed.' };
|
||||
}
|
||||
const availability = String(data.get('availability'));
|
||||
if (!(availabilityValues as readonly string[]).includes(availability))
|
||||
return fail(400, { message: 'Invalid availability' });
|
||||
const note = String(data.get('note') ?? '').trim();
|
||||
if (note.length > 500) return fail(400, { message: 'Keep the note under 500 characters' });
|
||||
const [updated] = await db
|
||||
.update(pokemonGameEntries)
|
||||
.set({ availability: availability as Availability, note: note || null, source: 'manual' })
|
||||
.where(eq(pokemonGameEntries.id, id))
|
||||
.returning({ id: pokemonGameEntries.id });
|
||||
if (!updated) return fail(404, { message: 'Unknown entry' });
|
||||
return { saved: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
475
src/routes/projects/game-tracker/pokemon/+page.svelte
Normal file
475
src/routes/projects/game-tracker/pokemon/+page.svelte
Normal file
@@ -0,0 +1,475 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
availabilityLabels,
|
||||
availabilityValues,
|
||||
obtainableAvailability,
|
||||
type Availability
|
||||
} from '$lib/availability';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageProps } from './$types';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
|
||||
import { summarizeProgress } from '$lib/catch-em-all/progress';
|
||||
|
||||
type Entry = { id: number; availability: Availability; note: string | null };
|
||||
type Game = { id: number; name: string };
|
||||
let { data, form }: PageProps = $props();
|
||||
// rebuilt whenever the page data changes, so the server render already shows progress
|
||||
const caught = $derived(new SvelteSet(data.caughtIds));
|
||||
const pending = new SvelteSet<number>();
|
||||
let saveError = $state('');
|
||||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||||
let hydrated = $state(false);
|
||||
onMount(() => (hydrated = true));
|
||||
|
||||
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
|
||||
);
|
||||
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 visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
|
||||
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
|
||||
const progressBars = $derived([
|
||||
{
|
||||
label: 'seen at least once',
|
||||
value: progress.seenPokemon,
|
||||
total: progress.catchablePokemon,
|
||||
unit: 'Pokémon'
|
||||
},
|
||||
{
|
||||
label: 'caught in every game it’s in',
|
||||
value: progress.completedPokemon,
|
||||
total: progress.catchablePokemon,
|
||||
unit: 'Pokémon'
|
||||
},
|
||||
{
|
||||
label: 'games completed',
|
||||
value: progress.completedGames,
|
||||
total: progress.catchableGames,
|
||||
unit: 'games'
|
||||
}
|
||||
]);
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let showUnobtainable = $state(false);
|
||||
let pageNumber = $state(1);
|
||||
const pageSize = 25;
|
||||
const filteredPokemon = $derived(
|
||||
data.pokemon.filter((pokemon) => {
|
||||
const availability =
|
||||
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
|
||||
if (
|
||||
!showUnobtainable &&
|
||||
viewGameId !== null &&
|
||||
!(availability && obtainableAvailability.has(availability))
|
||||
)
|
||||
return false;
|
||||
return `${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`;
|
||||
if (obtainableAvailability.has(entry.availability)) return null;
|
||||
const reason = {
|
||||
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 as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
|
||||
return entry.note ? `${reason} (${entry.note})` : reason;
|
||||
}
|
||||
|
||||
let detailsDialog: HTMLDialogElement;
|
||||
let detailNumber = $state<number | null>(null);
|
||||
function openDetails(number: number) {
|
||||
detailNumber = number;
|
||||
detailsDialog.showModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
|
||||
>
|
||||
<div class="mt-3 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/game-tracker/pokemon')}
|
||||
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">
|
||||
{percent(progress.caughtEntries, progress.catchableEntries)}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar
|
||||
value={progress.caughtEntries}
|
||||
max={progress.catchableEntries}
|
||||
label="Overall catch progress"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
|
||||
</p>
|
||||
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
|
||||
{#each progressBars as item (item.label)}
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">{item.label}</p>
|
||||
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={item.value} max={item.total} label={item.label} small />
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{item.value} of {item.total}
|
||||
{item.unit}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-6 text-base text-[var(--site-muted)]">
|
||||
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
|
||||
</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}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
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>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnobtainable}
|
||||
onchange={(event) => {
|
||||
showUnobtainable = event.currentTarget.checked;
|
||||
pageNumber = 1;
|
||||
}}
|
||||
class="h-5 w-5 accent-[var(--site-text)]"
|
||||
/>
|
||||
show Pokémon you can’t get in this game
|
||||
</label>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon/locations')}
|
||||
class="underline underline-offset-4">browse locations</a
|
||||
>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openDetails(pokemon.number)}
|
||||
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<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}
|
||||
{#if entry && reason === null}<span
|
||||
class="block truncate text-sm text-[var(--site-muted)]"
|
||||
>{availabilityLabels[entry.availability]}{entry.note
|
||||
? ` · ${entry.note}`
|
||||
: ''}</span
|
||||
>{/if}</span
|
||||
>
|
||||
</button>
|
||||
</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={!hydrated || !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">
|
||||
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
|
||||
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
|
||||
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
|
||||
game but no route is confirmed, and “—” means there is no record. Neither proves it is
|
||||
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
bind:this={detailsDialog}
|
||||
onclose={() => (detailNumber = null)}
|
||||
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
|
||||
>close</button
|
||||
>
|
||||
</div>
|
||||
{#if detailNumber !== null}
|
||||
<PokemonDetails
|
||||
number={detailNumber}
|
||||
gameId={viewGameId}
|
||||
isAdmin={data.isAdmin}
|
||||
onsaved={() => invalidateAll()}
|
||||
/>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { pokemonDetail } from '$lib/server/pokemon-detail';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const game = Number(url.searchParams.get('game'));
|
||||
const gameId = Number.isSafeInteger(game) && game > 0 && game <= 2147483647 ? game : null;
|
||||
const number = Number(params.number);
|
||||
if (number > 100000) error(404, 'Unknown Pokémon');
|
||||
return json(await pokemonDetail(number, gameId));
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { asc, countDistinct, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
locations: await db
|
||||
.select({
|
||||
id: pokemonLocations.id,
|
||||
name: pokemonLocations.name,
|
||||
region: pokemonLocations.region,
|
||||
hasPicture: sql<boolean>`${pokemonLocations.imageKey} is not null`,
|
||||
pokemon: countDistinct(pokemonEncounters.pokemonId)
|
||||
})
|
||||
.from(pokemonLocations)
|
||||
.leftJoin(pokemonEncounters, eq(pokemonEncounters.locationId, pokemonLocations.id))
|
||||
.groupBy(pokemonLocations.id)
|
||||
.orderBy(asc(pokemonLocations.region), asc(pokemonLocations.name))
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let search = $state('');
|
||||
let region = $state('');
|
||||
let withPokemonOnly = $state(true);
|
||||
const regions = $derived([...new Set(data.locations.map((l) => l.region ?? 'other'))]);
|
||||
const grouped = $derived(
|
||||
Map.groupBy(
|
||||
data.locations.filter(
|
||||
(location) =>
|
||||
(!withPokemonOnly || location.pokemon > 0) &&
|
||||
(!region || (location.region ?? 'other') === region) &&
|
||||
location.name.toLowerCase().includes(search.trim().toLowerCase())
|
||||
),
|
||||
(location) => location.region ?? 'other'
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← catchemall</a
|
||||
>
|
||||
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">locations</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{data.locations.length} places across every region. Open one to see which Pokémon live there.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
|
||||
<label class="block"
|
||||
>search
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="route, city, cave…"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
/>
|
||||
</label>
|
||||
<label class="block"
|
||||
>region
|
||||
<select
|
||||
bind:value={region}
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
>
|
||||
<option value="">all regions</option>
|
||||
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-5 flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={withPokemonOnly}
|
||||
class="h-5 w-5 accent-[var(--site-text)]"
|
||||
/>
|
||||
only places with recorded Pokémon
|
||||
</label>
|
||||
|
||||
{#each [...grouped] as [name, locations] (name)}
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-semibold">{name}</h2>
|
||||
<ul class="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each locations as location (location.id)}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
|
||||
id: String(location.id)
|
||||
})}
|
||||
class="flex items-baseline justify-between gap-3 py-3 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span>{location.name}</span>
|
||||
<span class="shrink-0 text-sm text-[var(--site-muted)]"
|
||||
>{location.pokemon ? `${location.pokemon} Pokémon` : ''}{location.hasPicture
|
||||
? ' · picture'
|
||||
: ''}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{:else}
|
||||
<p class="mt-10 text-[var(--site-muted)]">no locations match.</p>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { games, pokemon, pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import { deleteObject, locationImageUrl, uploadImage } from '$lib/server/r2';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const PICTURE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const MAX_PICTURE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
async function findLocation(id: string) {
|
||||
const [location] = await db
|
||||
.select()
|
||||
.from(pokemonLocations)
|
||||
.where(eq(pokemonLocations.id, Number(id)));
|
||||
if (!location) error(404, 'Location not found');
|
||||
return location;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const location = await findLocation(params.id);
|
||||
const encounters = await db
|
||||
.select({
|
||||
gameId: games.id,
|
||||
game: games.name,
|
||||
number: pokemon.number,
|
||||
name: pokemon.name,
|
||||
area: pokemonEncounters.area,
|
||||
method: pokemonEncounters.method,
|
||||
minLevel: pokemonEncounters.minLevel,
|
||||
maxLevel: pokemonEncounters.maxLevel,
|
||||
rarity: pokemonEncounters.rarity,
|
||||
form: pokemonEncounters.form,
|
||||
conditions: pokemonEncounters.conditions,
|
||||
note: pokemonEncounters.note
|
||||
})
|
||||
.from(pokemonEncounters)
|
||||
.innerJoin(games, eq(games.id, pokemonEncounters.gameId))
|
||||
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
|
||||
.where(eq(pokemonEncounters.locationId, location.id))
|
||||
.orderBy(
|
||||
asc(games.generation),
|
||||
asc(games.id),
|
||||
asc(pokemonEncounters.area),
|
||||
asc(pokemonEncounters.method),
|
||||
asc(pokemon.number)
|
||||
);
|
||||
return {
|
||||
location: {
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
region: location.region,
|
||||
description: location.description,
|
||||
imageCredit: location.imageCredit,
|
||||
imageUrl: locationImageUrl(location.imageKey)
|
||||
},
|
||||
encounters,
|
||||
seo: {
|
||||
title: `${location.name} · Pokémon locations`,
|
||||
description: `Which Pokémon live in ${location.name}${location.region ? ` (${location.region})` : ''}, in which games, how to encounter them and at what levels.`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
save: async (event) => {
|
||||
requireAdmin(event);
|
||||
const location = await findLocation(event.params.id);
|
||||
const data = await event.request.formData();
|
||||
const description = String(data.get('description') ?? '').trim();
|
||||
const imageCredit = String(data.get('imageCredit') ?? '').trim();
|
||||
if (description.length > 5000 || imageCredit.length > 300)
|
||||
return fail(400, { message: 'Description or credit is too long' });
|
||||
|
||||
let imageKey = location.imageKey;
|
||||
const picture = data.get('picture');
|
||||
if (picture instanceof File && picture.size > 0) {
|
||||
if (!PICTURE_TYPES.includes(picture.type))
|
||||
return fail(400, { message: 'Use a JPEG, PNG or WebP picture' });
|
||||
if (picture.size > MAX_PICTURE_BYTES)
|
||||
return fail(400, { message: 'Pictures can be at most 5 MB' });
|
||||
try {
|
||||
imageKey = (await uploadImage(picture, 'locations')).key;
|
||||
} catch {
|
||||
return fail(503, { message: 'Picture storage is not available right now' });
|
||||
}
|
||||
} else if (data.get('removePicture') === 'on') {
|
||||
imageKey = null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(pokemonLocations)
|
||||
.set({ description: description || null, imageCredit: imageCredit || null, imageKey })
|
||||
.where(eq(pokemonLocations.id, location.id));
|
||||
// Remove the replaced object only after the row no longer points at it.
|
||||
if (location.imageKey && location.imageKey !== imageKey)
|
||||
await deleteObject(location.imageKey).catch(() => undefined);
|
||||
return { saved: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
const games = $derived([
|
||||
...new Map(data.encounters.map((encounter) => [encounter.gameId, encounter.game]))
|
||||
]);
|
||||
let gameId = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
const requested = Number(page.url.searchParams.get('game'));
|
||||
if (!games.some(([id]) => id === gameId))
|
||||
gameId = games.find(([id]) => id === requested)?.[0] ?? games[0]?.[0] ?? null;
|
||||
});
|
||||
const rows = $derived(data.encounters.filter((encounter) => encounter.gameId === gameId));
|
||||
const areas = $derived([...Map.groupBy(rows, (row) => row.area ?? '')]);
|
||||
let saving = $state(false);
|
||||
|
||||
function levels(min: number | null, max: number | null) {
|
||||
if (min === null) return '';
|
||||
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon/locations')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← locations</a
|
||||
>
|
||||
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.location.name}</h1>
|
||||
{#if data.location.region}<p class="mt-2 text-[var(--site-muted)]">{data.location.region}</p>{/if}
|
||||
|
||||
<div class="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<figure>
|
||||
{#if data.location.imageUrl}
|
||||
<img
|
||||
src={data.location.imageUrl}
|
||||
alt={data.location.name}
|
||||
class="w-full rounded-lg border border-[var(--site-border)]"
|
||||
/>
|
||||
{#if data.location.imageCredit}<figcaption class="mt-2 text-sm text-[var(--site-muted)]">
|
||||
{data.location.imageCredit}
|
||||
</figcaption>{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center rounded-lg border border-dashed border-[var(--site-border)] text-[var(--site-muted)]"
|
||||
>
|
||||
no picture yet
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
<div>
|
||||
{#if data.location.description}
|
||||
<p class="whitespace-pre-line">{data.location.description}</p>
|
||||
{:else}
|
||||
<p class="text-[var(--site-muted)]">no notes about this place yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
enctype="multipart/form-data"
|
||||
class="mt-8 border-t border-[var(--site-border)] pt-6"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<h2 class="text-xl font-semibold">edit location</h2>
|
||||
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
|
||||
<label class="mt-4 block"
|
||||
>notes
|
||||
<textarea
|
||||
name="description"
|
||||
rows="4"
|
||||
maxlength="5000"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
>{data.location.description ?? ''}</textarea
|
||||
>
|
||||
</label>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="block"
|
||||
>picture (JPEG, PNG or WebP, up to 5 MB)
|
||||
<input
|
||||
type="file"
|
||||
name="picture"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="mt-2 block w-full"
|
||||
/>
|
||||
</label>
|
||||
<label class="block"
|
||||
>picture credit
|
||||
<input
|
||||
name="imageCredit"
|
||||
value={data.location.imageCredit ?? ''}
|
||||
maxlength="300"
|
||||
placeholder="e.g. my own screenshot"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if data.location.imageUrl}<label class="mt-3 flex items-center gap-2"
|
||||
><input type="checkbox" name="removePicture" /> remove the current picture</label
|
||||
>{/if}
|
||||
<button
|
||||
disabled={saving}
|
||||
class="mt-5 rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
|
||||
>{saving ? 'saving…' : 'save'}</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<section class="mt-12">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<h2 class="text-2xl font-semibold">Pokémon here</h2>
|
||||
{#if games.length}
|
||||
<label class="block"
|
||||
>game
|
||||
<select
|
||||
bind:value={gameId}
|
||||
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
>
|
||||
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#each areas as [area, list] (area)}
|
||||
{#if area}<h3 class="mt-6 font-semibold">{area}</h3>{/if}
|
||||
<ul class="mt-2">
|
||||
{#each list as encounter, index (index)}
|
||||
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.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)]">#{encounter.number}</span>{encounter.name}
|
||||
<span class="block text-sm text-[var(--site-muted)]">
|
||||
{[
|
||||
encounter.method,
|
||||
levels(encounter.minLevel, encounter.maxLevel),
|
||||
encounter.rarity ? `${encounter.rarity}%` : '',
|
||||
encounter.form ?? '',
|
||||
...encounter.conditions,
|
||||
encounter.note ?? ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="mt-4 text-[var(--site-muted)]">no Pokémon recorded here.</p>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
Reference in New Issue
Block a user