beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

View File

@@ -1,5 +1,14 @@
export const load = async ({ cookies }) => {
return {
editable: cookies.get('token')
};
};
import { isAdmin } from '$lib/server/authorization';
import { dev } from '$app/environment';
import { siteEnvironment } from '$lib/title';
import type { LayoutServerLoad } from './$types';
const environment = siteEnvironment(dev, process.env.ORIGIN);
export const load: LayoutServerLoad = (event) => ({
user: event.locals.user
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
: null,
isAdmin: isAdmin(event),
editable: isAdmin(event),
version: process.env.APP_VERSION || 'dev',
environment
});

View File

@@ -1,6 +1,11 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { assets, resolve } from '$app/paths';
import { dev } from '$app/environment';
import { page } from '$app/state';
import { getSeo } from '$lib/seo';
import CopyButton from '$lib/components/CopyButton.svelte';
import SiOsu from '~icons/simple-icons/osu';
import SiSteam from '~icons/simple-icons/steam';
import SiTwitch from '~icons/simple-icons/twitch';
@@ -10,47 +15,105 @@
import SiLinkedin from '~icons/simple-icons/linkedin';
import SiInstagram from '~icons/simple-icons/instagram';
import SiX from '~icons/simple-icons/x';
// import SiBluesky from '~icons/simple-icons/bluesky';
// import SiThreads from '~icons/simple-icons/threads';
import SiMail from '~icons/simple-icons/gmail';
import CopyButton from '$lib/components/CopyButton.svelte';
import SiDiscord from '~icons/simple-icons/discord';
import SiSignal from '~icons/simple-icons/signal';
import SiWhatsapp from '~icons/simple-icons/whatsapp';
import { resolve } from '$app/paths';
import { dev } from '$app/environment';
import { getTitle } from '$lib/title';
import { page } from '$app/state';
let { children } = $props();
const links = [
{ icon: SiMail, label: 'Email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'GitHub', href: 'https://github.com/stanrunge' },
{ icon: SiLinkedin, label: 'LinkedIn', href: 'https://linkedin.com/in/stanrunge' },
{ icon: SiX, label: 'X', href: 'https://x.com/stanrunge' },
{ icon: SiYoutube, label: 'YouTube', href: 'https://youtube.com/@stanrunge' },
{ icon: SiInstagram, label: 'Instagram', href: 'https://instagram.com/stan_runge' },
// { icon: SiBluesky, label: 'Bluesky', href: 'https://bsky.app/profile/stanrunge.bsky.social' },
// { icon: SiThreads, label: 'Threads', href: 'https://www.threads.com/@stan_runge' },
{ icon: SiTwitch, label: 'Twitch', href: 'https://twitch.tv/stanrunge_' },
let { data, children } = $props();
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
let menuOpen = $state(false);
type ThemePreference = 'dark' | 'system' | 'light';
let theme = $state<ThemePreference>('dark');
const navigation = [
{ label: 'home', href: '/' },
{ label: 'projects', href: '/projects' },
{ label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' }
] as const;
const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
{ icon: SiLinkedin, label: 'linkedin', href: 'https://linkedin.com/in/stanrunge' },
{ icon: SiX, label: 'x / twitter', href: 'https://x.com/stanrunge' },
{ icon: SiYoutube, label: 'youtube', href: 'https://youtube.com/@stanrunge' },
{ icon: SiInstagram, label: 'instagram', href: 'https://instagram.com/stan_runge' },
{ icon: SiTwitch, label: 'twitch', href: 'https://twitch.tv/stanrunge_' },
{
icon: SiSpotify,
label: 'Spotify',
label: 'spotify',
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
},
{ icon: SiSteam, label: 'Steam', href: 'https://steamcommunity.com/id/stanrunge' },
{ icon: SiSteam, label: 'steam', href: 'https://steamcommunity.com/id/stanrunge' },
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
];
const copies = [
const copyLinks = [
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
];
onMount(() => {
const preference = document.documentElement.dataset.themePreference;
theme = preference === 'light' || preference === 'system' ? preference : 'dark';
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
const onSystemThemeChange = () => {
if (theme === 'system') applyTheme('system');
};
systemTheme.addEventListener('change', onSystemThemeChange);
return () => systemTheme.removeEventListener('change', onSystemThemeChange);
});
function applyTheme(preference: ThemePreference) {
const resolvedTheme =
preference === 'system'
? window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
: preference;
document.documentElement.dataset.themePreference = preference;
document.documentElement.dataset.theme = resolvedTheme;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', resolvedTheme === 'light' ? '#ffffff' : '#000000');
}
function toggleTheme() {
theme = theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark';
applyTheme(theme);
try {
localStorage.setItem('theme', theme);
} catch {
// The selected theme still applies for this visit.
}
}
function current(href: string) {
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
}
</script>
{#snippet themeIcon()}
<svg
viewBox="0 0 24 24"
width="22"
height="22"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
aria-hidden="true"
>
<path
d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
/>
<circle cx="12" cy="12" r="6" fill={theme === 'light' ? 'currentColor' : 'none'} />
{#if theme === 'system'}
<path d="M12 6a6 6 0 0 1 0 12Z" fill="currentColor" stroke="none" />
{/if}
</svg>
{/snippet}
<svelte:head>
{#if !dev}
<script
@@ -59,45 +122,157 @@
data-website-id="47f2b385-188a-4776-a287-4889008ebac5"
></script>
{/if}
<title>{getTitle(page.url.pathname)}</title>
<title>{seo.title}</title>
<meta name="description" content={seo.description} />
<meta name="robots" content={seo.indexable ? 'index, follow' : 'noindex, nofollow'} />
<link rel="canonical" href={seo.url} />
<meta property="og:site_name" content="Stan Runge" />
<meta property="og:type" content={seo.type} />
<meta property="og:title" content={seo.title} />
<meta property="og:description" content={seo.description} />
<meta property="og:url" content={seo.url} />
<meta property="og:image" content={seo.image} />
<meta property="og:image:alt" content={seo.imageAlt} />
<meta property="og:image:width" content="793" />
<meta property="og:image:height" content="793" />
{#if seo.modifiedAt}<meta property="article:modified_time" content={seo.modifiedAt} />{/if}
<meta name="twitter:card" content="summary" />
<meta name="twitter:creator" content="@stanrunge" />
<meta name="twitter:title" content={seo.title} />
<meta name="twitter:description" content={seo.description} />
<meta name="twitter:image" content={seo.image} />
<meta name="twitter:image:alt" content={seo.imageAlt} />
</svelte:head>
<div class="flex flex-col min-h-screen">
<header class="flex justify-between items-center gap-4 py-4 px-4 border-b-4 border-gray-300">
<a href={resolve('/')} class="text-2xl font-bold underline">Stan Runge</a>
<div class="flex gap-8">
<div class="text-center py-2 font-bold text-xl ">
<a href={resolve('/blog')}>Blog</a>
</div>
<div class="text-center py-2 font-bold text-xl ">
<a href={resolve('/projects')}>Projects</a>
</div>
</div>
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
{#each copies as c (c.label)}
<CopyButton {...c} />
{/each}
{#each links as { icon: Icon, label, href } (label)}
<a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
<Icon />
</a>
{/each}
</nav>
</header>
<main class="flex-1">
{@render children()}
</main>
<footer class="flex justify-center py-8 bg-gray-200">
<p>
Hosted on <a
class="underline text-blue-500"
href="https://git.stanrunge.dev/stan/personal-website">my git server :)</a
<div class="flex min-h-screen flex-col bg-[var(--site-bg)] text-[var(--site-text)]">
<a
href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
>skip to content</a
>
<header class="border-b border-[var(--site-border)]">
<div class="mx-auto flex max-w-6xl items-center gap-5 px-4 py-4 sm:px-6">
<a
href={resolve('/')}
class="flex shrink-0 items-center gap-3 text-base font-semibold tracking-tight"
onclick={() => (menuOpen = false)}
>
</p>
<span class="h-7 w-7 overflow-hidden rounded-full bg-black">
<img
src="{assets}/favicon-32x32.png"
alt=""
width="32"
height="32"
class="h-full w-full scale-[1.7]"
/>
</span>
<span>stan runge</span>
</a>
<nav class="ml-auto hidden items-center gap-6 text-base md:flex" aria-label="Main navigation">
{#each navigation as item (item.href)}
<a
href={resolve(item.href)}
aria-current={current(item.href) ? 'page' : undefined}
class={current(item.href)
? 'text-[var(--site-text)]'
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{item.label}</a
>
{/each}
</nav>
<div class="ml-auto hidden items-center gap-5 text-base md:ml-4 md:flex">
<button
type="button"
onclick={toggleTheme}
aria-label={`Theme: ${theme}. Switch to ${theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark'} mode`}
title={`Theme: ${theme}`}
class="flex h-10 w-10 items-center justify-center text-[var(--site-muted)] hover:text-[var(--site-text)]"
>{@render themeIcon()}</button
>
{#if data.user}
<a href={resolve('/account')} class="max-w-28 truncate hover:underline"
>{data.user.name}</a
>
{:else}
<a
href={resolve(
`/login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
)}
class="hover:underline">sign in</a
>
{/if}
</div>
<button
type="button"
class="ml-auto text-base text-[var(--site-muted)] md:hidden"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onclick={() => (menuOpen = !menuOpen)}>{menuOpen ? 'close' : 'menu'}</button
>
</div>
{#if menuOpen}
<nav
id="mobile-menu"
class="border-t border-[var(--site-border)] px-4 py-4 md:hidden"
aria-label="Mobile navigation"
>
<div class="mx-auto flex max-w-6xl flex-col items-center gap-1 text-center text-base">
{#each navigation as item (item.href)}
<a href={resolve(item.href)} onclick={() => (menuOpen = false)} class="w-full py-2"
>{item.label}</a
>
{/each}
<div
class="mt-3 flex w-full items-center justify-center gap-5 border-t border-[var(--site-border)] pt-4"
>
<button
type="button"
onclick={toggleTheme}
aria-label={`Theme: ${theme}. Switch to ${theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark'} mode`}
title={`Theme: ${theme}`}
class="flex h-10 w-10 items-center justify-center text-[var(--site-muted)]"
>{@render themeIcon()}</button
>
{#if data.user}
<a href={resolve('/account')} onclick={() => (menuOpen = false)}>account</a>
<form method="POST" action="/logout"><button>sign out</button></form>
{:else}
<a href={resolve('/login')} onclick={() => (menuOpen = false)}>sign in</a>
<a href={resolve('/register')} onclick={() => (menuOpen = false)}>register</a>
{/if}
</div>
</div>
</nav>
{/if}
</header>
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
<footer class="mt-16 border-t border-[var(--site-border)]">
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
<span
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
>{data.version.slice(0, 7)}</span
></span
>
<a
href="https://git.stanrunge.dev/stan/personal-website"
rel="external"
class="hover:text-[var(--site-text)]">source ↗</a
>
</div>
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
{#each socialLinks as { icon: Icon, label, href } (label)}
<a
{href}
rel="external"
aria-label={label}
title={label}
class="flex h-10 w-10 items-center justify-center text-xl hover:text-[var(--site-text)]"
><Icon /></a
>
{/each}
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
</nav>
</div>
</footer>
</div>

View File

@@ -1,31 +1,36 @@
import { env } from '$env/dynamic/private';
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { listItemCompletions } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const actions = {
authenticate: async ({ request, cookies }) => {
const data = await request.formData();
export const load: PageServerLoad = async ({ locals }) => {
const lists = await db.query.lists.findMany({
columns: { id: true, name: true, slug: true, isProgress: true },
with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' }
});
const token = data.get('token');
if (!token) {
return {
status: 400,
body: { error: 'Missing token' }
};
}
if (token !== env.AUTH_TOKEN) {
return {
status: 401,
body: { error: 'Invalid token' }
};
}
cookies.set('token', token, {
path: '/',
maxAge: 60 * 60 * 24 * 7
});
return {
success: true
};
}
const completed = new Set(
locals.user
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: []
);
return {
lists: lists.map(({ items, ...list }) => ({
...list,
total: items.length,
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
}))
};
};
// Retire the old shared-token login; legacy cookies grant no permissions.
export const actions = {
authenticate: () => fail(410, { message: 'Use the central account login at /login' })
};

View File

@@ -1,107 +1,181 @@
<script lang="ts">
import { resolve } from '$app/paths';
import portrait from '$lib/assets/stan.jpg?url';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const projects = [
{
name: 'vash esports',
subtitle: 'the best way to play osu!',
href: 'https://esports.vash.software',
external: true
},
{
name: 'catchemall',
subtitle: 'catch 1025 pokemon across 53 games',
href: '/projects/catch-em-all',
external: false
},
{
name: 'animaldex',
subtitle: 'add neighborhood pets to the world map 😼',
href: '/projects/animaldex',
external: false
},
{
name: 'osu! map finder',
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
href: '/projects/spotify-beatmap-finder',
external: false
},
{
name: 'derby lens',
subtitle: 'estimate odds for horse betting',
href: '/projects/derby',
external: false
}
] as const;
const experience = [
{ when: '2026–now', role: 'associate software engineer', place: 'Cargill' },
{
when: '2021–2026',
role: 'BSc computer science · software engineering',
place: 'The Hague University of Applied Sciences'
},
{ when: '2026', role: 'software engineering intern', place: '3webapps' },
{ when: '2023', role: 'software engineering intern', place: 'h5mag' },
{ when: '2021–2023', role: 'junior IT specialist', place: 'Finance Plus' }
] as const;
</script>
<svelte:head>
<link rel="preload" as="image" href={portrait} fetchpriority="high" />
</svelte:head>
<div class="flex justify-around py-8">
<div class="max-w-150">
<h2 class="font-bold text-2xl underline text-center">experience</h2>
<div class="flex gap-2">
<p class="min-w-28 text-right">2026 - <b>current</b></p>
<div>
<p class="font-bold">Associate Software Engineer</p>
<p>Cargill</p>
</div>
</div>
<div class="flex gap-2">
<p class="min-w-28 text-right">2021 - 2026</p>
<div>
<p class="font-bold">BSc. Computer Science (Software Engineering)</p>
<p>The Hague University of Applied Sciences</p>
</div>
</div>
<div class="flex gap-2">
<p class="min-w-28 text-right">2026</p>
<p class="font-bold">internship at 3webapps</p>
</div>
<div class="flex gap-2">
<p class="min-w-28 text-right">2023</p>
<p class="font-bold">internship at h5mag</p>
</div>
<div class="flex gap-2">
<p class="min-w-28 text-right">2021 - 2023</p>
<p class="font-bold">junior IT specialist at Finance Plus</p>
</div>
<div class="flex gap-2">
<p class="min-w-28 text-right">2020 - <b>current</b></p>
<p class="font-bold">300+ IT problems solved in Netherlands</p>
</div>
</div>
<div class="px-8">
<h1 class="font-black text-center text-4xl pb-8">Stan Runge</h1>
<enhanced:img
class="rounded-2xl"
src="$lib/assets/stan.jpg?enhanced"
alt="stan runge"
sizes="(min-width: 768px) 760px, 90vw"
fetchpriority="high"
/>
<p class="italic text-sm text-center py-2">
me preparing for my presentation about <a href="https://arxiv.org/abs/2209.08167"
>quantum vision transformers</a
<div class="mx-auto max-w-6xl px-4 sm:px-6">
<section
class="grid items-center gap-8 pt-7 pb-12 sm:py-20 md:grid-cols-[minmax(0,1fr)_minmax(0,320px)] md:gap-16"
>
<div class="min-w-0 text-center md:text-left">
<h1 class="text-5xl font-semibold tracking-[-0.055em] sm:text-6xl lg:text-7xl">stan runge</h1>
<nav
class="mt-7 grid grid-cols-2 justify-items-center gap-x-4 gap-y-3 text-base text-[var(--site-muted)] sm:flex sm:flex-wrap sm:justify-center sm:gap-x-6 md:justify-start"
aria-label="Find me online"
>
</p>
</div>
<div class="max-w-100">
<div>
<h2 class="font-bold text-2xl underline text-center">projects</h2>
<a
href="https://git.stanrunge.dev/stan"
rel="external"
class="hover:text-[var(--site-text)]">git ↗</a
>
<a href="https://x.com/stanrunge" rel="external" class="hover:text-[var(--site-text)]"
>twitter ↗</a
>
<a href="https://esports.vash.software" rel="external" class="hover:text-[var(--site-text)]"
>vash esports ↗</a
>
<a href="mailto:stan@stanrunge.dev" class="hover:text-[var(--site-text)]">email ↗</a>
</nav>
</div>
<img
src={portrait}
alt="Stan at his desk"
width="760"
height="746"
loading="eager"
decoding="sync"
fetchpriority="high"
class="aspect-square w-full max-w-80 justify-self-center rounded-md bg-[var(--site-surface)] object-cover md:justify-self-end"
/>
</section>
<div class="flex flex-col gap-4">
<a href="https://esports.vash.software">
<p class="font-bold text-xl">vash esports</p>
<p>automated osu! matchmaking/tournament platform</p>
</a>
<div>
<p class="font-bold text-xl">eudi-auth for 3webapps</p>
<p>
plugin for WooCommerce/Magento2 that adds authentication with the European Digital
Identity (EUDI) wallet
</p>
</div>
<div>
<p class="font-bold text-xl">realtime collaboration for h5mag</p>
<p>
socket.io server for h5mag (magazine web editor) that broadcasts user actions to other
editors, similar to writing a Google Doc together
</p>
</div>
<div>
<p class="font-bold text-xl">infra</p>
<p>server, project deployments and dev config as code with setup script</p>
</div>
<a href={resolve('/projects/animaldex')}>
<p class="font-bold text-xl">animaldex</p>
<p>world map with pets & other animals around the neighborhood 😼</p>
</a>
<a href={resolve('/projects/catch-em-all')}>
<p class="font-bold text-xl">catch em all</p>
<p>table of all 1025 pokemon across 38 games with checkboxes for catching them all</p>
</a>
<a href={resolve('/projects/spotify-beatmap-finder')}>
<p class="font-bold text-xl">spotify osu! map finder</p>
<p>small tool for finding osu! maps using a spotify url of a song or playlist</p>
</a>
<a href="https://beeriokart.stanrunge.dev">
<p class="font-bold text-xl">beerio kart</p>
<div>Go</div>
<p>webserver in go with a double-elim bracket for a one-time mario kart tournament</p>
</a>
<section aria-labelledby="projects-heading" class="border-t border-[var(--site-border)] pt-8">
<h2 id="projects-heading" class="mb-4 text-2xl font-semibold tracking-tight">
<a href={resolve('/projects')} class="hover:text-[var(--site-muted)]">projects ↗</a>
</h2>
<ul>
{#each projects as project (project.name)}
<li class="border-b border-[var(--site-border)] text-lg">
{#if project.external}
<a
href={project.href}
rel="external"
class="flex items-center justify-between gap-4 py-4 hover:text-[var(--site-muted)]"
>
<span class="min-w-0">
<span class="block">{project.name}</span>
<span class="mt-1 block text-base text-[var(--site-muted)]">{project.subtitle}</span
>
</span><span aria-hidden="true" class="shrink-0 text-[var(--site-muted)]">↗</span>
</a>
{:else}
<a
href={resolve(project.href)}
class="flex items-center justify-between gap-4 py-4 hover:text-[var(--site-muted)]"
>
<span class="min-w-0">
<span class="block">{project.name}</span>
<span class="mt-1 block text-base text-[var(--site-muted)]">{project.subtitle}</span
>
</span><span aria-hidden="true" class="shrink-0 text-[var(--site-muted)]">↗</span>
</a>
{/if}
</li>
{/each}
</ul>
</section>
<p>+ a lot more planned (soon™)</p>
</div>
</div>
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
<h2 id="lists-heading" class="mb-4 text-2xl font-semibold tracking-tight">
<a href={resolve('/lists')} class="hover:text-[var(--site-muted)]">lists ↗</a>
</h2>
<ul>
{#each data.lists as list (list.id)}
<li class="border-b border-[var(--site-border)] text-lg">
<a
href={resolve('/lists/[slug]', { slug: list.slug })}
class="block py-4 hover:text-[var(--site-muted)]"
>
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span>{list.name}</span>
<span class="text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
{:else}
{list.total} {list.total === 1 ? 'item' : 'items'}
{/if} ↗
</span>
</span>
{#if list.isProgress}<span class="mt-3 block"
><ProgressBar
value={list.checked}
max={list.total}
label={`Progress on ${list.name}`}
small
/></span
>{/if}
</a>
</li>
{/each}
</ul>
</section>
<section aria-labelledby="experience-heading" class="pt-14 sm:pt-20">
<h2 id="experience-heading" class="mb-4 text-2xl font-semibold tracking-tight">experience</h2>
<ol>
{#each experience as item (item.when + item.place)}
<li
class="grid gap-1 border-t border-[var(--site-border)] py-4 sm:grid-cols-[minmax(0,1fr)_130px] sm:gap-6"
>
<div class="min-w-0">
<h3 class="font-medium">{item.role}</h3>
<p class="mt-1 text-base text-[var(--site-muted)]">{item.place}</p>
</div>
<span class="text-base text-[var(--site-muted)] sm:text-right">{item.when}</span>
</li>
{/each}
</ol>
</section>
</div>

View File

@@ -0,0 +1,13 @@
import { auth, authProviders } from '$lib/server/auth';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, request, url }) => {
if (!locals.user) redirect(303, '/login?returnTo=/account');
const accounts = await auth.api.listUserAccounts({ headers: request.headers });
return {
email: locals.user.email.endsWith('@osu.invalid') ? null : locals.user.email,
providers: authProviders,
linkedProviders: accounts.map((account) => account.providerId),
oauthError: url.searchParams.has('error')
};
};

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { authClient } from '$lib/auth-client';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let busy = $state(false);
let message = $state('');
async function link(provider: 'google' | 'osu') {
busy = true;
message = '';
try {
const result = await authClient.linkSocial({
provider,
callbackURL: '/account',
errorCallbackURL: '/account'
});
if (result.error) {
message = result.error.message || 'Could not link this account.';
busy = false;
}
} catch {
message = 'Could not connect. Please try again.';
busy = false;
}
}
</script>
<section class="mx-auto max-w-xl p-6 space-y-6">
<div>
<h1 class="text-2xl font-bold">Your account</h1>
<p class="mt-2">Signed in as {data.user?.name}{data.email ? ` (${data.email})` : ''}.</p>
</div>
<p class="text-sm text-gray-500">Account ID: <code>{data.user?.id}</code></p>
<div class="rounded border p-4 space-y-3">
<h2 class="font-bold">Sign-in methods</h2>
<p class="text-sm text-gray-600">
Link a provider here to use it with this account and keep your saved progress together.
</p>
{#if message || data.oauthError}<p role="alert" class="text-red-700">
{message ||
'Linking failed or was cancelled. The provider may already belong to another account.'}
</p>{/if}
{#if data.linkedProviders.includes('credential')}<p>Email and password connected</p>{/if}
{#each ['google', 'osu'] as provider (provider)}
{@const label = provider === 'google' ? 'Google' : 'osu!'}
{#if data.linkedProviders.includes(provider)}<p>{label} connected</p>
{:else if data.providers[provider as 'google' | 'osu']}<button
class="block rounded border px-4 py-2 disabled:opacity-50"
disabled={busy}
onclick={() => link(provider as 'google' | 'osu')}>Connect {label}</button
>{/if}
{/each}
</div>
{#if data.isAdmin}
<div class="rounded border p-4 space-y-3">
<h2 class="font-bold">Administration</h2>
<div class="flex flex-wrap gap-4">
<a class="underline" href={resolve('/blog')}>Blog articles</a><a
class="underline"
href={resolve('/lists')}>Lists</a
><a class="underline" href={resolve('/projects/animaldex/admin')}>Animaldex moderation</a><a
class="underline"
href={resolve('/projects/progress')}>Progress</a
>
</div>
</div>
{/if}
<form method="POST" action="/logout">
<button class="rounded border px-4 py-2">Log out</button>
</form>
</section>

View File

@@ -1,25 +1,3 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { z } from 'zod';
const loginSchema = z.object({ password: z.string().min(1) });
export const POST: RequestHandler = async ({ request, cookies }) => {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
throw error(401, { message: 'Invalid password' });
}
cookies.set('admin_token', env.ADMIN_SECRET, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
});
return json({ ok: true });
};
import { error, redirect } from '@sveltejs/kit';
export const GET = () => redirect(303, '/login');
export const POST = () => error(410, 'Use the central account login at /login');

View File

@@ -20,7 +20,8 @@ export const load: PageServerLoad = async (event) => {
return {
blogArticles: articles,
isAdmin: admin
isAdmin: admin,
now: Date.now()
};
};

View File

@@ -1,125 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
let { data } = $props();
let createNewArticlePopup = $state(false);
let loginPopup = $state(false);
let password = $state('');
let loginError = $state('');
let loginLoading = $state(false);
async function login() {
loginLoading = true;
loginError = '';
const res = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
loginPopup = false;
password = '';
await invalidateAll();
} else {
loginError = 'Wrong password';
}
loginLoading = false;
}
import { calendarDate, relativeTime } from '$lib/relative-time';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let searchQuery = $state('');
let selectedTag = $state('');
let tags = $derived([...new Set(data.blogArticles.flatMap((article) => article.tags))].sort());
let visibleArticles = $derived(
data.blogArticles.filter((article) => {
const query = searchQuery.trim().toLowerCase();
return (
(!selectedTag || article.tags.includes(selectedTag)) &&
(!query ||
`${article.title} ${article.content} ${article.tags.join(' ')}`
.toLowerCase()
.includes(query))
);
})
);
</script>
<div class="text-center py-8 gap-2 flex align-center justify-center">
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
{#if data.isAdmin}
<button
onclick={() => (createNewArticlePopup = true)}
class="bg-gray-800 rounded px-4 py-2 text-white font-bold cursor-pointer"
>Write new article</button
>
{:else}
<button
onclick={() => (loginPopup = true)}
class="text-xs text-gray-400 underline cursor-pointer">Admin login</button
>
{/if}
</div>
<div class="flex flex-col items-center gap-2">
{#each data.blogArticles as article (article.slug)}
<a href={resolve('/blog/[slug]', { slug: article.slug })}>
<div class="flex gap-2 py-2 px-4 border rounded min-w-100">
<p class="font-bold text-md">{article.title}</p>
{#if data.isAdmin && article.status === 'draft'}
<span class="text-xs uppercase text-orange-500">draft</span>
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
>edit</a
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">blog</h1>
{#if data.isAdmin}<details class="relative">
<summary class="cursor-pointer text-base underline underline-offset-4">new article</summary>
<form
method="POST"
use:enhance
class="absolute top-full right-0 z-10 mt-3 w-72 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
>
<label class="block"
>title<input
name="title"
required
maxlength="256"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/></label
>
{/if}
<p>{article.createdAt?.toDateString()}</p>
<button
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>create draft</button
>
</form>
</details>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
<div class="mb-8 space-y-4">
<label class="block max-w-lg">
<span class="sr-only">Search articles</span>
<input
type="search"
placeholder="search articles"
bind:value={searchQuery}
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 text-base"
/>
</label>
{#if tags.length}
<div class="flex flex-wrap gap-x-4 gap-y-2" aria-label="Filter by tag">
<button
type="button"
onclick={() => (selectedTag = '')}
aria-pressed={!selectedTag}
class={selectedTag ? 'text-[var(--site-muted)]' : 'underline underline-offset-4'}
>all</button
>
{#each tags as tag (tag)}
<button
type="button"
onclick={() => (selectedTag = selectedTag === tag ? '' : tag)}
aria-pressed={selectedTag === tag}
class={selectedTag === tag
? 'underline underline-offset-4'
: 'text-[var(--site-muted)]'}>{tag}</button
>
{/each}
</div>
</a>
{/each}
{/if}
</div>
<ul>
{#each visibleArticles as article (article.id)}
<li class="border-t border-[var(--site-border)] py-5">
<div class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<a
href={resolve('/blog/[slug]', { slug: article.slug })}
class="text-lg font-medium hover:text-[var(--site-muted)]">{article.title}</a
>
<div
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-base text-[var(--site-muted)]"
>
{#if data.isAdmin && article.status === 'draft'}<span>draft</span>{/if}
{#each article.tags as tag (tag)}<span>{tag}</span>{/each}
<time datetime={article.createdAt.toISOString()} title={calendarDate(article.createdAt)}
>{relativeTime(article.createdAt, data.now)}</time
>
{#if data.isAdmin}<a
href={resolve('/blog/[slug]/edit', { slug: article.slug })}
class="hover:text-[var(--site-text)]">edit</a
>{/if}
</div>
</div>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
{data.blogArticles.length ? 'no matching articles' : 'no articles yet'}
</li>
{/each}
</ul>
</div>
{#if createNewArticlePopup}
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
<form method="POST" use:enhance class="w-80 rounded bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-bold">Create New Article</h2>
<input
name="title"
placeholder="Article title"
required
class="mb-4 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
/>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (createNewArticlePopup = false)}
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
>
<button
type="submit"
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white cursor-pointer"
>Create draft</button
>
</div>
</form>
</div>
{/if}
{#if loginPopup}
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
<div class="w-80 rounded bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-bold">Admin login</h2>
<input
type="password"
bind:value={password}
placeholder="Password"
onkeydown={(e) => e.key === 'Enter' && login()}
class="mb-3 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
/>
{#if loginError}
<p class="mb-3 text-xs text-red-500">{loginError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => {
loginPopup = false;
loginError = '';
}}
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
>
<button
onclick={login}
disabled={loginLoading}
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
{loginLoading ? 'Logging in…' : 'Log in'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -1,24 +1,30 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { calendarDate } from '$lib/relative-time';
import 'highlight.js/styles/github-dark.css';
let { data } = $props();
</script>
<svelte:head><title>{data.blogArticle.title} - Blog</title></svelte:head>
<article class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{data.blogArticle.title}</h1>
<div class="mb-6 flex items-center justify-between gap-4">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">{data.blogArticle.title}</h1>
{#if data.isAdmin}
<a
href={resolve('/blog/[slug]/edit', { slug: data.blogArticle.slug })}
class="text-sm underline">Edit</a
class="text-base underline">edit</a
>
{/if}
</div>
<p class="mb-6 text-sm text-gray-500">{data.blogArticle.createdAt?.toDateString()}</p>
<div class="prose prose-neutral max-w-none">
<div class="mb-8 flex flex-wrap gap-x-4 gap-y-1 text-base text-[var(--site-muted)]">
<time datetime={data.blogArticle.createdAt.toISOString()}
>{calendarDate(data.blogArticle.createdAt)}</time
>
{#each data.blogArticle.tags as tag (tag)}<span>{tag}</span>{/each}
</div>
<div class="article-content prose max-w-none">
<!-- Only administrators can author blog Markdown. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.contentHtml}
</div>
</article>

View File

@@ -1,9 +1,10 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { isAdmin, requireAdmin } from '$lib/server/auth';
import { updateArticleSchema } from '$lib/validation/blog';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { parseArticleTags, updateArticleSchema } from '$lib/validation/blog';
import { validSlug } from '$lib/server/slugify';
import { and, eq, ne } from 'drizzle-orm';
import { error, fail, redirect, type RequestEvent } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
@@ -26,59 +27,81 @@ export const load: PageServerLoad = async (event) => {
};
};
async function readArticleForm(event: RequestEvent) {
const formData = await event.request.formData();
const parsed = updateArticleSchema.safeParse({
title: formData.get('title')?.toString().trim(),
slug: formData.get('slug')?.toString().trim().toLowerCase(),
content: formData.get('content')?.toString(),
tags: parseArticleTags(formData.get('tags')?.toString() ?? '')
});
if (!parsed.success || !validSlug(parsed.data.slug)) return null;
return parsed.data;
}
export const actions = {
save: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const parsed = updateArticleSchema.safeParse({
title: formData.get('title')?.toString(),
content: formData.get('content')?.toString()
});
if (!parsed.success) {
return fail(400, { message: 'Title and content are required' });
}
const values = await readArticleForm(event);
if (!values)
return fail(400, {
message: 'Enter a title, a lowercase slug, and up to 8 tags of 32 characters each.'
});
const current = await db.query.blogArticles.findFirst({ where: { slug: event.params.slug } });
if (!current) error(404, 'Article not found');
const duplicate = await db
.select({ id: blogArticles.id })
.from(blogArticles)
.where(and(eq(blogArticles.slug, values.slug), ne(blogArticles.id, current.id)));
if (duplicate.length) return fail(409, { message: 'That article slug is already in use.' });
const [updated] = await db
.update(blogArticles)
.set({
title: parsed.data.title,
content: parsed.data.content,
title: values.title,
slug: values.slug,
content: values.content,
tags: values.tags,
updatedAt: new Date()
})
.where(eq(blogArticles.slug, event.params.slug))
.where(eq(blogArticles.id, current.id))
.returning();
if (!updated) {
return error(404, { message: 'Article not found' });
}
if (values.slug !== event.params.slug) redirect(303, `/blog/${values.slug}/edit`);
return { saved: true };
},
publish: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const parsed = updateArticleSchema.safeParse({
title: formData.get('title')?.toString(),
content: formData.get('content')?.toString()
});
if (!parsed.success) {
return fail(400, { message: 'Title and content are required' });
}
const values = await readArticleForm(event);
if (!values)
return fail(400, {
message: 'Enter a title, a lowercase slug, and up to 8 tags of 32 characters each.'
});
const current = await db.query.blogArticles.findFirst({ where: { slug: event.params.slug } });
if (!current) error(404, 'Article not found');
const duplicate = await db
.select({ id: blogArticles.id })
.from(blogArticles)
.where(and(eq(blogArticles.slug, values.slug), ne(blogArticles.id, current.id)));
if (duplicate.length) return fail(409, { message: 'That article slug is already in use.' });
const [updated] = await db
.update(blogArticles)
.set({
title: parsed.data.title,
content: parsed.data.content,
title: values.title,
slug: values.slug,
content: values.content,
tags: values.tags,
status: 'published',
updatedAt: new Date()
})
.where(eq(blogArticles.slug, event.params.slug))
.where(eq(blogArticles.id, current.id))
.returning();
if (!updated) {
@@ -86,5 +109,15 @@ export const actions = {
}
return redirect(303, `/blog/${updated.slug}`);
},
delete: async (event) => {
requireAdmin(event);
const current = await db.query.blogArticles.findFirst({ where: { slug: event.params.slug } });
if (!current) error(404, 'Article not found');
const confirmation = String((await event.request.formData()).get('confirmTitle') ?? '');
if (confirmation !== current.title)
return fail(400, { message: 'Type the exact article title to confirm deletion.' });
await db.delete(blogArticles).where(eq(blogArticles.id, current.id));
redirect(303, '/blog');
}
};

View File

@@ -4,30 +4,43 @@
let { data, form } = $props();
let title = $state(data.blogArticle.title);
let content = $state(data.blogArticle.content ?? '');
let title = $state('');
let slug = $state('');
let tags = $state('');
let content = $state('');
let saving = $state(false);
let loadedSlug = '';
$effect(() => {
const article = data.blogArticle;
if (article.slug === loadedSlug) return;
loadedSlug = article.slug;
title = article.title;
slug = article.slug;
tags = article.tags.join(', ');
content = article.content ?? '';
});
</script>
<svelte:head><title>Editing {data.blogArticle.title}</title></svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl font-bold">
Editing article
<span
class="ml-2 rounded px-2 py-0.5 text-xs uppercase {data.blogArticle.status === 'published'
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700'}">{data.blogArticle.status}</span
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-16">
<a
href={resolve('/blog')}
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← blog</a
>
<div class="mt-5 mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">edit article</h1>
<p class="mt-1 text-base text-[var(--site-muted)]">{data.blogArticle.status}</p>
</div>
{#if data.blogArticle.status === 'published'}
<a
href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })}
class="text-base underline underline-offset-4">view article ↗</a
>
</h1>
<a href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })} class="text-sm underline"
>View</a
>
{/if}
</div>
{#if form?.message}
<p class="mb-3 text-sm text-red-500">{form.message}</p>
<p role="alert" class="mb-5 text-base text-red-500">{form.message}</p>
{/if}
<form
@@ -40,36 +53,80 @@
saving = false;
};
}}
class="flex flex-col gap-3"
class="space-y-6"
>
<input
name="title"
bind:value={title}
required
class="rounded border border-gray-300 px-3 py-2 text-lg font-bold outline-none"
/>
<textarea
name="content"
bind:value={content}
rows="20"
class="rounded border border-gray-300 px-3 py-2 font-body text-base leading-relaxed text-gray-900 outline-none"
></textarea>
<p class="-mt-2 text-xs text-gray-400">Supports Markdown formatting.</p>
<label class="block text-base font-medium"
>title<input
name="title"
bind:value={title}
required
maxlength="256"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 text-xl font-medium"
/></label
>
<div class="grid gap-6 sm:grid-cols-2">
<label class="block text-base font-medium"
>slug<input
name="slug"
bind:value={slug}
required
maxlength="120"
pattern="[a-z0-9]+(-[a-z0-9]+)*"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 font-mono text-base"
/></label
>
<label class="block text-base font-medium"
>tags<input
name="tags"
bind:value={tags}
placeholder="comma-separated tags"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 text-base"
/></label
>
</div>
<label class="block text-base font-medium"
>content <span class="font-normal text-[var(--site-muted)]">· markdown</span>
<textarea
name="content"
bind:value={content}
rows="24"
class="mt-2 block min-h-96 w-full resize-y rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 font-mono text-base leading-relaxed text-[var(--site-text)]"
></textarea></label
>
<div class="flex justify-end gap-2">
<div class="flex flex-wrap justify-end gap-3 border-t border-[var(--site-border)] pt-5">
<button
type="submit"
disabled={saving}
class="rounded bg-gray-800 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>Save draft</button
class="rounded border border-[var(--site-border)] px-5 py-2.5 text-base disabled:opacity-50"
>save</button
>
<button
type="submit"
formaction="?/publish"
disabled={saving}
class="rounded bg-green-700 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>Publish</button
class="rounded bg-[var(--site-text)] px-5 py-2.5 text-base font-medium text-[var(--site-bg)] disabled:opacity-50"
>{data.blogArticle.status === 'published' ? 'update published article' : 'publish'}</button
>
</div>
</form>
<details class="mt-12 border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer text-base text-[var(--site-muted)]">delete article</summary>
<form method="POST" action="?/delete" class="mt-3 space-y-3">
<p class="text-base">
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
</p>
<label class="block text-base"
>article title<input
name="confirmTitle"
required
autocomplete="off"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/></label
>
<button class="rounded border border-red-500 px-4 py-2 text-base text-red-500"
>delete article</button
>
</form>
</details>
</div>

View File

@@ -0,0 +1,47 @@
import { db } from '$lib/server/db';
import { listItemCompletions, lists } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { uniqueListSlug } from '$lib/server/slugify';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const rows = await db.query.lists.findMany({
with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' }
});
const completed = new Set(
locals.user
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: []
);
return {
lists: rows.map(({ items, ...list }) => ({
...list,
total: items.length,
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
}))
};
};
export const actions: Actions = {
newList: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const type = String(form.get('type') ?? '');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a list name of 1–200 characters.' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
const slug = await uniqueListSlug(name);
await db.insert(lists).values({ name, slug, isProgress: type === 'progress' });
redirect(303, `/lists/${slug}`);
}
};

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">lists</h1>
{#if data.isAdmin}<details class="relative">
<summary class="cursor-pointer text-base underline underline-offset-4">new list</summary>
<form
method="POST"
action="?/newList"
use:enhance
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
>
<label class="block"
>name<input
name="name"
required
maxlength="200"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/></label
>
<label class="block"
>type<select
name="type"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
><option value="normal">normal</option><option value="progress">progress</option
></select
></label
>
<button
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>create list</button
>
</form>
</details>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
<ul>
{#each data.lists as list (list.id)}
<li class="border-t border-[var(--site-border)]">
<a
href={resolve('/lists/[slug]', { slug: list.slug })}
class="block py-5 hover:text-[var(--site-muted)]"
>
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span class="text-lg font-medium">{list.name}</span>
<span class="text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
{:else}
{list.total}
{list.total === 1 ? 'item' : 'items'}{list.isProgress ? ' · progress' : ''}
{/if}
</span>
</span>
{#if list.isProgress}<span class="mt-3 block"
><ProgressBar
value={list.checked}
max={list.total}
label={`Progress on ${list.name}`}
small
/></span
>{/if}
</a>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
no lists yet
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,178 @@
import { db } from '$lib/server/db';
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
import { requireAdmin, requireUser } from '$lib/server/authorization';
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
import { validSlug } from '$lib/server/slugify';
import { and, eq, ne } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
async function currentList(slug: string) {
const list = await db.query.lists.findFirst({ where: { slug } });
if (!list) error(404, 'List not found');
return list;
}
async function removeStoredPicture(url: string | null) {
const key = url ? listImageKey(url) : null;
if (!key) return;
try {
await deleteObject(key);
} catch (cause) {
console.error('Could not remove list picture from R2', cause);
}
}
function itemFields(form: FormData) {
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const removePicture = form.get('removePicture') === 'on';
const file = form.get('picture');
if (!name || name.length > 500) return { error: 'Enter an item name of 1–500 characters.' };
if (description.length > 5000) return { error: 'Description must be 5,000 characters or less.' };
if (file instanceof File && file.size > 0)
return { error: 'Enable JavaScript to upload an image.' };
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
return {
values: {
name,
description: description || null,
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
}
};
}
export const load: PageServerLoad = async ({ params, locals }) => {
if (/^[1-9]\d*$/.test(params.slug)) {
const oldId = Number(params.slug);
if (Number.isSafeInteger(oldId)) {
const oldList = await db.query.lists.findFirst({ where: { id: oldId } });
if (oldList) redirect(308, `/lists/${oldList.slug}`);
}
}
const list = await db.query.lists.findFirst({
where: { slug: params.slug },
with: { items: { orderBy: { id: 'asc' } } }
});
if (!list) error(404, 'List not found');
const completedIds =
locals.user && list.isProgress
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: [];
const inList = completedIds.filter((id) => list.items.some((item) => item.id === id));
return { list, completedIds: inList, checked: inList.length, total: list.items.length };
};
export const actions: Actions = {
deleteList: async (event) => {
requireAdmin(event);
const list = await currentList(event.params.slug);
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
if (confirmation !== list.name)
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
const pictures = await db
.select({ pictureUrl: listItems.pictureUrl })
.from(listItems)
.where(eq(listItems.listId, list.id));
await db.delete(lists).where(eq(lists.id, list.id));
await Promise.all(pictures.map((item) => removeStoredPicture(item.pictureUrl)));
redirect(303, '/lists');
},
updateList: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const slug = String(form.get('slug') ?? '')
.trim()
.toLowerCase();
const type = String(form.get('type') ?? '');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
if (!validSlug(slug))
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
const current = await currentList(event.params.slug);
const duplicate = await db
.select({ id: lists.id })
.from(lists)
.where(and(eq(lists.slug, slug), ne(lists.id, current.id)));
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
const [list] = await db
.update(lists)
.set({ name, slug, isProgress: type === 'progress' })
.where(eq(lists.id, current.id))
.returning();
if (!list) error(404, 'List not found');
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
return { saved: true };
},
addItem: async (event) => {
requireAdmin(event);
const fields = itemFields(await event.request.formData());
if (fields.error) return fail(400, { message: fields.error });
const list = await currentList(event.params.slug);
await db.insert(listItems).values({ ...fields.values!, listId: list.id });
return { saved: true };
},
updateItem: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const id = Number(form.get('itemId'));
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item.' });
const fields = itemFields(form);
if (fields.error) return fail(400, { message: fields.error });
const list = await currentList(event.params.slug);
const existing = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
if (!existing) error(404, 'Item not found');
const [item] = await db
.update(listItems)
.set(fields.values!)
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
.returning({ id: listItems.id });
if (!item) error(404, 'Item not found');
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
await removeStoredPicture(existing.pictureUrl);
return { saved: true };
},
removeItem: async (event) => {
requireAdmin(event);
const id = Number((await event.request.formData()).get('itemId'));
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item' });
const list = await currentList(event.params.slug);
const [item] = await db
.delete(listItems)
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
.returning({ pictureUrl: listItems.pictureUrl });
if (!item) error(404, 'Item not found');
await removeStoredPicture(item.pictureUrl);
return { saved: true };
},
setCompletion: async (event) => {
const user = requireUser(event);
const list = await currentList(event.params.slug);
if (!list.isProgress) return fail(400, { message: 'This is not a progress list.' });
const form = await event.request.formData();
const id = Number(form.get('itemId'));
const completed = form.get('completed');
if (!Number.isSafeInteger(id) || id <= 0 || (completed !== 'yes' && completed !== 'no'))
return fail(400, { message: 'Invalid progress change.' });
const item = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
if (!item) error(404, 'Item not found');
if (completed === 'yes') {
await db
.insert(listItemCompletions)
.values({ itemId: id, userId: user.id })
.onConflictDoNothing();
} else {
await db
.delete(listItemCompletions)
.where(and(eq(listItemCompletions.itemId, id), eq(listItemCompletions.userId, user.id)));
}
return { saved: true };
}
};

View File

@@ -0,0 +1,265 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import ListPictureDropzone from '$lib/components/ListPictureDropzone.svelte';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let completed = $derived(new Set(data.completedIds));
let uploadError = $state('');
let uploading = $state(false);
let managing = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
if (!(picture instanceof File) || picture.size === 0) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel();
return;
}
if (picture.size > 5 * 1024 * 1024) {
uploadError = 'Images must be 5 MB or smaller.';
cancel();
return;
}
uploading = true;
try {
const uploadForm = new FormData();
uploadForm.set('picture', picture);
const uploaded = await fetch(resolve('/lists/api/upload'), {
method: 'POST',
body: uploadForm
});
if (!uploaded.ok) throw new Error('Could not upload the image. Please try again.');
const { key } = await uploaded.json();
formData.set('pictureKey', key);
} catch (cause) {
uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
cancel();
} finally {
uploading = false;
}
};
</script>
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/lists')}
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← lists</a
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">
{data.total}
{data.total === 1 ? 'item' : 'items'}
</p>
</div>
{#if data.isAdmin}<button
type="button"
onclick={() => (managing = !managing)}
aria-pressed={managing}
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</button
>{/if}
</div>
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
{#if data.list.isProgress}
{#if data.user}
<div class="border-y border-[var(--site-border)] py-4">
<p class="font-medium">
{data.checked} of {data.total} checked · {data.total
? Math.round((data.checked / data.total) * 100)
: 0}%
</p>
<div class="mt-2">
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
</div>
</div>
{:else}
<p>
<a class="underline" href={resolve(`/login?returnTo=/lists/${data.list.slug}`)}>Log in</a> to
save your progress.
</p>
{/if}
{/if}
{#if data.isAdmin && managing}
<details class="border-b border-[var(--site-border)] pb-5">
<summary class="cursor-pointer text-base text-[var(--site-muted)]">list settings</summary>
<div class="mt-5 space-y-5">
<form
method="POST"
action="?/updateList"
use:enhance
class="grid gap-3 sm:grid-cols-2 sm:items-end"
>
<label
>List name<input
name="name"
value={data.list.name}
required
maxlength="200"
class="block w-full rounded border p-2"
/></label
>
<label
>Slug<input
name="slug"
value={data.list.slug}
required
maxlength="120"
pattern="[a-z0-9]+(-[a-z0-9]+)*"
class="block w-full rounded border p-2"
/></label
>
<label
>Type<select name="type" class="block w-full rounded border p-2">
<option value="normal" selected={!data.list.isProgress}>Normal</option>
<option value="progress" selected={data.list.isProgress}>Progress</option>
</select></label
>
<button class="rounded border p-2">Save list</button>
</form>
<details class="border-t border-[var(--site-border)] pt-4">
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
<p class="text-sm">
This deletes the list, its items, and everyone’s saved progress. Type <strong
>{data.list.name}</strong
> to confirm.
</p>
<label class="block"
>List name<input
name="confirmName"
required
autocomplete="off"
class="mt-1 block w-full rounded border p-2"
/></label
>
<button class="rounded bg-red-700 px-4 py-2 text-white">Delete list</button>
</form>
</details>
</div>
</details>
{/if}
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each data.list.items as item (item.id)}
<li class="py-4">
<div class="flex items-start gap-4">
{#if item.pictureUrl}<img
src={item.pictureUrl}
alt={item.name}
loading="lazy"
width="64"
height="64"
class="h-16 w-16 shrink-0 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<div class="min-w-0 flex-1">
<p class:line-through={completed.has(item.id)} class="font-medium">{item.name}</p>
{#if item.description}<p
class="mt-1 whitespace-pre-wrap text-base text-[var(--site-muted)]"
>
{item.description}
</p>{/if}
</div>
{#if data.list.isProgress && data.user}
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
<input type="hidden" name="itemId" value={item.id} />
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
<button
class="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--site-border)] text-base"
aria-label={`${completed.has(item.id) ? 'Uncheck' : 'Check'} ${item.name}`}
>
{completed.has(item.id) ? '✓' : ''}
</button>
</form>
{/if}
</div>
{#if data.isAdmin && managing}
<details class="mt-3 border-t border-[var(--site-border)] pt-3">
<summary class="cursor-pointer text-base underline">edit item</summary>
<form
method="POST"
action="?/updateItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-3 grid gap-3"
>
<input type="hidden" name="itemId" value={item.id} />
<label
>Name<input
name="name"
value={item.name}
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone label="Replace picture (optional)" />
{#if item.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2">{item.description ?? ''}</textarea
></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Save item</button
>
</form>
<form method="POST" action="?/removeItem" use:enhance class="mt-3">
<input type="hidden" name="itemId" value={item.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${item.name}`}
>Remove item</button
>
</form>
</details>
{/if}
</li>
{:else}<li class="py-5 text-[var(--site-muted)]">no items yet</li>{/each}
</ul>
{#if data.isAdmin && managing}
<details class="border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer font-medium">add item</summary>
<form
method="POST"
action="?/addItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-5 grid gap-3"
>
<label
>Name<input
name="name"
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone />
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2"></textarea></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Add item</button
>
</form>
</details>
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
{/if}
</section>

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/authorization';
import { uploadImage } from '$lib/server/r2';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const form = await event.request.formData().catch(() => null);
const picture = form?.get('picture');
if (
!(picture instanceof File) ||
!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)
) {
error(400, 'Choose a JPEG, PNG, or WebP image.');
}
if (!picture.size || picture.size > 5 * 1024 * 1024)
error(400, 'Images must be 5 MB or smaller.');
try {
return json(await uploadImage(picture, 'lists'));
} catch (cause) {
console.error('List image upload failed', cause);
error(503, 'Image storage is unavailable.');
}
};

View File

@@ -0,0 +1,9 @@
import { authProviders } from '$lib/server/auth';
import { safeReturnTo } from '$lib/auth-redirect';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url, locals }) => {
const returnTo = safeReturnTo(url.searchParams.get('returnTo'));
if (locals.user) redirect(303, returnTo);
return { providers: authProviders, returnTo, oauthError: url.searchParams.has('error') };
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import AuthForm from '$lib/components/AuthForm.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<AuthForm mode="login" {...data} />

View File

@@ -0,0 +1,15 @@
import { auth } from '$lib/server/auth';
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, url, cookies }) => {
if (
![url.origin, new URL(String(auth.options.baseURL)).origin].includes(
request.headers.get('origin') ?? ''
)
)
error(403, 'Invalid origin');
await auth.api.signOut({ headers: request.headers });
cookies.delete('admin_token', { path: '/' });
cookies.delete('token', { path: '/' });
redirect(303, '/login');
};

View File

@@ -2,45 +2,57 @@
import { resolve } from '$app/paths';
const projects = [
{
name: 'Animaldex',
href: '/projects/animaldex',
description: 'World map with pets & other animals around the neighborhood'
},
{
name: 'CatchEmAll',
href: '/projects/catch-em-all',
description: 'Table of all 1025 pokemon across 38 games with checkboxes for catching them all'
},
{
name: 'Derby Lens',
href: '/projects/derby',
description: 'Experimental win-probability model for the Duindigt Derby R 2026 with scenario sliders'
},
{
name: 'CS Prices',
href: '/projects/cs-prices',
description: 'Dashboard for CS2 skin price histories, trends and predictions'
},
{
name: 'Spotify Beatmap Finder',
href: '/projects/spotify-beatmap-finder',
description: 'Search for osu! beatmaps with Spotify songs/albums/playlists'
}
{ name: 'catchemall', href: '/projects/catch-em-all', external: false },
{ name: 'animaldex', href: '/projects/animaldex', external: false },
{ name: 'derby lens', href: '/projects/derby', external: false },
{ name: 'cs prices', href: '/projects/cs-prices', external: false },
{ name: 'osu! map finder', href: '/projects/spotify-beatmap-finder', external: false },
{ name: 'game tracker', href: '/projects/game-tracker', external: false },
{ name: 'money tracker', href: '/projects/money-tracker', external: false },
{ name: 'vash esports', href: 'https://esports.vash.software', external: true },
{ name: 'beerio kart', href: 'https://beeriokart.stanrunge.dev', external: true }
] as const;
const earlier = [
'distributor',
'realtime editing',
'eIDAS auth module for WooCommerce + Magento 2'
];
</script>
<div class="text-center">
<h1 class="text-2xl font-bold underline py-8">Projects ({projects.length})</h1>
<ul class="flex flex-col gap-4 items-center">
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<h1 class="mb-10 text-4xl font-semibold tracking-tight sm:text-5xl">projects</h1>
<ul>
{#each projects as project (project.name)}
<a class="border min-w-160 py-2 text-center rounded-lg" href={resolve(project.href)}>
<h1 class="font-bold text-xl">
{project.name}
</h1>
<p>{project.description}</p>
</a>
<li class="border-t border-[var(--site-border)] text-lg">
{#if project.external}
<a
href={project.href}
rel="external"
class="flex items-center justify-between gap-4 py-5 hover:text-[var(--site-muted)]"
>
<span>{project.name}</span><span aria-hidden="true" class="text-[var(--site-muted)]"
>↗</span
>
</a>
{:else}
<a
href={resolve(project.href)}
class="flex items-center justify-between gap-4 py-5 hover:text-[var(--site-muted)]"
>
<span>{project.name}</span><span aria-hidden="true" class="text-[var(--site-muted)]"
>↗</span
>
</a>
{/if}
</li>
{/each}
</ul>
<h2 class="mt-14 mb-3 text-2xl font-semibold">other</h2>
<ul>
{#each earlier as name (name)}
<li class="border-t border-[var(--site-border)] py-5 text-lg text-[var(--site-muted)]">
{name}
</li>
{/each}
</ul>
</div>

View File

@@ -1,15 +1,5 @@
<script lang="ts">
import './layout.css';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Animaldex" />
<link rel="manifest" href="/site.webmanifest" />
</svelte:head>
{@render children()}

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
interface TrailPoint {
id: string;
id: number;
lat: number;
lng: number;
seenAt: string;
@@ -13,7 +14,7 @@
}
interface AnimalFeatureProps {
animalId: string;
animalId: number;
species: string;
breed: string | null;
animalName: string | null;
@@ -28,9 +29,17 @@
}
let mapContainer: HTMLDivElement;
let detailsSection = $state<HTMLDivElement | null>(null);
let map: MaptilerMap | null = null;
let destroyed = false;
let htmlMarkers: MaptilerMarker[] = [];
let MapMarker: typeof MaptilerMarker | null = null;
let mapError = $state('');
let animalCount = $state<number | null>(null);
let visibleAnimals = $state<GeoJSONFeature[]>([]);
let locating = $state(false);
let request: AbortController | null = null;
const submitted = $derived(page.url.searchParams.has('submitted'));
let filterSpecies = $state('');
let filterBreed = $state('');
@@ -41,7 +50,7 @@
let showFilters = $state(false);
let selected = $state<{
animalId: string;
animalId: number;
species: string;
breed: string | null;
animalName: string | null;
@@ -53,54 +62,85 @@
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
onMount(async () => {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
async function selectAnimal(feature: GeoJSONFeature) {
selected = { ...feature.properties, trail: parseTrail(feature.properties.trail) };
await tick();
detailsSection?.scrollIntoView({ block: 'nearest' });
}
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
MapMarker = Marker;
onMount(() => {
(async () => {
try {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
if (destroyed) return;
if (!env.PUBLIC_MAPTILER_API_KEY) {
mapError = 'The map is not configured yet.';
return;
}
const initMap = (center: [number, number], zoom: number) => {
map = new Map({
container: mapContainer,
style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center,
zoom
});
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
MapMarker = Marker;
map.on('load', () => {
map!.addSource('trails', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map!.addLayer({
id: 'trails',
type: 'line',
source: 'trails',
paint: {
'line-color': '#f97316',
'line-width': 2,
'line-dasharray': [2, 2],
'line-opacity': 0.7
}
});
loadMarkers(Marker);
});
const initMap = (center: [number, number], zoom: number) => {
map = new Map({
container: mapContainer,
style:
'https://api.maptiler.com/maps/satellite/style.json?key=' +
env.PUBLIC_MAPTILER_API_KEY,
center,
zoom,
scrollZoom: false,
cooperativeGestures: true
});
map.on('moveend', () => {
if (MapMarker) loadMarkers(MapMarker);
});
};
map.on('load', () => {
map!.addSource('trails', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map!.addLayer({
id: 'trails',
type: 'line',
source: 'trails',
paint: {
'line-color': '#ffffff',
'line-width': 2,
'line-dasharray': [2, 2],
'line-opacity': 0.7
}
});
loadMarkers(Marker);
});
navigator.geolocation.getCurrentPosition(
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
() => initMap([4.708, 52.009], 15.5),
{ enableHighAccuracy: true, timeout: 5000 }
);
map.on('moveend', () => {
if (MapMarker) loadMarkers(MapMarker);
});
};
const fallback = () => {
if (!destroyed) initMap([4.708, 52.009], 15.5);
};
if (!navigator.geolocation) {
fallback();
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
if (!destroyed) initMap([position.coords.longitude, position.coords.latitude], 14);
},
fallback,
{ enableHighAccuracy: true, timeout: 10000 }
);
} catch {
mapError = 'The map could not load. Please try again later.';
}
})();
});
onDestroy(() => {
destroyed = true;
request?.abort();
htmlMarkers.forEach((m) => m.remove());
map?.remove();
});
@@ -120,11 +160,25 @@
if (filterName) params.set('name', filterName);
if (filterReporter) params.set('reporter', filterReporter);
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
if (filterTo) params.set('toDate', new Date(`${filterTo}T23:59:59`).toISOString());
const res = await fetch(`/projects/animaldex/api/map?${params}`);
if (!res.ok) return;
const geojson: { features: GeoJSONFeature[] } = await res.json();
request?.abort();
request = new AbortController();
let geojson: { features: GeoJSONFeature[] };
try {
const res = await fetch(`${resolve('/projects/animaldex/api/map')}?${params}`, {
signal: request.signal
});
if (!res.ok) throw new Error('Map request failed');
geojson = await res.json();
mapError = '';
animalCount = geojson.features.length;
visibleAnimals = geojson.features;
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return;
mapError = 'Could not load animals in this area. Try moving the map again.';
return;
}
htmlMarkers.forEach((m) => m.remove());
htmlMarkers = [];
@@ -152,34 +206,27 @@
for (const feature of geojson.features) {
const props = feature.properties;
const trail = parseTrail(props.trail);
const photoUrl = trail[0]?.photoUrl ?? null;
const photoUrl = [...trail].reverse().find((point) => point.photoUrl)?.photoUrl ?? null;
const el = document.createElement('div');
const el = document.createElement('button');
el.type = 'button';
el.setAttribute('aria-label', `View ${props.animalName || props.species}`);
el.style.cssText = `
width: 44px; height: 44px; border-radius: 50%;
border: 3px solid white;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
cursor: pointer; overflow: hidden;
background: #f97316;
background: #171717; color: white;
`;
if (photoUrl) {
el.style.backgroundImage = `url(${photoUrl})`;
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
} else {
el.innerHTML = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🐾</div>`;
el.textContent = '🐾';
}
el.onclick = () => {
selected = {
animalId: props.animalId,
species: props.species,
breed: props.breed,
animalName: props.animalName,
sightingCount: props.sightingCount,
trail
};
};
el.onclick = () => selectAnimal(feature);
const marker = new Marker({ element: el })
.setLngLat(feature.geometry.coordinates)
@@ -190,6 +237,7 @@
function applyFilters() {
showFilters = false;
selected = null;
if (MapMarker) loadMarkers(MapMarker);
}
function clearFilters() {
@@ -199,9 +247,29 @@
filterReporter = '';
filterFrom = '';
filterTo = '';
selected = null;
if (MapMarker) loadMarkers(MapMarker);
}
function locate() {
if (!navigator.geolocation || !map) {
mapError = 'Location is unavailable in this browser.';
return;
}
locating = true;
navigator.geolocation.getCurrentPosition(
(position) => {
locating = false;
map?.flyTo({ center: [position.coords.longitude, position.coords.latitude], zoom: 14 });
},
() => {
locating = false;
mapError = 'Location access was denied. You can still browse the map.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
const activeFilterCount = $derived(
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
.length
@@ -209,149 +277,223 @@
</script>
<svelte:head>
<title>Animaldex</title>
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
</svelte:head>
<div class="relative h-screen w-full overflow-hidden">
<div bind:this={mapContainer} class="h-full w-full"></div>
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
<section class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div class="mb-7 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">animaldex</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">neighborhood animals on the map</p>
</div>
<a
href={resolve('/projects/animaldex/register')}
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
class="rounded bg-[var(--site-text)] px-4 py-2.5 text-base font-medium text-[var(--site-bg)]"
>
+ Log a sighting
add an animal
</a>
<button
onclick={() => (showFilters = !showFilters)}
class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
</div>
{#if submitted}<p role="status" class="mb-5 border-y border-[var(--site-border)] py-3">
sighting submitted. it will appear after review.
</p>{/if}
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<span class="text-base text-[var(--site-muted)]"
>{animalCount === null
? mapError
? 'map unavailable'
: 'loading animals…'
: `${animalCount} ${animalCount === 1 ? 'animal' : 'animals'} in this area`}</span
>
Filters
{#if activeFilterCount > 0}
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] text-white"
>{activeFilterCount}</span
>
{/if}
</button>
<div class="flex items-center gap-5">
<button
type="button"
onclick={locate}
disabled={locating}
class="underline underline-offset-4">{locating ? 'locating…' : 'my location'}</button
>
<button
type="button"
onclick={() => (showFilters = !showFilters)}
aria-expanded={showFilters}
class="underline underline-offset-4"
>
filters{activeFilterCount ? ` (${activeFilterCount})` : ''}
</button>
</div>
</div>
{#if showFilters}
<div
class="absolute top-16 left-1/2 z-20 w-80 -translate-x-1/2 rounded-2xl bg-white p-4 shadow-xl"
>
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold text-gray-800">Filter sightings</h2>
<button onclick={() => (showFilters = false)} class="text-gray-400 hover:text-gray-600"
>✕</button
>
</div>
<div class="space-y-2">
<div class="mb-5 border-y border-[var(--site-border)] py-5">
<h2 class="mb-3 text-lg font-medium">filter sightings</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<input
bind:value={filterSpecies}
placeholder="Species"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
aria-label="Species"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<input
bind:value={filterBreed}
placeholder="Breed"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
aria-label="Breed"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<input
bind:value={filterName}
placeholder="Animal name"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
aria-label="Animal name"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<input
bind:value={filterReporter}
placeholder="Reporter name"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
aria-label="Reporter name"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-3 sm:col-span-2">
<div>
<label for="filter-from" class="mb-1 block text-xs text-gray-500">From</label>
<label for="filter-from" class="mb-1 block text-base">from</label>
<input
id="filter-from"
type="date"
bind:value={filterFrom}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
<div>
<label for="filter-to" class="mb-1 block text-xs text-gray-500">To</label>
<label for="filter-to" class="mb-1 block text-base">to</label>
<input
id="filter-to"
type="date"
bind:value={filterTo}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<div class="mt-4 flex gap-4">
<button
onclick={applyFilters}
class="flex-1 rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
>Apply</button
>
<button
onclick={clearFilters}
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
>Clear</button
class="rounded bg-[var(--site-text)] px-4 py-2 text-base text-[var(--site-bg)]"
>apply</button
>
<button onclick={clearFilters} class="text-base underline underline-offset-4">clear</button>
</div>
</div>
{/if}
<div
bind:this={mapContainer}
role="region"
aria-label="Animaldex map"
class="h-[min(56vh,560px)] min-h-80 w-full overflow-hidden rounded border border-[var(--site-border)] bg-[var(--site-surface)] sm:h-[min(65vh,640px)]"
></div>
{#if mapError}<p role="alert" class="mt-3 text-base">{mapError}</p>{/if}
{#if animalCount === 0}<p class="mt-4 text-base text-[var(--site-muted)]">
no animals here yet. try another area.
</p>{/if}
{#if selected}
<div class="absolute right-4 bottom-8 z-10 w-80 overflow-hidden rounded-2xl bg-white shadow-xl">
{#if selected.trail[0]?.photoUrl}
<div class="relative h-40 bg-gray-100">
<img src={selected.trail[0].photoUrl} alt="" class="h-full w-full object-cover" />
<div
bind:this={detailsSection}
class="mt-8 max-w-2xl border-t border-[var(--site-border)] pt-6"
>
{#if [...selected.trail].reverse().find((point) => point.photoUrl)?.photoUrl}
<div
class="relative mb-5 aspect-video max-h-80 overflow-hidden rounded bg-[var(--site-surface)]"
>
<img
src={[...selected.trail].reverse().find((point) => point.photoUrl)?.photoUrl}
alt={selected.animalName || selected.species}
class="h-full w-full object-cover"
/>
<button
onclick={() => (selected = null)}
class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
>✕</button
aria-label="Close animal details"
class="absolute top-3 right-3 rounded bg-black px-3 py-1 text-white">✕</button
>
</div>
{/if}
<div class="p-4">
<div class="mb-1 flex items-start justify-between">
<div>
<div class="mb-1 flex items-start justify-between gap-4">
<div>
<p class="text-lg font-semibold text-gray-900">
<h2 class="text-2xl font-semibold">
{selected.animalName ?? selected.species}
</p>
<p class="text-sm text-gray-500">
</h2>
<p class="mt-1 text-base text-[var(--site-muted)]">
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
</p>
</div>
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700"
<span class="text-base text-[var(--site-muted)]"
>{selected.sightingCount}
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</div>
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
<button
type="button"
onclick={() => (selected = null)}
class="text-base underline underline-offset-4">close details</button
>
<div class="mt-5 divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-xs">
<span class="text-gray-600"
<div class="flex flex-wrap items-center justify-between gap-2 py-3 text-base">
<span
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}</span
>
{#if s.reporterName}<span class="text-gray-400">by {s.reporterName}</span>{/if}
{#if s.reporterName}<span class="text-[var(--site-muted)]">by {s.reporterName}</span
>{/if}
</div>
{/each}
</div>
<a
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
class="mt-5 inline-block text-base underline underline-offset-4"
>
I saw this animal too!
i saw this animal too ↗
</a>
</div>
</div>
{/if}
</div>
{#if visibleAnimals.length}
<div class="mt-10">
<h2 class="mb-3 text-xl font-semibold">animals in this area</h2>
<ul class="grid gap-x-8 sm:grid-cols-2">
{#each visibleAnimals.slice(0, 24) as feature (feature.properties.animalId)}
{@const animal = feature.properties}
{@const photo = [...parseTrail(animal.trail)]
.reverse()
.find((point) => point.photoUrl)?.photoUrl}
<li class="border-t border-[var(--site-border)]">
<button
type="button"
onclick={() => selectAnimal(feature)}
class="flex w-full items-center gap-3 py-3 text-left hover:text-[var(--site-muted)]"
>
{#if photo}<img
src={photo}
alt=""
class="h-12 w-12 rounded object-cover"
/>{:else}<span
class="flex h-12 w-12 items-center justify-center rounded bg-[var(--site-surface)]"
>🐾</span
>{/if}
<span class="min-w-0">
<span class="block font-medium">{animal.animalName || animal.species}</span>
<span class="block text-base text-[var(--site-muted)]"
>{animal.species} · {animal.sightingCount}
{animal.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</span>
</button>
</li>
{/each}
</ul>
{#if visibleAnimals.length > 24}<p class="mt-3 text-base text-[var(--site-muted)]">
showing 24 animals. zoom in to see the others.
</p>{/if}
</div>
{/if}
</section>

View File

@@ -1 +0,0 @@
export const ssr = false;

View File

@@ -5,97 +5,63 @@
let { data }: { data: PageData } = $props();
let password = $state('');
let loginError = $state('');
let loginLoading = $state(false);
async function login() {
loginLoading = true;
loginError = '';
const res = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
await invalidateAll();
} else {
loginError = 'Wrong password';
}
loginLoading = false;
}
let actionLoading = $state<string | null>(null);
let actionError = $state('');
async function moderate(type: 'animals' | 'sightings', id: string, action: 'accept' | 'deny') {
actionLoading = `${type}-${id}-${action}`;
await fetch(`/api/${type}/${id}/moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
actionLoading = null;
await invalidateAll();
async function moderate(id: number, action: 'accept' | 'deny') {
actionLoading = `animals-${id}-${action}`;
actionError = '';
try {
const response = await fetch(resolve(`/projects/animaldex/api/animals/${id}/moderate`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
if (!response.ok) throw new Error();
await invalidateAll();
} catch {
actionError = 'Could not update this animal. Please try again.';
} finally {
actionLoading = null;
}
}
const queue = $derived(data.queue ?? []);
</script>
<svelte:head><title>Animaldex - Admin</title></svelte:head>
{#if !data.admin}
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-80 rounded-2xl bg-white p-8 shadow-xl">
<h1 class="mb-6 text-xl font-semibold text-gray-900">Admin login</h1>
<input
type="password"
bind:value={password}
placeholder="Password"
onkeydown={(e) => e.key === 'Enter' && login()}
class="mb-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
{#if loginError}
<p class="mb-3 text-xs text-red-500">{loginError}</p>
{/if}
<button
onclick={login}
disabled={loginLoading}
class="w-full rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:opacity-50"
<div class="mx-auto max-w-md p-8 text-center">
<h1 class="text-xl font-bold">Administrator access required</h1>
{#if !data.user}<a
class="mt-4 inline-block underline"
href={resolve('/login?returnTo=/projects/animaldex/admin')}>Log in with your account</a
>
{loginLoading ? 'Logging in…' : 'Log in'}
</button>
</div>
{:else}<p class="mt-4">Your account does not have administrator access.</p>{/if}
</div>
{:else}
<div class="min-h-screen bg-gray-50">
<div
class="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-6 py-3"
<section class="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14">
<a href={resolve('/projects/animaldex')} class="text-base text-[var(--site-muted)]"
>← animaldex</a
>
<div class="flex items-center gap-3">
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
<h1 class="font-semibold text-gray-900">Animaldex admin</h1>
</div>
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
<div class="mt-5 mb-8 flex flex-wrap items-end justify-between gap-4">
<h1 class="text-4xl font-semibold tracking-tight">animaldex admin</h1>
<span class="text-base text-[var(--site-muted)]">
{queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending
</span>
</div>
<div class="mx-auto max-w-3xl space-y-4 px-4 py-6">
{#if actionError}<p role="alert" class="mb-5 text-base">{actionError}</p>{/if}
<div class="space-y-4">
{#if queue.length === 0}
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
No submissions yet
</div>
<p class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
no submissions yet
</p>
{/if}
{#each queue as item (item.animalId)}
{@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
<div
class="overflow-hidden rounded-2xl bg-white shadow-sm {status === 'denied'
? 'opacity-60'
: ''}"
>
<div class="border-t border-[var(--site-border)] pt-4">
<div class="flex gap-4 p-4">
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
<div class="h-24 w-24 shrink-0 overflow-hidden rounded bg-[var(--site-surface)]">
{#if item.photoUrl}
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
{:else}
@@ -105,30 +71,23 @@
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-semibold text-gray-900">{item.animalName ?? item.species}</p>
<p class="text-sm text-gray-500">
<p class="font-semibold">{item.animalName ?? item.species}</p>
<p class="text-base text-[var(--site-muted)]">
{item.species}{item.breed ? ` · ${item.breed}` : ''}
</p>
</div>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
{status === 'accepted'
? 'bg-green-100 text-green-700'
: status === 'denied'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'}"
>
<span class="shrink-0 text-base text-[var(--site-muted)]">
{status}
</span>
</div>
{#if item.aiBreedSuggestion}
<p class="mt-1 text-xs text-gray-400">
<p class="mt-1 text-base text-[var(--site-muted)]">
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
(item.aiBreedConfidence ?? 0) * 100
)}%)
</p>
{/if}
<p class="mt-1 text-xs text-gray-400">
<p class="mt-1 text-base text-[var(--site-muted)]">
{new Date(item.submittedAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
@@ -138,27 +97,23 @@
})}
{#if item.reporterName}· by {item.reporterName}{/if}
</p>
<p class="mt-0.5 text-xs text-gray-400">
<p class="mt-0.5 text-base text-[var(--site-muted)]">
📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)}
</p>
</div>
</div>
<div class="flex gap-2 border-t border-gray-100 px-4 py-3">
<div class="flex gap-3 border-t border-[var(--site-border)] px-4 py-3">
<button
onclick={() => moderate('animals', item.animalId, 'accept')}
onclick={() => moderate(item.animalId, 'accept')}
disabled={actionLoading !== null || status === 'accepted'}
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
? 'cursor-default bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
class="flex-1 rounded border border-[var(--site-border)] py-2 text-base disabled:opacity-50"
>
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
</button>
<button
onclick={() => moderate('animals', item.animalId, 'deny')}
onclick={() => moderate(item.animalId, 'deny')}
disabled={actionLoading !== null || status === 'denied'}
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
? 'cursor-default bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
class="flex-1 rounded border border-[var(--site-border)] py-2 text-base disabled:opacity-50"
>
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
</button>
@@ -166,5 +121,5 @@
</div>
{/each}
</div>
</div>
</section>
{/if}

View File

@@ -1,25 +1,3 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { z } from 'zod';
const loginSchema = z.object({ password: z.string().min(1) });
export const POST: RequestHandler = async ({ request, cookies }) => {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
throw error(401, { message: 'Invalid password' });
}
cookies.set('admin_token', env.ADMIN_SECRET, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
});
return json({ ok: true });
};
import { error, redirect } from '@sveltejs/kit';
export const GET = () => redirect(303, '/login');
export const POST = () => error(410, 'Use the central account login at /login');

View File

@@ -3,8 +3,8 @@ import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings, photos } from '$lib/server/db/schema';
import { registerAnimalSchema } from '$lib/validation/sighting';
import { sql } from 'drizzle-orm';
import { env } from '$env/dynamic/private';
import { distanceMeters } from '$lib/geo';
const MAX_DISTANCE_METERS = 30_000;
@@ -18,14 +18,7 @@ export const POST: RequestHandler = async ({ request }) => {
const data = parsed.data;
const [distRow] = await db.execute(sql`
SELECT ST_Distance(
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
ST_MakePoint(${data.lng}, ${data.lat})::geography
) AS dist
`);
const dist = Number((distRow as Record<string, unknown>).dist);
const dist = distanceMeters(data.deviceLat, data.deviceLng, data.lat, data.lng);
if (dist > MAX_DISTANCE_METERS) {
throw error(422, {
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`

View File

@@ -9,6 +9,9 @@ import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const id = Number(event.params.id);
if (!Number.isInteger(id) || id <= 0) throw error(404, { message: 'Not found' });
const body = await event.request.json().catch(() => null);
const parsed = moderationActionSchema.safeParse(body);
@@ -26,14 +29,14 @@ export const POST: RequestHandler = async (event) => {
acceptedAt: isAccept ? now : null,
deniedAt: isAccept ? null : now
})
.where(eq(animals.id, event.params.id))
.where(eq(animals.id, id))
.returning({ id: animals.id });
if (result.length && isAccept) {
await tx
.update(sightings)
.set({ acceptedAt: now, deniedAt: null })
.where(eq(sightings.animalId, event.params.id));
.where(eq(sightings.animalId, id));
}
return result;

View File

@@ -5,20 +5,9 @@ import { animals, sightings, photos } from '$lib/server/db/schema';
import { addSightingSchema } from '$lib/validation/sighting';
import { eq } from 'drizzle-orm';
import { env } from '$env/dynamic/private';
import { distanceMeters } from '$lib/geo';
const MAX_DISTANCE_METERS = 30_000;
const EARTH_RADIUS_METERS = 6_371_000;
// Haversine distance between two lat/lng points, in meters.
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
}
export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json().catch(() => null);
@@ -33,7 +22,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
const [animal] = await db
.select({ id: animals.id, acceptedAt: animals.acceptedAt })
.from(animals)
.where(eq(animals.id, params.id))
.where(eq(animals.id, data.animalId))
.limit(1);
if (!animal) {

View File

@@ -9,6 +9,9 @@ import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const id = Number(event.params.id);
if (!Number.isInteger(id) || id <= 0) throw error(404, { message: 'Not found' });
const body = await event.request.json().catch(() => null);
const parsed = moderationActionSchema.safeParse(body);
@@ -25,7 +28,7 @@ export const POST: RequestHandler = async (event) => {
acceptedAt: isAccept ? now : null,
deniedAt: isAccept ? null : now
})
.where(eq(sightings.id, event.params.id))
.where(eq(sightings.id, id))
.returning({ id: sightings.id });
if (!updated) {

View File

@@ -12,6 +12,6 @@ export const POST: RequestHandler = async ({ request }) => {
throw error(400, { message: 'imageUrl is required' });
}
// const result = await detectBreed(parsed.data.imageUrl);
return json(result ?? { breed: null, confidence: 0 });
// Breed detection is not configured for this site yet.
return json({ breed: null, confidence: 0 });
};

View File

@@ -57,14 +57,14 @@ export const GET: RequestHandler = async ({ url }) => {
.orderBy(sightings.seenAt);
const animalMap = new Map<
string,
number,
{
animalId: string;
animalId: number;
species: string;
breed: string | null;
animalName: string | null;
sightings: Array<{
id: string;
id: number;
lat: number;
lng: number;
seenAt: Date;
@@ -118,10 +118,5 @@ export const GET: RequestHandler = async ({ url }) => {
};
});
console.log(
`[map] query returned ${rows.length} rows for bbox ${q.minLng},${q.minLat} → ${q.maxLng},${q.maxLat}`
);
console.log(`[map] built ${features.length} features`);
return json({ type: 'FeatureCollection', features });
};

View File

@@ -1,22 +1,18 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2';
import { presignedUploadSchema } from '$lib/validation/sighting';
import { uploadImage, isAllowedMimeType } from '$lib/server/r2';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = presignedUploadSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'Invalid request body' });
const form = await request.formData().catch(() => null);
const photo = form?.get('photo');
if (!(photo instanceof File) || !isAllowedMimeType(photo.type))
throw error(400, 'Use a JPEG, PNG, WebP, or HEIC photo.');
if (!photo.size || photo.size > 5 * 1024 * 1024)
throw error(400, 'Photos must be 5 MB or smaller.');
try {
return json(await uploadImage(photo));
} catch (cause) {
console.error('Animaldex image upload failed', cause);
throw error(503, 'Image storage is unavailable.');
}
const { mimeType } = parsed.data;
if (!isAllowedMimeType(mimeType)) {
throw error(400, { message: 'Unsupported image type' });
}
const result = await createPresignedUpload(mimeType);
return json(result);
};

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -14,14 +14,16 @@
let animalName = $state('');
let description = $state('');
let reporterName = $state('');
let seenAt = $state(new Date().toISOString().slice(0, 16));
let seenAt = $state(localDateTime());
let aiSuggestion = $state<{ breed: string; confidence: number } | null>(null);
let aiLoading = $state(false);
let aiDismissed = $state(false);
function localDateTime() {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
let photoFiles = $state<File[]>([]);
let photoPreviews = $state<string[]>([]);
let draggingPhotos = $state(false);
let lat = $state<number | null>(null);
let lng = $state<number | null>(null);
@@ -33,6 +35,8 @@
let mapContainer = $state<HTMLDivElement>(null!);
let map: MaptilerMap | null = null;
let marker: MaptilerMarker | null = null;
let mapError = $state('');
let destroyed = false;
let submitting = $state(false);
let submitError = $state('');
@@ -46,9 +50,26 @@
const COMMON_SPECIES = ['Dog', 'Cat', 'Fox', 'Bird', 'Rabbit', 'Deer', 'Squirrel', 'Hedgehog'];
const finalSpecies = $derived(species === '__custom__' ? customSpecies : species);
onMount(async () => {
onMount(() => {
reporterName = localStorage.getItem('reporterName') ?? '';
requestLocation();
});
onDestroy(() => {
destroyed = true;
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
marker?.remove();
map?.remove();
});
function requestLocation() {
locationError = '';
locationLoading = true;
if (!navigator.geolocation) {
locationLoading = false;
locationError = 'Location is unavailable in this browser.';
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
deviceLat = pos.coords.latitude;
@@ -59,77 +80,78 @@
},
() => {
locationLoading = false;
locationError = 'Location access denied. GPS is required to register a sighting.';
locationError = 'Allow location access to log a sighting near you.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
}
async function initMap() {
if (!mapContainer || lat === null || lng === null) return;
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
map = new Map({
container: mapContainer,
style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center: [lng!, lat!],
zoom: 15
});
map.on('load', () => {
marker = new Marker({ draggable: true }).setLngLat([lng!, lat!]).addTo(map!);
marker.on('dragend', () => {
const lngLat = marker!.getLngLat();
lat = lngLat.lat;
lng = lngLat.lng;
try {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
if (destroyed) return;
if (!env.PUBLIC_MAPTILER_API_KEY) {
mapError = 'The map is not configured yet. Your GPS location is still available.';
return;
}
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
map = new Map({
container: mapContainer,
style: `https://api.maptiler.com/maps/satellite/style.json?key=${env.PUBLIC_MAPTILER_API_KEY}`,
center: [lng!, lat!],
zoom: 15
});
});
map.on('load', () => {
marker = new Marker({ draggable: true }).setLngLat([lng!, lat!]).addTo(map!);
marker.on('dragend', () => {
const lngLat = marker!.getLngLat();
lat = lngLat.lat;
lng = lngLat.lng;
});
});
} catch {
mapError = 'The map could not load. You can still submit using your GPS location.';
}
}
function handlePhotoChange(e: Event) {
const input = e.target as HTMLInputElement;
const newFiles = Array.from(input.files ?? []).slice(0, 10 - photoFiles.length);
addPhotos(Array.from(input.files ?? []));
input.value = '';
}
function addPhotos(files: File[]) {
const newFiles = files.slice(0, 10 - photoFiles.length);
if (
newFiles.some(
(file) => !['image/jpeg', 'image/png', 'image/webp', 'image/heic'].includes(file.type)
)
) {
submitError = 'Use JPEG, PNG, WebP, or HEIC photos.';
return;
}
if (newFiles.some((file) => file.size > 5 * 1024 * 1024)) {
submitError = 'Photos must be 5 MB or smaller.';
return;
}
submitError = '';
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
photoFiles = [...photoFiles, ...newFiles].slice(0, 10);
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
if (photoFiles.length === 1 && !isNewSighting) runAIDetection(photoFiles[0]);
}
function dropPhotos(event: DragEvent) {
event.preventDefault();
draggingPhotos = false;
addPhotos(Array.from(event.dataTransfer?.files ?? []));
}
function removePhoto(index: number) {
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
photoFiles = photoFiles.filter((_, i) => i !== index);
photoPreviews = photoPreviews.filter((_, i) => i !== index);
}
async function runAIDetection(file: File) {
aiLoading = true;
aiDismissed = false;
try {
const presignRes = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mimeType: file.type })
});
if (!presignRes.ok) return;
const { uploadUrl, publicUrl } = await presignRes.json();
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
const aiRes = await fetch('/api/detect-breed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: publicUrl })
});
if (!aiRes.ok) return;
const result = await aiRes.json();
if (result.breed) {
aiSuggestion = result;
if (!breed) breed = result.breed;
}
} catch {
/* silent */
} finally {
aiLoading = false;
}
photoPreviews = photoFiles.map((file) => URL.createObjectURL(file));
}
async function submit() {
@@ -152,25 +174,22 @@
const photoKeys: string[] = [];
if (photoFiles.length > 0) {
for (const file of photoFiles) {
const presignRes = await fetch('/api/upload', {
const uploadForm = new FormData();
uploadForm.set('photo', file);
const uploadRes = await fetch(resolve('/projects/animaldex/api/upload'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mimeType: file.type })
body: uploadForm
});
if (!presignRes.ok) throw new Error('Failed to get upload URL');
const { key, uploadUrl } = await presignRes.json();
const uploadRes = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type }
});
if (!uploadRes.ok) throw new Error('Photo upload failed');
if (!uploadRes.ok) throw new Error('Photo upload failed. Please try again.');
const { key } = await uploadRes.json();
photoKeys.push(key);
}
}
if (reporterName) localStorage.setItem('reporterName', reporterName);
const endpoint = isNewSighting ? `/api/animals/${animalId}/sightings` : '/api/animals';
const endpoint = isNewSighting
? resolve(`/projects/animaldex/api/animals/${animalId}/sightings`)
: resolve('/projects/animaldex/api/animals');
const body = isNewSighting
? {
animalId,
@@ -188,8 +207,6 @@
animalName: animalName || undefined,
description: description || undefined,
reporterName: reporterName || undefined,
aiBreedSuggestion: aiSuggestion?.breed,
aiBreedConfidence: aiSuggestion?.confidence,
seenAt: new Date(seenAt).toISOString(),
lat,
lng,
@@ -207,7 +224,7 @@
const err = await res.json();
throw new Error(err.message ?? 'Submission failed');
}
goto(resolve('/?submitted=1'));
goto(resolve('/projects/animaldex?submitted=1'));
} catch (e: unknown) {
submitError = e instanceof Error ? e.message : 'Something went wrong.';
} finally {
@@ -217,46 +234,49 @@
</script>
<svelte:head>
<title>Animaldex - Log a sighting</title>
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
</svelte:head>
<div class="min-h-screen bg-gray-50">
<div
class="sticky top-0 z-10 flex items-center gap-3 border-b border-gray-100 bg-white px-4 py-3"
<section class="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/animaldex')}
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← animaldex</a
>
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
<h1 class="font-semibold text-gray-900">
{isNewSighting ? 'Log another sighting' : 'Register an animal'}
</h1>
</div>
<h1 class="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl">
{isNewSighting ? 'add a sighting' : 'add an animal'}
</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">
{isNewSighting ? 'mark where you saw this animal' : 'add a neighborhood animal to the map'}
</p>
<div class="mx-auto max-w-lg space-y-5 px-4 py-6">
<div class="mt-8 space-y-8">
{#if locationError}
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<p class="font-medium">📍 Location required</p>
<div role="alert" class="border-y border-[var(--site-border)] py-4 text-base">
<p class="font-medium">location required</p>
<p class="mt-1">{locationError}</p>
<p class="mt-2 text-xs">
Go to <strong>Settings → Site permissions → Location</strong> and allow this site.
</p>
<button type="button" onclick={requestLocation} class="mt-3 underline underline-offset-4"
>try again</button
>
</div>
{/if}
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-3 text-sm font-semibold text-gray-700">
Photos
<section class="border-t border-[var(--site-border)] pt-6">
<h2 class="mb-3 text-xl font-semibold">
photos
{#if isNewSighting}
<span class="text-xs font-normal text-gray-400">(optional)</span>
<span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
{:else}
<span class="text-red-400">*</span>
<span class="text-base font-normal text-[var(--site-muted)]">(at least one)</span>
{/if}
</h2>
<div class="flex flex-wrap gap-2">
{#each photoPreviews as preview, i (i)}
<div class="relative h-20 w-20 overflow-hidden rounded-xl">
<div class="relative h-20 w-20 overflow-hidden rounded">
<img src={preview} alt="" class="h-full w-full object-cover" />
<button
type="button"
onclick={() => removePhoto(i)}
aria-label={`Remove photo ${i + 1}`}
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-xs text-white"
>✕</button
>
@@ -264,59 +284,36 @@
{/each}
{#if photoFiles.length < 10}
<label
class="flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-orange-300"
class="relative flex min-h-28 w-full cursor-pointer items-center justify-center rounded border border-dashed border-[var(--site-border)] text-center text-base text-[var(--site-muted)] hover:text-[var(--site-text)] focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--site-text)] {draggingPhotos
? 'bg-[var(--site-surface)]'
: ''}"
ondragover={(event) => {
event.preventDefault();
draggingPhotos = true;
}}
ondragleave={() => (draggingPhotos = false)}
ondrop={dropPhotos}
>
<span class="text-2xl">+</span>
<span class="text-xs">Photo</span>
<span>drop photos here or choose files</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
class="hidden"
class="absolute inset-0 w-full cursor-pointer opacity-0"
onchange={handlePhotoChange}
/>
</label>
{/if}
</div>
<p class="mt-2 text-xs text-gray-400">Max 10 photos · JPEG, PNG, WebP, HEIC</p>
<p class="mt-3 text-base text-[var(--site-muted)]">
up to 10 photos · JPEG, PNG, WebP, HEIC · 5 MB each
</p>
</section>
{#if aiLoading}
<div class="flex items-center gap-2 rounded-xl bg-orange-50 p-3 text-sm text-orange-700">
<span class="animate-spin">⟳</span> Identifying breed…
</div>
{:else if aiSuggestion && !aiDismissed}
<div class="rounded-xl border border-orange-200 bg-orange-50 p-3">
<p class="text-sm font-medium text-orange-800">
🤖 AI thinks this is a <strong>{aiSuggestion.breed}</strong>
<span class="text-xs font-normal text-orange-600"
>({Math.round(aiSuggestion.confidence * 100)}% confident)</span
>
</p>
<div class="mt-2 flex gap-2">
<button
onclick={() => {
if (aiSuggestion) breed = aiSuggestion.breed;
}}
class="rounded-lg bg-orange-500 px-3 py-1 text-xs font-semibold text-white"
>Use this</button
>
<button
onclick={() => {
aiDismissed = true;
aiSuggestion = null;
}}
class="rounded-lg border border-orange-200 px-3 py-1 text-xs text-orange-700"
>Dismiss</button
>
</div>
</div>
{/if}
{#if !isNewSighting}
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-3 text-sm font-semibold text-gray-700">
Species <span class="text-red-400">*</span>
<section class="border-t border-[var(--site-border)] pt-6">
<h2 class="mb-3 text-xl font-semibold">
Species <span class="text-[var(--site-muted)]">*</span>
</h2>
<div class="flex flex-wrap gap-2">
{#each COMMON_SPECIES as s (s)}
@@ -325,107 +322,120 @@
species = s;
customSpecies = '';
}}
class="rounded-full px-3 py-1.5 text-sm {species === s
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">{s}</button
class="rounded border border-[var(--site-border)] px-3 py-1.5 text-base {species === s
? 'bg-[var(--site-text)] text-[var(--site-bg)]'
: 'bg-[var(--site-surface)] text-[var(--site-text)] hover:bg-[var(--site-hover)]'}"
>{s}</button
>
{/each}
<button
onclick={() => {
species = '__custom__';
}}
class="rounded-full px-3 py-1.5 text-sm {species === '__custom__'
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Other…</button
class="rounded border border-[var(--site-border)] px-3 py-1.5 text-base {species ===
'__custom__'
? 'bg-[var(--site-text)] text-[var(--site-bg)]'
: 'bg-[var(--site-surface)] text-[var(--site-text)] hover:bg-[var(--site-hover)]'}"
>Other…</button
>
</div>
{#if species === '__custom__'}
<input
bind:value={customSpecies}
placeholder="Enter species"
class="mt-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="mt-3 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
{/if}
</section>
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Breed <span class="text-xs font-normal text-gray-400">(optional)</span>
<section class="border-t border-[var(--site-border)] pt-6">
<h2 class="mb-1 text-xl font-semibold">
Breed <span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
</h2>
<input
bind:value={breed}
placeholder="e.g. Golden Retriever"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</section>
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<section class="space-y-3 border-t border-[var(--site-border)] pt-6">
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Animal name <span class="text-xs font-normal text-gray-400">(optional)</span>
<h2 class="mb-1 text-xl font-semibold">
Animal name <span class="text-base font-normal text-[var(--site-muted)]"
>(optional)</span
>
</h2>
<input
bind:value={animalName}
placeholder="e.g. Fluffy"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Description <span class="text-xs font-normal text-gray-400">(optional)</span>
<h2 class="mb-1 text-xl font-semibold">
Description <span class="text-base font-normal text-[var(--site-muted)]"
>(optional)</span
>
</h2>
<textarea
bind:value={description}
placeholder="Behaviour, features, context…"
rows="3"
class="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="w-full resize-none rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
></textarea>
</div>
</section>
{/if}
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<section class="space-y-3 border-t border-[var(--site-border)] pt-6">
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
When did you see it? <span class="text-red-400">*</span>
<h2 class="mb-1 text-xl font-semibold">
When did you see it? <span class="text-[var(--site-muted)]">*</span>
</h2>
<input
type="datetime-local"
bind:value={seenAt}
max={new Date().toISOString().slice(0, 16)}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
max={localDateTime()}
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Your name <span class="text-xs font-normal text-gray-400">(optional)</span>
<h2 class="mb-1 text-xl font-semibold">
Your name <span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
</h2>
<input
bind:value={reporterName}
placeholder="Saved for next time"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
</section>
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-2 text-sm font-semibold text-gray-700">
Location <span class="text-red-400">*</span>
<section class="border-t border-[var(--site-border)] pt-6">
<h2 class="mb-2 text-xl font-semibold">
Location <span class="text-[var(--site-muted)]">*</span>
</h2>
{#if locationLoading}
<div
class="flex h-40 items-center justify-center rounded-xl bg-gray-100 text-sm text-gray-400"
class="flex h-64 items-center justify-center rounded bg-[var(--site-surface)] text-base text-[var(--site-muted)]"
>
Getting your location…
</div>
{:else if lat !== null}
<div bind:this={mapContainer} class="h-48 w-full overflow-hidden rounded-xl"></div>
<p class="mt-2 text-xs text-gray-400">Drag the pin to fine-tune (max 30 km from you).</p>
<div
bind:this={mapContainer}
class="h-64 w-full overflow-hidden rounded border border-[var(--site-border)]"
></div>
{#if mapError}<p role="alert" class="mt-2 text-base">{mapError}</p>{/if}
<p class="mt-2 text-base text-[var(--site-muted)]">
Drag the pin to fine-tune (max 30 km from you).
</p>
{/if}
</section>
{#if submitError}
<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div role="alert" class="border-y border-[var(--site-border)] py-3 text-base">
{submitError}
</div>
{/if}
@@ -433,13 +443,13 @@
<button
onclick={submit}
disabled={submitting || !!locationError || lat === null}
class="w-full rounded-xl bg-orange-500 py-3 font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
class="w-full rounded bg-[var(--site-text)] py-3 font-medium text-[var(--site-bg)] disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
</button>
<p class="pb-8 text-center text-xs text-gray-400">
<p class="pb-8 text-center text-base text-[var(--site-muted)]">
All submissions are reviewed before appearing on the map.
</p>
</div>
</div>
</section>

View File

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

View File

@@ -1,30 +1,7 @@
<script lang="ts">
import './layout.css';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let { 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>
<div class="p-4">{@render children()}</div>

View File

@@ -1,6 +1,12 @@
import { db } from '$lib/server/db';
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
import {
games,
pokemonGameEntries,
userEntries,
userGames,
catchEmAllSettings
} from '$lib/server/db/schema';
import { and, eq, inArray } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
import { availabilityValues } from '$lib/availability';
import { fail } from '@sveltejs/kit';
@@ -11,7 +17,7 @@ export const load: PageServerLoad = async ({ locals }) => {
with: {
entry: true
},
orderBy: (pokemon) => pokemon.number
orderBy: { number: 'asc' }
});
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
...rest,
@@ -21,27 +27,39 @@ export const load: PageServerLoad = async ({ locals }) => {
})
}));
const games = await db.query.game.findMany({
orderBy: (game) => [game.generation, game.id]
const gameRows = await db.query.games.findMany({
orderBy: { generation: 'asc', id: 'asc' }
});
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: userEntry.pokemonGameEntryId })
.from(userEntry)
.where(eq(userEntry.userId, locals.user.id));
.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: userGame.gameId })
.from(userGame)
.where(eq(userGame.userId, locals.user.id));
.select({ id: userGames.gameId })
.from(userGames)
.where(eq(userGames.userId, locals.user.id));
selectedGameIds = gameRows.map((r) => r.id);
}
return { pokemon, games, caughtIds, selectedGameIds };
return {
pokemon,
games: gameRows,
caughtIds,
selectedGameIds,
gamesConfigured: gamesConfigured || selectedGameIds.length > 0
};
};
export const actions = {
@@ -50,58 +68,71 @@ export const actions = {
const data = await request.formData();
const entryId = Number(data.get('entryId'));
if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' });
if (!Number.isSafeInteger(entryId) || entryId <= 0 || entryId > 2147483647)
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));
.select({ availability: pokemonGameEntries.availability })
.from(pokemonGameEntries)
.where(eq(pokemonGameEntries.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' });
return fail(400, { message: 'This entry does not have confirmed encounter data' });
}
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(userEntry)
.insert(userEntries)
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntry)
.delete(userEntries)
.where(
and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id))
and(eq(userEntries.pokemonGameEntryId, entryId), 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 gameIds = data
.getAll('gameIds')
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0);
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: game.id })
.from(game)
.where(inArray(game.id, gameIds));
.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.delete(userGame).where(eq(userGame.userId, userId));
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(userGame).values(gameIds.map((gameId) => ({ userId, gameId })));
await tx.insert(userGames).values(gameIds.map((gameId) => ({ userId, gameId })));
}
});
return { saved: true };
}
} satisfies Actions;

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import { error } from '@sveltejs/kit';
import { raceBySlug, races } from '$lib/derby/races';
import type { EntryGenerator, PageLoad } from './$types';
export const entries: EntryGenerator = () => races.map((r) => ({ race: r.slug }));
export const load: PageLoad = ({ params }) => {
const race = raceBySlug(params.race);
if (!race) error(404, 'Onbekende koers');
return { race };
};

View File

@@ -1,8 +1,11 @@
import { isAdmin, requireAdmin } from '$lib/server/authorization';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { tasks, tasksToTopics, topics } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
export const load = async ({ cookies, params }) => {
export const load: PageServerLoad = async (event) => {
const { params } = event;
return {
task: await db.query.tasks.findFirst({
with: {
@@ -17,28 +20,24 @@ export const load = async ({ cookies, params }) => {
}
}),
topics: await db.query.topics.findMany(),
editable: cookies.get('token')
editable: isAdmin(event)
};
};
export const actions = {
createTopic: async ({ cookies, request, params }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
export const actions: Actions = {
createTopic: async (event) => {
requireAdmin(event);
const { request, params } = event;
const data = await request.formData();
const topic = await db
.insert(topics)
.values({ name: data.get('name') })
.values({ name: String(data.get('name') ?? '').trim() })
.returning();
const existing = await db.query.tasksToTopics.findFirst({
where: and(
eq(tasksToTopics.taskId, Number(params.id)),
eq(tasksToTopics.topicId, topic[0].id)
)
where: { taskId: Number(params.id), topicId: topic[0].id }
});
if (!existing) {
@@ -48,21 +47,18 @@ export const actions = {
.returning();
}
},
addTopic: async ({ cookies, request, params }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
addTopic: async (event) => {
requireAdmin(event);
const { request, params } = event;
const data = await request.formData();
const topicId = Number(data.get('topic-id'));
const existing = await db.query.tasksToTopics.findFirst({
where: and(eq(tasksToTopics.taskId, Number(params.id)), eq(tasksToTopics.topicId, topicId))
where: { taskId: Number(params.id), topicId }
});
console.log(existing);
if (!existing) {
await db
.insert(tasksToTopics)
@@ -70,17 +66,16 @@ export const actions = {
.returning();
}
},
removeTopic: async ({ cookies, request, params }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
removeTopic: async (event) => {
requireAdmin(event);
const { request, params } = event;
const data = await request.formData();
const topicId = Number(data.get('topic-id'));
const existing = await db.query.tasksToTopics.findFirst({
where: and(eq(tasksToTopics.taskId, Number(params.id)), eq(tasksToTopics.topicId, topicId))
where: { taskId: Number(params.id), topicId }
});
if (existing) {
@@ -91,10 +86,9 @@ export const actions = {
);
}
},
updateProgress: async ({ cookies, request, params }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
updateProgress: async (event) => {
requireAdmin(event);
const { request, params } = event;
const data = await request.formData();
@@ -112,10 +106,9 @@ export const actions = {
})
.where(eq(tasks.id, Number(params.id)));
},
updateNotes: async ({ cookies, request, params }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
updateNotes: async (event) => {
requireAdmin(event);
const { request, params } = event;
const data = await request.formData();

View File

@@ -1,20 +1,21 @@
import { requireAdmin } from '$lib/server/authorization';
import type { Actions } from './$types';
import { db } from '$lib/server/db';
import { tasks } from '$lib/server/db/schema';
import { redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ cookies, request }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
export const actions: Actions = {
default: async (event) => {
requireAdmin(event);
const { request } = event;
const data = await request.formData();
const task = await db
.insert(tasks)
.values({ name: data.get('name') })
.values({ name: String(data.get('name') ?? '').trim() })
.returning();
redirect(303, '/progress/' + task[0].id);
redirect(303, '/projects/progress/' + task[0].id);
}
};

View File

@@ -0,0 +1,75 @@
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { parseSpotifyInput } from '$lib/map-finder/input';
import type { TrackMatchResult } from '$lib/map-finder/types';
import { getSpotifyMetadata } from '$lib/server/spotify';
import { getOsuMaps, MATCH_CONCURRENCY } from '$lib/server/osu';
import { ApiError, publicError, logApiError, mapConcurrent } from '$lib/server/finder-http';
import { acquireSearch } from '$lib/server/finder-limit';
export const actions: Actions = {
default: async ({ request, fetch, getClientAddress }) => {
const data = await request.formData();
const value = data.get('query');
const query = typeof value === 'string' ? value.trim().slice(0, 501) : '';
let input;
try {
input = parseSpotifyInput(query);
} catch (error) {
return fail(400, { query, error: (error as Error).message });
}
if (input.kind === 'unsupported')
return fail(400, {
query,
error: `Unsupported Spotify resource: ${input.resource}. Use a track, playlist, or album.`
});
let address: string;
try {
address = getClientAddress();
} catch {
address = 'shared-unknown';
}
const release = acquireSearch(
address,
input.kind === 'playlist' || input.kind === 'album' ? 6 : 1
);
if (!release)
return fail(429, {
query,
error:
'Site search limit reached. Wait up to 10 minutes, and finish any pending search before trying again.'
});
try {
const metadata = await getSpotifyMetadata(fetch, input);
const results = await mapConcurrent(
metadata.tracks,
MATCH_CONCURRENCY,
async (track): Promise<TrackMatchResult> => {
try {
const matches = await getOsuMaps(fetch, track);
return {
track,
matches,
status: matches.some((m) => m.confidence !== 'low') ? 'matched' : 'no_match'
};
} catch (error) {
logApiError(error);
return { track, matches: [], status: 'error', error: publicError(error) };
}
}
);
return { query, results, collection: metadata.collection, truncated: metadata.truncated };
} catch (error) {
logApiError(error);
return fail(
error instanceof ApiError && error.status === 429
? 429
: error instanceof ApiError && [403, 404].includes(error.status)
? 404
: 502,
{ query, error: publicError(error) }
);
} finally {
release();
}
}
};

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import Artwork from '$lib/map-finder/Artwork.svelte';
import MapCard from '$lib/map-finder/MapCard.svelte';
let { form }: PageProps = $props();
let pending = $state(false);
let filter = $state('all');
const filters = [
{ key: 'all', label: 'All' },
{ key: 'matched', label: 'Matched' },
{ key: 'no_match', label: 'No match' },
{ key: 'error', label: 'Errors' }
];
let results = $derived(form?.results ?? []);
let visible = $derived(results.filter((result) => filter === 'all' || result.status === filter));
let matched = $derived(results.filter((result) => result.status === 'matched').length);
</script>
<div class="mx-auto max-w-5xl px-4 py-6 sm:py-12">
<div class="rounded-3xl bg-white p-5 text-gray-800 shadow sm:p-10">
<h1 class="mb-6 text-center text-3xl font-semibold lg:text-4xl">Spotify Map Finder</h1>
<form
method="POST"
class="space-y-4"
aria-busy={pending}
use:enhance={() => {
pending = true;
return async ({ update }) => {
try {
filter = 'all';
await update({ reset: false });
} finally {
pending = false;
}
};
}}
>
<label for="query" class="sr-only">Song or Spotify URL</label>
<input
id="query"
name="query"
type="text"
value={form?.query ?? ''}
required
maxlength="500"
placeholder="Deftones Change or a Spotify URL"
aria-describedby="input-help"
class="block w-full rounded border border-gray-300 bg-gray-100 p-3 focus-visible:outline-2 focus-visible:outline-blue-600"
/>
<p id="input-help" class="text-sm text-gray-600">
Search a song, or enter a Spotify track, playlist or album URL/URI. Up to 50 tracks.
</p>
<button
disabled={pending}
class="w-full rounded bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-60"
>{pending ? 'Searching…' : 'Search'}</button
>
</form>
<div role="status">
{#if pending}<p class="mt-4 text-sm text-gray-600">
Searching for maps. Playlists and albums may take a few minutes.
</p>{/if}
</div>
{#if form?.error}<p role="alert" class="mt-4 text-red-700">{form.error}</p>{/if}
{#if form?.results}
<section class="mt-8" aria-label="Search results" aria-busy={pending}>
{#if form.collection}
<div class="mb-4 flex items-start gap-3">
<Artwork src={form.collection.image} class="h-16 w-16 shrink-0" />
<div class="min-w-0 break-words">
<h2 class="text-xl font-semibold">{form.collection.name}</h2>
{#if form.collection.owner}<p class="text-sm text-gray-600">
{form.collection.owner}
</p>{/if}
<p class="text-sm">{results.length} tracks processed · {matched} matched</p>
<a
class="text-sm text-blue-700 underline"
href={`https://open.spotify.com/${form.collection.kind}/${form.collection.id}`}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>
</div>
</div>
{#if form.truncated}<p class="mb-4 text-sm text-amber-800">
Showing the first {results.length} usable tracks. The collection reached the processing
limit.
</p>{/if}
<label class="mb-6 block text-sm"
>Show
<select bind:value={filter} class="ml-2 rounded border border-gray-300 bg-white p-2">
{#each filters as option (option.key)}<option value={option.key}
>{option.label}</option
>{/each}
</select>
</label>
{/if}
{#if !results.length}<p class="text-gray-600">
No usable tracks found.
</p>{:else if !visible.length}<p class="text-gray-600">No tracks in this filter.</p>{/if}
<ul class="space-y-8">
{#each visible as result (result.track.id)}
<li>
<div class="mb-4 flex items-start gap-3">
<Artwork src={result.track.album.images[0]?.url} class="h-16 w-16 shrink-0" />
<div class="min-w-0 break-words">
<h3 class="font-semibold">
{result.track.artists.map((artist) => artist.name).join(', ')} – {result.track
.name}
</h3>
<p class="text-sm text-gray-600">{result.track.album.name}</p>
<a
class="text-sm text-blue-700 underline"
href={`https://open.spotify.com/track/${result.track.id}`}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>
</div>
</div>
{#if result.status === 'error'}<p class="text-red-700">{result.error}</p>{:else}
{#if result.status === 'no_match'}<p class="mb-3 text-gray-600">
No confident match found.
</p>{/if}
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each result.matches.filter((map) => map.confidence !== 'low') as map (map.id)}<li
>
<MapCard {map} />
</li>{/each}
</ul>
{#if result.matches.some((map) => map.confidence === 'low')}
<details class="mt-4">
<summary class="cursor-pointer text-sm text-gray-600"
>Low-confidence candidates ({result.matches.filter(
(map) => map.confidence === 'low'
).length})</summary
>
<ul class="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each result.matches.filter((map) => map.confidence === 'low') as map (map.id)}<li
>
<MapCard {map} />
</li>{/each}
</ul>
</details>
{/if}
{/if}
</li>
{/each}
</ul>
</section>
{/if}
</div>
</div>

View File

@@ -0,0 +1,61 @@
import { expect, it, vi } from 'vitest';
vi.mock('$lib/server/spotify', () => ({ getSpotifyMetadata: vi.fn() }));
vi.mock('$lib/server/osu', () => ({ getOsuMaps: vi.fn(), MATCH_CONCURRENCY: 3 }));
import { actions } from './+page.server';
import { getSpotifyMetadata } from '$lib/server/spotify';
import { getOsuMaps } from '$lib/server/osu';
import type { RequestEvent } from './$types';
const event = (query: string, address = 'validation-test') => {
const data = new FormData();
data.set('query', query);
return {
request: new Request('https://site.test', { method: 'POST', body: data }),
fetch: vi.fn(),
getClientAddress: () => address
} as unknown as RequestEvent;
};
it.each(['', 'spotify:track:bad', 'https://open.spotify.com/artist/6PUzxtIHkv346yP89NzP9X'])(
'validates before making API calls: %s',
async (query) => {
const result = await actions.default(event(query));
expect(result).toMatchObject({ status: 400 });
expect(getSpotifyMetadata).not.toHaveBeenCalled();
}
);
it('keeps successful tracks when another match fails', async () => {
const track = {
id: '6PUzxtIHkv346yP89NzP9X',
name: 'Song',
artists: [{ name: 'Artist' }],
album: { name: '', images: [] }
};
vi.mocked(getSpotifyMetadata).mockResolvedValue({
tracks: [track, { ...track, id: '2PUzxtIHkv346yP89NzP9X' }],
truncated: false
});
vi.mocked(getOsuMaps)
.mockResolvedValueOnce([
{ id: 1, title: 'Song', artist: 'Artist', score: 100, confidence: 'high', reasons: [] }
])
.mockRejectedValueOnce(new Error('sensitive upstream error'));
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await actions.default(event('Song', 'partial-test'));
expect(result).toMatchObject({
results: [
{ status: 'matched' },
{ status: 'error', error: 'Search could not be completed. Please try again.' }
]
});
log.mockRestore();
});
it('returns site-level 429 after the per-client budget is exhausted', async () => {
vi.mocked(getSpotifyMetadata).mockResolvedValue({ tracks: [], truncated: false });
for (let i = 0; i < 2; i++)
await actions.default(event('spotify:album:6PUzxtIHkv346yP89NzP9X', 'limited-test'));
const result = await actions.default(event('Song', 'limited-test'));
expect(result).toMatchObject({
status: 429,
data: { query: 'Song', error: expect.stringContaining('Site search limit') }
});
});

View File

@@ -0,0 +1,9 @@
import { authProviders } from '$lib/server/auth';
import { safeReturnTo } from '$lib/auth-redirect';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url, locals }) => {
const returnTo = safeReturnTo(url.searchParams.get('returnTo'));
if (locals.user) redirect(303, returnTo);
return { providers: authProviders, returnTo, oauthError: url.searchParams.has('error') };
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import AuthForm from '$lib/components/AuthForm.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<AuthForm mode="register" {...data} />

View File

@@ -0,0 +1,15 @@
import { SITE_ORIGIN } from '$lib/seo';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ url }) => {
const production = url.hostname === new URL(SITE_ORIGIN).hostname;
const body = production
? `User-agent: *\nAllow: /\nSitemap: ${SITE_ORIGIN}/sitemap.xml\n`
: 'User-agent: *\nDisallow: /\n';
return new Response(body, {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=3600'
}
});
};

View File

@@ -0,0 +1,61 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { SITE_ORIGIN } from '$lib/seo';
import { races } from '$lib/derby/races';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
const staticPaths = [
'/',
'/projects',
'/blog',
'/lists',
'/projects/catch-em-all',
'/projects/animaldex',
'/projects/derby',
'/projects/spotify-beatmap-finder'
];
function escapeXml(value: string): string {
return value.replace(/[<>&"']/g, (character) => {
const entities: Record<string, string> = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;'
};
return entities[character];
});
}
export const GET: RequestHandler = async () => {
const [articles, lists] = await Promise.all([
db
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
.from(blogArticles)
.where(eq(blogArticles.status, 'published')),
db.query.lists.findMany({ columns: { slug: true } })
]);
const entries: { path: string; lastmod?: string }[] = [
...staticPaths.map((path) => ({ path })),
...races.map((race) => ({ path: `/projects/derby/${race.slug}` })),
...articles.map((article) => ({
path: `/blog/${encodeURIComponent(article.slug)}`,
lastmod: article.updatedAt.toISOString()
})),
...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` }))
];
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries
.map(
({ path, lastmod }) =>
` <url><loc>${escapeXml(new URL(path, SITE_ORIGIN).href)}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>`
)
.join('\n')}\n</urlset>\n`;
return new Response(body, {
headers: {
'content-type': 'application/xml; charset=utf-8',
'cache-control': 'public, max-age=3600'
}
});
};