This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user