diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 10445bc..f5a902f 100755 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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() +}) \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2a56bb2..6b1e466 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,108 +1,58 @@ -
- - - -
- - {@render children?.()} -
- - {#if !data.editable} -
- -
- {/if} -
- -{#if editModalVisible} -
(editModalVisible = false)} - > -
e.stopPropagation()} - action="/?/authenticate" - method="post" - > -

WHO ARE YOU?!?!?!??!

- - -
+
+ + osu + + + steam + + + email + + + twitch + + + spotify + + + signal + + + bluesky + +
+ discord
-{/if} + + whatsapp + + + threads + + + github + + + youtube + + + linkedin + + + instagram + + + facebook + + + x + +
+ +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 42db257..6d906a4 100755 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,10 +1,97 @@ -

Stan Runge

-Moon - -
-
Vash Software
- -
Hogeschool Inholland
- -
Junior
+
+ Blog
+ +
+
+

experience

+
+

2026 - ???

+

???

+
+
+

2021 - 2026

+
+

BSc. Computer Science (Software Engineering)

+

The Hague University of Applied Sciences

+
+
+
+

2026

+

internship at 3webapps

+
+
+

2023

+

internship at h5mag

+
+
+

2021 - 2023

+

junior IT specialist at Finance Plus

+
+
+

2020 - current

+

300+ IT problems solved in Netherlands

+
+
+ +
+

Stan Runge

+ stan runge +

+ me preparing for my presentation about quantum vision transformers +

+
+ +
+
+

projects

+
+ +
+ +

vash esports

+

automated osu! matchmaking/tournament platform

+
+
+

eudi-auth for 3webapps

+

+ plugin for WooCommerce/Magento2 that adds authentication with the European Digital + Identity (EUDI) wallet +

+
+
+

realtime collaboration for h5mag

+

+ socket.io server for h5mag (magazine web editor) that broadcasts user actions to other + editors, similar to writing a Google Doc together +

+
+
+

infra

+

server, project deployments and dev config as code with setup script

+
+ +

animaldex

+

world map with pets & other animals around the neighborhood 😼

+
+ +

catch em all

+

table of all 1025 pokemon across 38 games with checkboxes for catching them all

+
+ +

spotify osu! map finder

+

small tool for finding osu! maps using a spotify url of a song or playlist

+
+ +

beerio kart

+
Go
+

webserver in go with a double-elim bracket for a one-time mario kart tournament

+
+ +

+ a lot more planned (soon™)

+
+
+
+ diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts new file mode 100644 index 0000000..ba87e87 --- /dev/null +++ b/src/routes/blog/+page.server.ts @@ -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 => { + 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}`); + } +} diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte new file mode 100644 index 0000000..e148cb7 --- /dev/null +++ b/src/routes/blog/+page.svelte @@ -0,0 +1,28 @@ + + +
+

Blog (Stan's yapping corner)

+ +
+ + +{#each data.blogArticles as article} +
+ {article.title} +

{article.createdAt?.toDateString()}

+
+{/each} + +{#if createNewArticlePopup} +
+
+

Create New Article

+
+
+{/if} diff --git a/src/routes/blog/{slug}/+page.server.ts b/src/routes/blog/{slug}/+page.server.ts new file mode 100644 index 0000000..0688a34 --- /dev/null +++ b/src/routes/blog/{slug}/+page.server.ts @@ -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 = { + +} diff --git a/src/routes/blog/{slug}/+page.svelte b/src/routes/blog/{slug}/+page.svelte new file mode 100644 index 0000000..d3efcba --- /dev/null +++ b/src/routes/blog/{slug}/+page.svelte @@ -0,0 +1,2 @@ + +l \ No newline at end of file diff --git a/src/routes/projects/animaldex/+layout.svelte b/src/routes/projects/animaldex/+layout.svelte new file mode 100644 index 0000000..1e47fdb --- /dev/null +++ b/src/routes/projects/animaldex/+layout.svelte @@ -0,0 +1,15 @@ + + + + + + + + + + +{@render children()} diff --git a/src/routes/projects/animaldex/+page.svelte b/src/routes/projects/animaldex/+page.svelte new file mode 100644 index 0000000..14833f6 --- /dev/null +++ b/src/routes/projects/animaldex/+page.svelte @@ -0,0 +1,356 @@ + + + + Animaldex + + + +
+
+ +
+ + + Log a sighting + + +
+ + {#if showFilters} +
+
+

Filter sightings

+ +
+
+ + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ {/if} + + {#if selected} +
+ {#if selected.trail[0]?.photoUrl} +
+ + +
+ {/if} +
+
+
+

+ {selected.animalName ?? selected.species} +

+

+ {selected.species}{selected.breed ? ` · ${selected.breed}` : ''} +

+
+ {selected.sightingCount} + {selected.sightingCount === 1 ? 'sighting' : 'sightings'} +
+
+ {#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)} +
+ {new Date(s.seenAt).toLocaleDateString('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric' + })} + {#if s.reporterName}by {s.reporterName}{/if} +
+ {/each} +
+ + I saw this animal too! + +
+
+ {/if} +
diff --git a/src/routes/projects/animaldex/+page.ts b/src/routes/projects/animaldex/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/src/routes/projects/animaldex/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/projects/animaldex/admin/+page.server.ts b/src/routes/projects/animaldex/admin/+page.server.ts new file mode 100644 index 0000000..8fc901c --- /dev/null +++ b/src/routes/projects/animaldex/admin/+page.server.ts @@ -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`( + 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 }; +}; diff --git a/src/routes/projects/animaldex/admin/+page.svelte b/src/routes/projects/animaldex/admin/+page.svelte new file mode 100644 index 0000000..ca73304 --- /dev/null +++ b/src/routes/projects/animaldex/admin/+page.svelte @@ -0,0 +1,170 @@ + + +Animaldex - Admin + +{#if !data.admin} +
+
+

Admin login

+ 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} +

{loginError}

+ {/if} + +
+
+{:else} +
+
+
+ +

Animaldex admin

+
+ + {queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending + +
+ +
+ {#if queue.length === 0} +
+ No submissions yet +
+ {/if} + + {#each queue as item (item.animalId)} + {@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'} +
+
+
+ {#if item.photoUrl} + + {:else} +
🐾
+ {/if} +
+
+
+
+

{item.animalName ?? item.species}

+

+ {item.species}{item.breed ? ` · ${item.breed}` : ''} +

+
+ + {status} + +
+ {#if item.aiBreedSuggestion} +

+ 🤖 AI: {item.aiBreedSuggestion} ({Math.round( + (item.aiBreedConfidence ?? 0) * 100 + )}%) +

+ {/if} +

+ {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} +

+

+ 📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)} +

+
+
+
+ + +
+
+ {/each} +
+
+{/if} diff --git a/src/routes/projects/animaldex/admin/login/+server.ts b/src/routes/projects/animaldex/admin/login/+server.ts new file mode 100644 index 0000000..e2cd02c --- /dev/null +++ b/src/routes/projects/animaldex/admin/login/+server.ts @@ -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 }); +}; diff --git a/src/routes/projects/animaldex/api/animals/+server.ts b/src/routes/projects/animaldex/api/animals/+server.ts new file mode 100644 index 0000000..49b5028 --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/+server.ts @@ -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).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 }); +}; diff --git a/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts new file mode 100644 index 0000000..7497f0d --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts @@ -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 }); +}; diff --git a/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts new file mode 100644 index 0000000..9a0c875 --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts @@ -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).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 }); +}; diff --git a/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts new file mode 100644 index 0000000..b1265e6 --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts @@ -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 }); +}; diff --git a/src/routes/projects/animaldex/api/detect-breed/+server.ts b/src/routes/projects/animaldex/api/detect-breed/+server.ts new file mode 100644 index 0000000..9c58bf6 --- /dev/null +++ b/src/routes/projects/animaldex/api/detect-breed/+server.ts @@ -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 }); +}; diff --git a/src/routes/projects/animaldex/api/map/+server.ts b/src/routes/projects/animaldex/api/map/+server.ts new file mode 100644 index 0000000..d3d1914 --- /dev/null +++ b/src/routes/projects/animaldex/api/map/+server.ts @@ -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`( + 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 }); +}; diff --git a/src/routes/projects/animaldex/api/upload/+server.ts b/src/routes/projects/animaldex/api/upload/+server.ts new file mode 100644 index 0000000..8e34691 --- /dev/null +++ b/src/routes/projects/animaldex/api/upload/+server.ts @@ -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); +}; diff --git a/src/routes/projects/animaldex/layout.css b/src/routes/projects/animaldex/layout.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/src/routes/projects/animaldex/layout.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/src/routes/projects/animaldex/register/+page.svelte b/src/routes/projects/animaldex/register/+page.svelte new file mode 100644 index 0000000..7e8fbc8 --- /dev/null +++ b/src/routes/projects/animaldex/register/+page.svelte @@ -0,0 +1,444 @@ + + + + Animaldex - Log a sighting + + + +
+
+ +

+ {isNewSighting ? 'Log another sighting' : 'Register an animal'} +

+
+ +
+ {#if locationError} +
+

📍 Location required

+

{locationError}

+

+ Go to Settings → Site permissions → Location and allow this site. +

+
+ {/if} + +
+

+ Photos + {#if isNewSighting} + (optional) + {:else} + * + {/if} +

+
+ {#each photoPreviews as preview, i (i)} +
+ + +
+ {/each} + {#if photoFiles.length < 10} + + {/if} +
+

Max 10 photos · JPEG, PNG, WebP, HEIC

+
+ + {#if aiLoading} +
+ Identifying breed… +
+ {:else if aiSuggestion && !aiDismissed} +
+

+ 🤖 AI thinks this is a {aiSuggestion.breed} + ({Math.round(aiSuggestion.confidence * 100)}% confident) +

+
+ + +
+
+ {/if} + + {#if !isNewSighting} +
+

+ Species * +

+
+ {#each COMMON_SPECIES as s (s)} + + {/each} + +
+ {#if species === '__custom__'} + + {/if} +
+ +
+

+ Breed (optional) +

+ +
+ +
+
+

+ Animal name (optional) +

+ +
+
+

+ Description (optional) +

+ +
+
+ {/if} + +
+
+

+ When did you see it? * +

+ +
+
+

+ Your name (optional) +

+ +
+
+ +
+

+ Location * +

+ {#if locationLoading} +
+ Getting your location… +
+ {:else if lat !== null} +
+

Drag the pin to fine-tune (max 30 km from you).

+ {/if} +
+ + {#if submitError} +
+ {submitError} +
+ {/if} + + + +

+ All submissions are reviewed before appearing on the map. +

+
+
diff --git a/src/routes/projects/animaldex/register/+page.ts b/src/routes/projects/animaldex/register/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/src/routes/projects/animaldex/register/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/projects/catch-em-all/+layout.server.ts b/src/routes/projects/catch-em-all/+layout.server.ts new file mode 100644 index 0000000..54048b3 --- /dev/null +++ b/src/routes/projects/catch-em-all/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { user: locals.user ?? null }; +}; diff --git a/src/routes/projects/catch-em-all/+layout.svelte b/src/routes/projects/catch-em-all/+layout.svelte new file mode 100644 index 0000000..801d902 --- /dev/null +++ b/src/routes/projects/catch-em-all/+layout.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {#if data.user} + Logged in as {data.user.name} +
+ +
+ {:else} + Log in + {/if} +
+
+ +
{@render children()}
+
diff --git a/src/routes/projects/catch-em-all/+page.server.ts b/src/routes/projects/catch-em-all/+page.server.ts new file mode 100644 index 0000000..e710b6b --- /dev/null +++ b/src/routes/projects/catch-em-all/+page.server.ts @@ -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; diff --git a/src/routes/projects/catch-em-all/+page.svelte b/src/routes/projects/catch-em-all/+page.svelte new file mode 100644 index 0000000..beb229b --- /dev/null +++ b/src/routes/projects/catch-em-all/+page.svelte @@ -0,0 +1,330 @@ + + + + catchemall + + +
+

Gotta catch 'em all!

+
+ +
+

+ 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. +

+
+ +{#if !data.user} +

+ Log in to save your progress. +

+{/if} + +
+ + +

{caughtCount}/{total} entries caught ({percentage} %)

+ 0 ? caughtCount / total : 0}> +
+ +
(scrollTop = scrollEl?.scrollTop ?? 0)} + class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300" +> + + + + + {#each visibleGames as game (game.id)} + + {/each} + + + + {#if startIndex > 0} + + + + {/if} + {#each visiblePokemon as pokemon (pokemon.id)} + + + {#each visibleGames as game (game.id)} + {@const entry = entryMap.get(pokemon.id)?.get(game.id)} + {@const reason = tooltip(entry, game)} + + {/each} + + {/each} + {#if endIndex < data.pokemon.length} + + + + {/if} + +
Pokemon + {game.name} +
+
+ + {pokemon.number} + {pokemon.name} + {pokemon.type1} + {#if pokemon.type2} + {pokemon.type2} + {/if} +
+
+ {#if entry && reason === null} + toggleCaught(entry.id, e.currentTarget.checked)} + /> + {:else} + + + + {/if} +
+
+ + +

Set rules

+

+ Pick the games you want to catch 'em all in. Only those show up in the table and count towards + your progress. +

+ +
+ + +
+ +
{ + return async ({ result, update }) => { + await update(); + if (result.type === 'success') rulesDialog.close(); + }; + }} + > +
+ {#each gamesByGeneration as [generation, games] (generation)} +
+ + 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} + + {#each games as game (game.id)} + + {/each} +
+ {/each} +
+ +
+ + +
+
+
diff --git a/src/routes/projects/catch-em-all/layout.css b/src/routes/projects/catch-em-all/layout.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/src/routes/projects/catch-em-all/layout.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/src/routes/progress/+page.server.ts b/src/routes/projects/progress/+page.server.ts similarity index 100% rename from src/routes/progress/+page.server.ts rename to src/routes/projects/progress/+page.server.ts diff --git a/src/routes/progress/+page.svelte b/src/routes/projects/progress/+page.svelte similarity index 100% rename from src/routes/progress/+page.svelte rename to src/routes/projects/progress/+page.svelte diff --git a/src/routes/progress/[id]/+page.server.ts b/src/routes/projects/progress/[id]/+page.server.ts similarity index 100% rename from src/routes/progress/[id]/+page.server.ts rename to src/routes/projects/progress/[id]/+page.server.ts diff --git a/src/routes/progress/[id]/+page.svelte b/src/routes/projects/progress/[id]/+page.svelte similarity index 100% rename from src/routes/progress/[id]/+page.svelte rename to src/routes/projects/progress/[id]/+page.svelte diff --git a/src/routes/progress/create/+page.server.ts b/src/routes/projects/progress/create/+page.server.ts similarity index 100% rename from src/routes/progress/create/+page.server.ts rename to src/routes/projects/progress/create/+page.server.ts diff --git a/src/routes/progress/create/+page.svelte b/src/routes/projects/progress/create/+page.svelte similarity index 100% rename from src/routes/progress/create/+page.svelte rename to src/routes/projects/progress/create/+page.svelte diff --git a/src/routes/progress/topics/+page.server.ts b/src/routes/projects/progress/topics/+page.server.ts similarity index 100% rename from src/routes/progress/topics/+page.server.ts rename to src/routes/projects/progress/topics/+page.server.ts diff --git a/src/routes/progress/topics/+page.svelte b/src/routes/projects/progress/topics/+page.svelte similarity index 100% rename from src/routes/progress/topics/+page.svelte rename to src/routes/projects/progress/topics/+page.svelte diff --git a/src/routes/progress/topics/[id]/+page.server.ts b/src/routes/projects/progress/topics/[id]/+page.server.ts similarity index 100% rename from src/routes/progress/topics/[id]/+page.server.ts rename to src/routes/projects/progress/topics/[id]/+page.server.ts diff --git a/src/routes/progress/topics/[id]/+page.svelte b/src/routes/projects/progress/topics/[id]/+page.svelte similarity index 100% rename from src/routes/progress/topics/[id]/+page.svelte rename to src/routes/projects/progress/topics/[id]/+page.svelte