base website #10
No files matched your search
@@ -71,3 +71,15 @@ export const publications = personalWebsiteSchema.table('publications', {
|
|||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
updatedAt: timestamp('updated_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()
|
||||||
|
})
|
||||||
+47
-97
@@ -1,108 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
interface Props {
|
let { children } = $props();
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
const { children, data }: Props = $props();
|
|
||||||
|
|
||||||
let editModalVisible = $state(false);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<header class="flex justify-center gap-4">
|
||||||
class="min-h-screen bg-black text-white bg-center bg-[url('/moon.jpg')] bg-auto bg-no-repeat flex flex-col"
|
<a href="https://osu.ppy.sh/users/11212255">
|
||||||
>
|
<img src="./osu.png" alt="osu" />
|
||||||
<nav class="flex justify-between bg-gray-800 items-center px-4 py-2">
|
</a>
|
||||||
<div class="m-2">
|
<a href="https://steamcommunity.com/id/stanrunge">
|
||||||
<a href="/" class="font-bold text-xl sm:text-2xl">Stan Runge</a>
|
<img src="./steam.png" alt="steam" />
|
||||||
|
</a>
|
||||||
|
<a href="mailto://[email protected]">
|
||||||
|
<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>
|
</div>
|
||||||
<div class="m-2 flex gap-4 items-center">
|
<a href="">
|
||||||
<a href="/progress" class="hover:underline">Progress</a>
|
<img src="./whatsapp.png" alt="whatsapp" />
|
||||||
<span>|</span>
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<a href="mailto:[email protected]" 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>
|
||||||
<a href="https://github.com/stanrunge" class="hover:text-gray-300" aria-label="GitHub">
|
<a href="">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
<img src="./threads.png" alt="threads" />
|
||||||
<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>
|
||||||
<a href="https://x.com/stanrunge" class="hover:text-gray-300" aria-label="X">
|
<a href="https://github.com/stanrunge">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
<img src="./github.png" alt="github" />
|
||||||
<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>
|
||||||
<a
|
<a href="https://youtube.com/@stanrunge">
|
||||||
href="https://linkedin.com/in/stanrunge"
|
<img src="./youtube.png" alt="youtube" />
|
||||||
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>
|
</a>
|
||||||
</div>
|
<a href="https://linkedin.com/in/stanrunge">
|
||||||
</div>
|
<img src="./linkedin.png" alt="linkedin" />
|
||||||
</nav>
|
</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>
|
||||||
|
|
||||||
<!-- page content goes here -->
|
{@render children()}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
+96
-9
@@ -1,10 +1,97 @@
|
|||||||
<h1 class="my-8 text-4xl font-bold text-center">Stan Runge</h1>
|
<div class="text-center py-4 font-bold text-l">
|
||||||
<img src="/moon.jpg" alt="Moon" />
|
<a href="/blog">Blog</a>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div>Vash Software</div>
|
|
||||||
|
|
||||||
<div>Hogeschool Inholland</div>
|
|
||||||
|
|
||||||
<div>Junior</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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 = {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
l
|
||||||
@@ -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()}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return { user: locals.user ?? null };
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user