Some checks failed
Test and deploy staging / staging (push) Failing after 43s
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>
458 lines
14 KiB
Svelte
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>
|