Files
website/src/routes/projects/animaldex/register/+page.svelte
Stan Runge 54924b39f0
Some checks failed
Test and deploy staging / staging (push) Failing after 43s
feat: upgrade to SvelteKit 3 and adapter-bun
Run the sveltekit-3 migration and finish its manual tasks:

- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path

Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:30 +00:00

458 lines
14 KiB
Svelte

<script lang="ts">
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import X from 'lucide-svelte/icons/x';
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(localDateTime());
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);
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 mapError = $state('');
let destroyed = false;
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(() => {
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;
deviceLng = pos.coords.longitude;
lat = pos.coords.latitude;
lng = pos.coords.longitude;
locationLoading = false;
},
() => {
locationLoading = false;
locationError = 'Allow location access to log a sighting near you.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
async function initMap() {
if (!mapContainer || lat === null || lng === null) return;
try {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { PUBLIC_MAPTILER_API_KEY } = await import('$app/env/public');
if (destroyed) return;
if (!PUBLIC_MAPTILER_API_KEY) {
mapError = 'The map is not configured yet. Your GPS location is still available.';
return;
}
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;
});
});
} 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;
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));
}
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 = photoFiles.map((file) => URL.createObjectURL(file));
}
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 uploadForm = new FormData();
uploadForm.set('photo', file);
const uploadRes = await fetch(resolve('projects/animaldex/api/upload'), {
method: 'POST',
body: uploadForm
});
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
? resolve(`projects/animaldex/api/animals/${animalId}/sightings`)
: resolve('projects/animaldex/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,
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('projects/animaldex?submitted=1'));
} catch (e: unknown) {
submitError = e instanceof Error ? e.message : 'Something went wrong.';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
</svelte:head>
<section class="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('projects/animaldex')}
class="inline-flex items-center gap-1 text-base text-[var(--site-muted)] hover:text-[var(--site-text)]"
><ArrowLeft size={16} aria-hidden="true" /> animaldex</a
>
<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="mt-8 space-y-8">
{#if locationError}
<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>
<Button type="button" variant="outline" onclick={requestLocation} class="mt-3"
>try again</Button
>
</div>
{/if}
<section class="border-t border-[var(--site-border)] pt-6">
<h2 class="mb-3 text-xl font-semibold">
photos
{#if isNewSighting}
<span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
{:else}
<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">
<img src={preview} alt="" class="h-full w-full object-cover" />
<Button
variant="secondary"
size="icon-xs"
type="button"
onclick={() => removePhoto(i)}
aria-label={`Remove photo ${i + 1}`}
class="absolute top-1 right-1 rounded-full"><X size={12} aria-hidden="true" /></Button
>
</div>
{/each}
{#if photoFiles.length < 10}
<label
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>drop photos here or choose files</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
class="absolute inset-0 w-full cursor-pointer opacity-0"
onchange={handlePhotoChange}
/>
</label>
{/if}
</div>
<p class="mt-3 text-base text-[var(--site-muted)]">
up to 10 photos · JPEG, PNG, WebP, HEIC · 5 MB each
</p>
</section>
{#if !isNewSighting}
<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)}
<Button
variant={species === s ? 'secondary' : 'outline'}
size="sm"
onclick={() => {
species = s;
customSpecies = '';
}}>{s}</Button
>
{/each}
<Button
variant={species === '__custom__' ? 'secondary' : 'outline'}
size="sm"
onclick={() => {
species = '__custom__';
}}>Other…</Button
>
</div>
{#if species === '__custom__'}
<Input
bind:value={customSpecies}
placeholder="Enter species"
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="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 border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</section>
<section class="space-y-3 border-t border-[var(--site-border)] pt-6">
<div>
<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 border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
<div>
<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 border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
</section>
{/if}
<section class="space-y-3 border-t border-[var(--site-border)] pt-6">
<div>
<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={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-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 border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
</div>
</section>
<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-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-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 role="alert" class="border-y border-[var(--site-border)] py-3 text-base">
{submitError}
</div>
{/if}
<Button
onclick={submit}
disabled={submitting || !!locationError || lat === null}
class="h-11 w-full"
>
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
</Button>
<p class="pb-8 text-center text-base text-[var(--site-muted)]">
All submissions are reviewed before appearing on the map.
</p>
</div>
</section>