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

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

View File

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