Compare commits

...

3 Commits

Author SHA1 Message Date
95514c8eea Merge pull request 'dev' (#2) from dev into staging
Some checks failed
build / build (push) Failing after 23s
build / deploy (push) Has been skipped
Reviewed-on: #2
2026-08-29 11:39:42 +00:00
97ff9852cd update homepage draft and other pages 2026-08-29 11:38:16 +00:00
6452331ee7 update tailwind to v4 2026-08-27 00:50:55 +00:00
45 changed files with 2248 additions and 268 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -22,9 +22,9 @@
"@playwright/test": "1.49.1",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@types/eslint": "9.6.1",
"@types/pg": "^8.11.10",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.18",
@@ -34,14 +34,13 @@
"eslint-plugin-svelte": "^2.46.1",
"globals": "15.14.0",
"lucide-svelte": "^0.473.0",
"postcss": "^8.4.49",
"prettier": "3.4.2",
"prettier-plugin-svelte": "3.3.2",
"svelte": "^5.16.5",
"svelte-check": "4.1.1",
"tailwind-merge": "^2.6.0",
"tailwind-variants": "^0.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss": "^4.0.0",
"typescript": "5.7.2",
"typescript-eslint": "8.19.1",
"vite": "^6.0.7",

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,78 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--ring: 212.7 26.8% 83.9%;
}
@import 'tailwindcss';
@theme {
--color-bg: #f6f4f0;
--color-surface: #edeae4;
--color-surface-hover: #e4e0d8;
--color-border: #d6d2ca;
--color-border-light: #e8e5de;
--color-text: #2c2c2c;
--color-text-muted: #6b6b6b;
--color-text-dim: #999999;
--color-text-ghost: #c5c0b8;
--color-accent: #3d6b8e;
--color-accent-hover: #4d7fa6;
--color-danger: #b44;
--font-heading: 'DM Serif Display', Georgia, serif;
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
--font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
section[id] {
scroll-margin-top: 88px;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
a {
color: inherit;
text-decoration: none;
}
}

View File

@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -71,3 +71,15 @@ export const publications = personalWebsiteSchema.table('publications', {
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
// Legacy ^
// New
export const blogArticles = personalWebsiteSchema.table('blog_articles', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 256 }),
slug: varchar('slug', { length: 256 }),
content: varchar('content', { length: 8192 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
})

View File

@@ -1,108 +1,58 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { ModeWatcher } from 'mode-watcher';
import '../app.css';
interface Props {
children?: import('svelte').Snippet;
}
const { children, data }: Props = $props();
let editModalVisible = $state(false);
let { children } = $props();
</script>
<div
class="min-h-screen bg-black text-white bg-center bg-[url('/moon.jpg')] bg-auto bg-no-repeat flex flex-col"
>
<nav class="flex justify-between bg-gray-800 items-center px-4 py-2">
<div class="m-2">
<a href="/" class="font-bold text-xl sm:text-2xl">Stan Runge</a>
</div>
<div class="m-2 flex gap-4 items-center">
<a href="/progress" class="hover:underline">Progress</a>
<span>|</span>
<div class="flex gap-2 items-center">
<a href="mailto:stan@stanrunge.dev" class="hover:text-gray-300" aria-label="Mail">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
/>
</svg>
</a>
<a href="https://github.com/stanrunge" class="hover:text-gray-300" aria-label="GitHub">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34
c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83
c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64
c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02
c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21
c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
/>
</svg>
</a>
<a href="https://x.com/stanrunge" class="hover:text-gray-300" aria-label="X">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m17.687 3.063l-4.996 5.711l-4.32-5.711H2.112l7.477 9.776l-7.086 8.099h3.034l5.469-6.25l4.78 6.25h6.102
l-7.794-10.304l6.625-7.571zm-1.064 16.06L5.654 4.782h1.803l10.846 14.34z"
/>
</svg>
</a>
<a
href="https://linkedin.com/in/stanrunge"
class="hover:text-gray-300"
aria-label="LinkedIn"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm-.5 15.5v-5.3
a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1
1.4 1.4v4.93zM6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69
1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37z"
/>
</svg>
</a>
</div>
</div>
</nav>
<!-- page content goes here -->
<main class="mx-auto py-8 px-4 max-w-5xl flex-grow">
<ModeWatcher />
{@render children?.()}
</main>
{#if !data.editable}
<div class="flex justify-end">
<button
class="p-3 rounded border border-white m-4"
onclick={() => (editModalVisible = !editModalVisible)}>Edit</button
>
</div>
{/if}
</div>
{#if editModalVisible}
<div
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
onclick={() => (editModalVisible = false)}
>
<form
class="bg-white rounded-lg shadow-lg p-6 max-w-md w-full flex flex-col items-center"
onclick={(e) => e.stopPropagation()}
action="/?/authenticate"
method="post"
>
<h2 class="text-lg font-semibold m-4 text-black">WHO ARE YOU?!?!?!??!</h2>
<input type="text" class="border border-black text-center rounded my-4" name="token" />
<button class="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700">
Authenticate
</button>
</form>
<header class="flex justify-center gap-4">
<a href="https://osu.ppy.sh/users/11212255">
<img src="./osu.png" alt="osu" />
</a>
<a href="https://steamcommunity.com/id/stanrunge">
<img src="./steam.png" alt="steam" />
</a>
<a href="mailto://stan@stanrunge.dev">
<img src="./mail.png" alt="email" />
</a>
<a href="https://twitch.tv/stanrunge_">
<img src="./twitch.png" alt="twitch" />
</a>
<a href="https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld?si=9874beb4481f45c3">
<img src="./spotify.png" alt="spotify" />
</a>
<a href="">
<img src="./signal.png" alt="signal" />
</a>
<a href="">
<img src="./bluesky.png" alt="bluesky" />
</a>
<div>
<img src="./discord.png" alt="discord" />
</div>
{/if}
<a href="">
<img src="./whatsapp.png" alt="whatsapp" />
</a>
<a href="">
<img src="./threads.png" alt="threads" />
</a>
<a href="https://github.com/stanrunge">
<img src="./github.png" alt="github" />
</a>
<a href="https://youtube.com/@stanrunge">
<img src="./youtube.png" alt="youtube" />
</a>
<a href="https://linkedin.com/in/stanrunge">
<img src="./linkedin.png" alt="linkedin" />
</a>
<a href="https://instagram.com/stan_runge">
<img src="./instagram.png" alt="instagram" />
</a>
<a href="">
<img src="./facebook.png" alt="facebook" />
</a>
<a href="https://x.com/stanrunge">
<img src="./x.png" alt="x" />
</a>
</header>
{@render children()}

View File

@@ -1,10 +1,97 @@
<h1 class="my-8 text-4xl font-bold text-center">Stan Runge</h1>
<img src="/moon.jpg" alt="Moon" />
<div class="flex flex-col">
<div>Vash Software</div>
<div>Hogeschool Inholland</div>
<div>Junior</div>
<div class="text-center py-4 font-bold text-l">
<a href="/blog">Blog</a>
</div>
<div class="flex justify-around py-8">
<div class="max-w-100">
<h2 class="font-bold text-2xl underline text-center">experience</h2>
<div class="flex gap-2">
<p class="min-w-24 text-right">2026 - ???</p>
<p class="font-bold">???</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 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-24 text-right">2026</p>
<p class="font-bold">internship at 3webapps</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 text-right">2023</p>
<p class="font-bold">internship at h5mag</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 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-24 text-right">2020 - current</p>
<p class="font-bold">300+ IT problems solved in Netherlands</p>
</div>
</div>
<div class="pt-32">
<h1 class="font-black text-center text-4xl">Stan Runge</h1>
<img src="./quantum-chair.png" alt="stan runge" />
<p class="italic text-sm text-center">
me preparing for my presentation about <a href="https://arxiv.org/abs/2209.08167"
>quantum vision transformers</a
>
</p>
</div>
<div class="max-w-100">
<div>
<h2 class="font-bold text-2xl underline text-center">projects</h2>
</div>
<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="/projects/animaldex">
<p class="font-bold text-xl">animaldex</p>
<p>world map with pets & other animals around the neighborhood 😼</p>
</a>
<a href="/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="/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>
<p>+ a lot more planned (soon™)</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
import { db } from "$lib/db"
import { blogArticles } from "$lib/db/schema";
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types.js";
export const load: PageServerLoad = async () => {
const articles = await db.select().from(blogArticles)
return {
blogArticles: articles
}
}
export const actions = {
default: async ({ request }): Promise<Response> => {
const formData = await request.formData();
const blogArticle = await db.insert(blogArticles).values({
title: formData.get('title')?.toString(),
slug: formData.get('slug')?.toString(),
content: formData.get('content')?.toString()
}).returning();
return redirect(303, `/blog/${blogArticle[0].slug}`);
}
}

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
let createNewArticlePopup = $state(false);
</script>
<div class="text-center py-8 gap-2 flex align-center justify-center">
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
<button onclick={() => createNewArticlePopup = true} class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button>
</div>
{#each data.blogArticles as article}
<div class="flex">
<a href="/blog/${article.slug}">{article.title}</a>
<p>{article.createdAt?.toDateString()}</p>
</div>
{/each}
{#if createNewArticlePopup}
<form method="POST" use:enhance>
<div>
<h2>Create New Article</h2>
</div>
</form>
{/if}

View File

@@ -0,0 +1,22 @@
import { db } from "$lib/db"
import { blogArticles } from "$lib/db/schema";
import { eq } from "drizzle-orm";
import type { PageServerLoad } from "./$types.js";
import { fail } from "@sveltejs/kit";
export const load: PageServerLoad = async ({params }) => {
const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug))
if (article.length === 0) {
return fail(404, { message: "Article not found" });
}
return {
blogArticle: article[0]
}
}
export const actions = {
}

View File

@@ -0,0 +1,2 @@
l

View File

@@ -0,0 +1,15 @@
<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

@@ -0,0 +1,356 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
interface TrailPoint {
id: string;
lat: number;
lng: number;
seenAt: string;
reporterName: string | null;
photoUrl: string | null;
}
interface AnimalFeatureProps {
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightingCount: number;
trail: string | TrailPoint[];
}
interface GeoJSONFeature {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: AnimalFeatureProps;
}
let mapContainer: HTMLDivElement;
let map: MaptilerMap | null = null;
let htmlMarkers: MaptilerMarker[] = [];
let MapMarker: typeof MaptilerMarker | null = null;
let filterSpecies = $state('');
let filterBreed = $state('');
let filterName = $state('');
let filterReporter = $state('');
let filterFrom = $state('');
let filterTo = $state('');
let showFilters = $state(false);
let selected = $state<{
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightingCount: number;
trail: TrailPoint[];
} | null>(null);
function parseTrail(raw: string | TrailPoint[]): TrailPoint[] {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
onMount(async () => {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public');
config.apiKey = PUBLIC_MAPTILER_API_KEY;
MapMarker = Marker;
const initMap = (center: [number, number], zoom: number) => {
map = new Map({
container: mapContainer,
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY,
center,
zoom
});
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);
});
map.on('moveend', () => {
if (MapMarker) loadMarkers(MapMarker);
});
};
navigator.geolocation.getCurrentPosition(
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
() => initMap([4.708, 52.009], 15.5),
{ enableHighAccuracy: true, timeout: 5000 }
);
});
onDestroy(() => {
htmlMarkers.forEach((m) => m.remove());
map?.remove();
});
async function loadMarkers(Marker: typeof MaptilerMarker) {
if (!map) return;
const bounds = map.getBounds();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
minLat: String(bounds.getSouth()),
minLng: String(bounds.getWest()),
maxLat: String(bounds.getNorth()),
maxLng: String(bounds.getEast())
});
if (filterSpecies) params.set('species', filterSpecies);
if (filterBreed) params.set('breed', filterBreed);
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());
const res = await fetch(`/api/map?${params}`);
if (!res.ok) return;
const geojson: { features: GeoJSONFeature[] } = await res.json();
htmlMarkers.forEach((m) => m.remove());
htmlMarkers = [];
const lineFeatures = geojson.features
.filter((f) => parseTrail(f.properties.trail).length > 1)
.map((f) => {
const trail = parseTrail(f.properties.trail);
return {
type: 'Feature' as const,
geometry: {
type: 'LineString' as const,
coordinates: [...trail]
.sort((a, b) => new Date(a.seenAt).getTime() - new Date(b.seenAt).getTime())
.map((s) => [s.lng, s.lat])
},
properties: {}
};
});
const trailSource = map?.getSource('trails');
// @ts-expect-error setData exists on GeoJSONSource
trailSource?.setData({ type: 'FeatureCollection', features: lineFeatures });
for (const feature of geojson.features) {
const props = feature.properties;
const trail = parseTrail(props.trail);
const photoUrl = trail[0]?.photoUrl ?? null;
const el = document.createElement('div');
el.style.cssText = `
width: 44px; height: 44px; border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
cursor: pointer; overflow: hidden;
background: #f97316;
`;
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.onclick = () => {
selected = {
animalId: props.animalId,
species: props.species,
breed: props.breed,
animalName: props.animalName,
sightingCount: props.sightingCount,
trail
};
};
const marker = new Marker({ element: el })
.setLngLat(feature.geometry.coordinates)
.addTo(map!);
htmlMarkers.push(marker);
}
}
function applyFilters() {
showFilters = false;
if (MapMarker) loadMarkers(MapMarker);
}
function clearFilters() {
filterSpecies = '';
filterBreed = '';
filterName = '';
filterReporter = '';
filterFrom = '';
filterTo = '';
if (MapMarker) loadMarkers(MapMarker);
}
const activeFilterCount = $derived(
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
.length
);
</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">
<a
href={resolve('/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"
>
+ Log a sighting
</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"
>
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>
{#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">
<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"
/>
<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"
/>
<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"
/>
<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"
/>
<div class="grid grid-cols-2 gap-2">
<div>
<label for="filter-from" class="mb-1 block text-xs text-gray-500">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"
/>
</div>
<div>
<label for="filter-to" class="mb-1 block text-xs text-gray-500">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"
/>
</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<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
>
</div>
</div>
{/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" />
<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
>
</div>
{/if}
<div class="p-4">
<div class="mb-1 flex items-start justify-between">
<div>
<p class="text-lg font-semibold text-gray-900">
{selected.animalName ?? selected.species}
</p>
<p class="text-sm text-gray-500">
{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"
>{selected.sightingCount}
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</div>
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
{#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"
>{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}
</div>
{/each}
</div>
<a
href={resolve(`/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"
>
I saw this animal too!
</a>
</div>
</div>
{/if}
</div>

View File

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

View File

@@ -0,0 +1,53 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { isAdmin } from '$lib/server/auth';
import { and, eq, isNull, sql } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
const admin = isAdmin(event);
if (!admin) return { admin: false, queue: [] };
const pending = await db
.select({
animalId: animals.id,
species: animals.species,
breed: animals.breed,
animalName: animals.animalName,
description: animals.description,
aiBreedSuggestion: animals.aiBreedSuggestion,
aiBreedConfidence: animals.aiBreedConfidence,
submittedAt: animals.submittedAt,
acceptedAt: animals.acceptedAt,
deniedAt: animals.deniedAt,
sightingId: sightings.id,
sightingLat: sightings.lat,
sightingLng: sightings.lng,
seenAt: sightings.seenAt,
reporterName: sightings.reporterName,
sightingAcceptedAt: sightings.acceptedAt,
sightingDeniedAt: sightings.deniedAt,
photoUrl: sql<string>`(
SELECT url FROM photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1
)`
})
.from(animals)
.innerJoin(
sightings,
and(
eq(sightings.animalId, animals.id),
sql`${sightings.id} = (
SELECT id FROM sightings
WHERE animal_id = ${animals.id}
ORDER BY submitted_at ASC
LIMIT 1
)`
)
)
.orderBy(animals.submittedAt);
return { admin: true, queue: pending };
};

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { PageData } from './$types';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
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);
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();
}
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"
>
{loginLoading ? 'Logging in…' : 'Log in'}
</button>
</div>
</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"
>
<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">
{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 queue.length === 0}
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
No submissions yet
</div>
{/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="flex gap-4 p-4">
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
{#if item.photoUrl}
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div class="flex h-full items-center justify-center text-2xl">🐾</div>
{/if}
</div>
<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">
{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'}"
>
{status}
</span>
</div>
{#if item.aiBreedSuggestion}
<p class="mt-1 text-xs text-gray-400">
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
(item.aiBreedConfidence ?? 0) * 100
)}%)
</p>
{/if}
<p class="mt-1 text-xs text-gray-400">
{new Date(item.submittedAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{#if item.reporterName}· by {item.reporterName}{/if}
</p>
<p class="mt-0.5 text-xs text-gray-400">
📍 {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">
<button
onclick={() => moderate('animals', 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'}"
>
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
</button>
<button
onclick={() => moderate('animals', 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'}"
>
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,25 @@
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 });
};

View File

@@ -0,0 +1,73 @@
import { json, error } from '@sveltejs/kit';
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';
const MAX_DISTANCE_METERS = 30_000;
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = registerAnimalSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid 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);
if (dist > MAX_DISTANCE_METERS) {
throw error(422, {
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
});
}
const result = await db.transaction(async (tx) => {
const [animal] = await tx
.insert(animals)
.values({
species: data.species,
breed: data.breed,
animalName: data.animalName,
description: data.description,
aiBreedSuggestion: data.aiBreedSuggestion,
aiBreedConfidence: data.aiBreedConfidence
})
.returning({ id: animals.id });
const [sighting] = await tx
.insert(sightings)
.values({
animalId: animal.id,
reporterName: data.reporterName,
seenAt: new Date(data.seenAt),
lat: data.lat,
lng: data.lng
})
.returning({ id: sightings.id });
await tx.insert(photos).values(
data.photoKeys.map((key, i) => ({
sightingId: sighting.id,
r2Key: key,
url: `${env.R2_PUBLIC_URL}/${key}`,
sortOrder: i
}))
);
return { animalId: animal.id, sightingId: sighting.id };
});
console.log(`[animals] created animal ${result.animalId}, sighting ${result.sightingId}`);
return json(result, { status: 201 });
};

View File

@@ -0,0 +1,51 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { moderationActionSchema } from '$lib/validation/sighting';
import { eq, isNull } from 'drizzle-orm';
import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const body = await event.request.json().catch(() => null);
const parsed = moderationActionSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'action must be "accept" or "deny"' });
}
const now = new Date();
const isAccept = parsed.data.action === 'accept';
const [updated] = await db.transaction(async (tx) => {
const result = await tx
.update(animals)
.set({
acceptedAt: isAccept ? now : null,
deniedAt: isAccept ? null : now
})
.where(eq(animals.id, event.params.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));
}
return result;
});
if (!updated) {
throw error(404, { message: 'Animal not found' });
}
console.log(
`[moderate] animal ${event.params.id}${parsed.data.action}, updated ${updated?.id}`
);
return json({ ok: true, action: parsed.data.action });
};

View File

@@ -0,0 +1,78 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings, photos } from '$lib/server/db/schema';
import { addSightingSchema } from '$lib/validation/sighting';
import { eq, sql } from 'drizzle-orm';
import { env } from '$env/dynamic/private';
const MAX_DISTANCE_METERS = 30_000;
export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json().catch(() => null);
const parsed = addSightingSchema.safeParse({ ...body, animalId: params.id });
if (!parsed.success) {
throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
}
const data = parsed.data;
const [animal] = await db
.select({ id: animals.id, acceptedAt: animals.acceptedAt })
.from(animals)
.where(eq(animals.id, params.id))
.limit(1);
if (!animal) {
throw error(404, { message: 'Animal not found' });
}
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);
if (dist > MAX_DISTANCE_METERS) {
throw error(422, {
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
});
}
const animalAlreadyAccepted = animal.acceptedAt !== null;
const result = await db.transaction(async (tx) => {
const [sighting] = await tx
.insert(sightings)
.values({
animalId: data.animalId,
reporterName: data.reporterName,
seenAt: new Date(data.seenAt),
lat: data.lat,
lng: data.lng,
...(animalAlreadyAccepted ? { acceptedAt: new Date() } : {})
})
.returning({ id: sightings.id });
if (data.photoKeys && data.photoKeys.length > 0) {
await tx.insert(photos).values(
data.photoKeys.map((key, i) => ({
sightingId: sighting.id,
r2Key: key,
url: `${env.R2_PUBLIC_URL}/${key}`,
sortOrder: i
}))
);
}
return { sightingId: sighting.id };
});
console.log(
`[sightings] created sighting ${result.sightingId} for animal ${params.id} (auto-accepted: ${animalAlreadyAccepted})`
);
return json(result, { status: 201 });
};

View File

@@ -0,0 +1,36 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { sightings } from '$lib/server/db/schema';
import { moderationActionSchema } from '$lib/validation/sighting';
import { eq } from 'drizzle-orm';
import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const body = await event.request.json().catch(() => null);
const parsed = moderationActionSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'action must be "accept" or "deny"' });
}
const now = new Date();
const isAccept = parsed.data.action === 'accept';
const [updated] = await db
.update(sightings)
.set({
acceptedAt: isAccept ? now : null,
deniedAt: isAccept ? null : now
})
.where(eq(sightings.id, event.params.id))
.returning({ id: sightings.id });
if (!updated) {
throw error(404, { message: 'Sighting not found' });
}
return json({ ok: true, action: parsed.data.action });
};

View File

@@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { detectBreed } from '$lib/server/breed-detector';
import { z } from 'zod';
const schema = z.object({ imageUrl: z.string().url() });
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'imageUrl is required' });
}
const result = await detectBreed(parsed.data.imageUrl);
return json(result ?? { breed: null, confidence: 0 });
};

View File

@@ -0,0 +1,127 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { mapQuerySchema } from '$lib/validation/sighting';
import { and, eq, gte, ilike, isNotNull, isNull, lte, sql } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const params = Object.fromEntries(url.searchParams);
const parsed = mapQuerySchema.safeParse(params);
if (!parsed.success) {
throw error(400, { message: 'Invalid query parameters' });
}
const q = parsed.data;
const conditions = [
isNotNull(animals.acceptedAt),
isNull(animals.deniedAt),
isNotNull(sightings.acceptedAt),
isNull(sightings.deniedAt),
sql`ST_Within(
${sightings}.location::geometry,
ST_MakeEnvelope(${q.minLng}, ${q.minLat}, ${q.maxLng}, ${q.maxLat}, 4326)
)`
];
if (q.species) conditions.push(ilike(animals.species, `%${q.species}%`));
if (q.breed) conditions.push(ilike(animals.breed, `%${q.breed}%`));
if (q.name) conditions.push(ilike(animals.animalName, `%${q.name}%`));
if (q.reporter) conditions.push(ilike(sightings.reporterName, `%${q.reporter}%`));
if (q.fromDate) conditions.push(gte(sightings.seenAt, new Date(q.fromDate)));
if (q.toDate) conditions.push(lte(sightings.seenAt, new Date(q.toDate)));
const rows = await db
.select({
animalId: animals.id,
species: animals.species,
breed: animals.breed,
animalName: animals.animalName,
sightingId: sightings.id,
seenAt: sightings.seenAt,
reporterName: sightings.reporterName,
lat: sightings.lat,
lng: sightings.lng,
photoUrl: sql<string>`(
SELECT url FROM photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1
)`
})
.from(sightings)
.innerJoin(animals, eq(sightings.animalId, animals.id))
.where(and(...conditions))
.orderBy(sightings.seenAt);
const animalMap = new Map<
string,
{
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightings: Array<{
id: string;
lat: number;
lng: number;
seenAt: Date;
reporterName: string | null;
photoUrl: string | null;
}>;
}
>();
for (const row of rows) {
if (!animalMap.has(row.animalId)) {
animalMap.set(row.animalId, {
animalId: row.animalId,
species: row.species,
breed: row.breed,
animalName: row.animalName,
sightings: []
});
}
animalMap.get(row.animalId)!.sightings.push({
id: row.sightingId,
lat: row.lat,
lng: row.lng,
seenAt: row.seenAt,
reporterName: row.reporterName,
photoUrl: row.photoUrl
});
}
const features = Array.from(animalMap.values()).map((animal) => {
const sorted = [...animal.sightings].sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime());
const latest = sorted[0];
return {
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [latest.lng, latest.lat] },
properties: {
animalId: animal.animalId,
species: animal.species,
breed: animal.breed,
animalName: animal.animalName,
sightingCount: animal.sightings.length,
trail: animal.sightings.map((s) => ({
id: s.id,
lng: s.lng,
lat: s.lat,
seenAt: s.seenAt.toISOString(),
reporterName: s.reporterName,
photoUrl: s.photoUrl
}))
}
};
});
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

@@ -0,0 +1,22 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2';
import { presignedUploadSchema } from '$lib/validation/sighting';
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 { mimeType } = parsed.data;
if (!isAllowedMimeType(mimeType)) {
throw error(400, { message: 'Unsupported image type' });
}
const result = await createPresignedUpload(mimeType);
return json(result);
};

View File

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

View File

@@ -0,0 +1,444 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
const animalId = $derived($page.url.searchParams.get('animalId'));
const isNewSighting = $derived(!!animalId);
let species = $state('');
let customSpecies = $state('');
let breed = $state('');
let animalName = $state('');
let description = $state('');
let reporterName = $state('');
let seenAt = $state(new Date().toISOString().slice(0, 16));
let aiSuggestion = $state<{ breed: string; confidence: number } | null>(null);
let aiLoading = $state(false);
let aiDismissed = $state(false);
let photoFiles = $state<File[]>([]);
let photoPreviews = $state<string[]>([]);
let lat = $state<number | null>(null);
let lng = $state<number | null>(null);
let deviceLat = $state<number | null>(null);
let deviceLng = $state<number | null>(null);
let locationError = $state('');
let locationLoading = $state(false);
let mapContainer = $state<HTMLDivElement>(null!);
let map: MaptilerMap | null = null;
let marker: MaptilerMarker | null = null;
let submitting = $state(false);
let submitError = $state('');
$effect(() => {
if (mapContainer && lat !== null && lng !== null && !map) {
initMap();
}
});
const COMMON_SPECIES = ['Dog', 'Cat', 'Fox', 'Bird', 'Rabbit', 'Deer', 'Squirrel', 'Hedgehog'];
const finalSpecies = $derived(species === '__custom__' ? customSpecies : species);
onMount(async () => {
reporterName = localStorage.getItem('reporterName') ?? '';
locationLoading = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
deviceLat = pos.coords.latitude;
deviceLng = pos.coords.longitude;
lat = pos.coords.latitude;
lng = pos.coords.longitude;
locationLoading = false;
},
() => {
locationLoading = false;
locationError = 'Location access denied. GPS is required to register a sighting.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
async function initMap() {
if (!mapContainer || lat === null || lng === null) return;
const { Map, Marker, config } = await import('@maptiler/sdk');
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public');
config.apiKey = PUBLIC_MAPTILER_API_KEY;
map = new Map({
container: mapContainer,
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + 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;
});
});
}
function handlePhotoChange(e: Event) {
const input = e.target as HTMLInputElement;
const newFiles = Array.from(input.files ?? []).slice(0, 10 - photoFiles.length);
photoFiles = [...photoFiles, ...newFiles].slice(0, 10);
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
if (photoFiles.length === 1 && !isNewSighting) runAIDetection(photoFiles[0]);
}
function removePhoto(index: number) {
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;
}
}
async function submit() {
if (submitting) return;
submitError = '';
if (!finalSpecies && !isNewSighting) {
submitError = 'Please select or enter a species.';
return;
}
if (photoFiles.length === 0 && !isNewSighting) {
submitError = 'At least one photo is required.';
return;
}
if (lat === null || lng === null || deviceLat === null || deviceLng === null) {
submitError = 'Location is required.';
return;
}
submitting = true;
try {
const photoKeys: string[] = [];
if (photoFiles.length > 0) {
for (const file of photoFiles) {
const presignRes = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mimeType: file.type })
});
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');
photoKeys.push(key);
}
}
if (reporterName) localStorage.setItem('reporterName', reporterName);
const endpoint = isNewSighting ? `/api/animals/${animalId}/sightings` : '/api/animals';
const body = isNewSighting
? {
animalId,
reporterName: reporterName || undefined,
seenAt: new Date(seenAt).toISOString(),
lat,
lng,
deviceLat,
deviceLng,
photoKeys
}
: {
species: finalSpecies,
breed: breed || undefined,
animalName: animalName || undefined,
description: description || undefined,
reporterName: reporterName || undefined,
aiBreedSuggestion: aiSuggestion?.breed,
aiBreedConfidence: aiSuggestion?.confidence,
seenAt: new Date(seenAt).toISOString(),
lat,
lng,
deviceLat,
deviceLng,
photoKeys
};
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? 'Submission failed');
}
goto(resolve('/?submitted=1'));
} catch (e: unknown) {
submitError = e instanceof Error ? e.message : 'Something went wrong.';
} finally {
submitting = false;
}
}
</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"
>
<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>
<div class="mx-auto max-w-lg space-y-5 px-4 py-6">
{#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>
<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>
</div>
{/if}
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-3 text-sm font-semibold text-gray-700">
Photos
{#if isNewSighting}
<span class="text-xs font-normal text-gray-400">(optional)</span>
{:else}
<span class="text-red-400">*</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">
<img src={preview} alt="" class="h-full w-full object-cover" />
<button
onclick={() => removePhoto(i)}
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
>
</div>
{/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"
>
<span class="text-2xl">+</span>
<span class="text-xs">Photo</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
class="hidden"
onchange={handlePhotoChange}
/>
</label>
{/if}
</div>
<p class="mt-2 text-xs text-gray-400">Max 10 photos · JPEG, PNG, WebP, HEIC</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>
</h2>
<div class="flex flex-wrap gap-2">
{#each COMMON_SPECIES as s (s)}
<button
onclick={() => {
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
>
{/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
>
</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"
/>
{/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>
</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"
/>
</section>
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<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>
<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"
/>
</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>
<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"
></textarea>
</div>
</section>
{/if}
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
When did you see it? <span class="text-red-400">*</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"
/>
</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>
<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"
/>
</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>
</h2>
{#if locationLoading}
<div
class="flex h-40 items-center justify-center rounded-xl bg-gray-100 text-sm text-gray-400"
>
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>
{/if}
</section>
{#if submitError}
<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{submitError}
</div>
{/if}
<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"
>
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
</button>
<p class="pb-8 text-center text-xs text-gray-400">
All submissions are reviewed before appearing on the map.
</p>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,64 +0,0 @@
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
const config = {
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
fontFamily: {
sans: [...fontFamily.sans]
}
}
},
};
export default config;

View File

@@ -1,9 +1,15 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
plugins: [tailwindcss(), sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
server: {
host: '127.0.0.1',
allowedHosts: ['dev.stanrunge.dev'],
hmr: { host: 'dev.stanrunge.dev', protocol: 'wss', clientPort: 443 }
}
});