update homepage draft and other pages
This commit is contained in:
15
src/routes/projects/animaldex/+layout.svelte
Normal file
15
src/routes/projects/animaldex/+layout.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Animaldex" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
356
src/routes/projects/animaldex/+page.svelte
Normal file
356
src/routes/projects/animaldex/+page.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||
|
||||
interface TrailPoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
seenAt: string;
|
||||
reporterName: string | null;
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
interface AnimalFeatureProps {
|
||||
animalId: string;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
animalName: string | null;
|
||||
sightingCount: number;
|
||||
trail: string | TrailPoint[];
|
||||
}
|
||||
|
||||
interface GeoJSONFeature {
|
||||
type: 'Feature';
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: AnimalFeatureProps;
|
||||
}
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: MaptilerMap | null = null;
|
||||
let htmlMarkers: MaptilerMarker[] = [];
|
||||
let MapMarker: typeof MaptilerMarker | null = null;
|
||||
|
||||
let filterSpecies = $state('');
|
||||
let filterBreed = $state('');
|
||||
let filterName = $state('');
|
||||
let filterReporter = $state('');
|
||||
let filterFrom = $state('');
|
||||
let filterTo = $state('');
|
||||
let showFilters = $state(false);
|
||||
|
||||
let selected = $state<{
|
||||
animalId: string;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
animalName: string | null;
|
||||
sightingCount: number;
|
||||
trail: TrailPoint[];
|
||||
} | null>(null);
|
||||
|
||||
function parseTrail(raw: string | TrailPoint[]): TrailPoint[] {
|
||||
return typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public');
|
||||
|
||||
config.apiKey = PUBLIC_MAPTILER_API_KEY;
|
||||
MapMarker = Marker;
|
||||
|
||||
const initMap = (center: [number, number], zoom: number) => {
|
||||
map = new Map({
|
||||
container: mapContainer,
|
||||
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY,
|
||||
center,
|
||||
zoom
|
||||
});
|
||||
|
||||
map.on('load', () => {
|
||||
map!.addSource('trails', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
});
|
||||
map!.addLayer({
|
||||
id: 'trails',
|
||||
type: 'line',
|
||||
source: 'trails',
|
||||
paint: {
|
||||
'line-color': '#f97316',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2],
|
||||
'line-opacity': 0.7
|
||||
}
|
||||
});
|
||||
loadMarkers(Marker);
|
||||
});
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
});
|
||||
};
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
|
||||
() => initMap([4.708, 52.009], 15.5),
|
||||
{ enableHighAccuracy: true, timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
htmlMarkers.forEach((m) => m.remove());
|
||||
map?.remove();
|
||||
});
|
||||
|
||||
async function loadMarkers(Marker: typeof MaptilerMarker) {
|
||||
if (!map) return;
|
||||
const bounds = map.getBounds();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const params = new URLSearchParams({
|
||||
minLat: String(bounds.getSouth()),
|
||||
minLng: String(bounds.getWest()),
|
||||
maxLat: String(bounds.getNorth()),
|
||||
maxLng: String(bounds.getEast())
|
||||
});
|
||||
if (filterSpecies) params.set('species', filterSpecies);
|
||||
if (filterBreed) params.set('breed', filterBreed);
|
||||
if (filterName) params.set('name', filterName);
|
||||
if (filterReporter) params.set('reporter', filterReporter);
|
||||
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
|
||||
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
|
||||
|
||||
const res = await fetch(`/api/map?${params}`);
|
||||
if (!res.ok) return;
|
||||
const geojson: { features: GeoJSONFeature[] } = await res.json();
|
||||
|
||||
htmlMarkers.forEach((m) => m.remove());
|
||||
htmlMarkers = [];
|
||||
|
||||
const lineFeatures = geojson.features
|
||||
.filter((f) => parseTrail(f.properties.trail).length > 1)
|
||||
.map((f) => {
|
||||
const trail = parseTrail(f.properties.trail);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [...trail]
|
||||
.sort((a, b) => new Date(a.seenAt).getTime() - new Date(b.seenAt).getTime())
|
||||
.map((s) => [s.lng, s.lat])
|
||||
},
|
||||
properties: {}
|
||||
};
|
||||
});
|
||||
|
||||
const trailSource = map?.getSource('trails');
|
||||
// @ts-expect-error setData exists on GeoJSONSource
|
||||
trailSource?.setData({ type: 'FeatureCollection', features: lineFeatures });
|
||||
|
||||
for (const feature of geojson.features) {
|
||||
const props = feature.properties;
|
||||
const trail = parseTrail(props.trail);
|
||||
const photoUrl = trail[0]?.photoUrl ?? null;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
cursor: pointer; overflow: hidden;
|
||||
background: #f97316;
|
||||
`;
|
||||
if (photoUrl) {
|
||||
el.style.backgroundImage = `url(${photoUrl})`;
|
||||
el.style.backgroundSize = 'cover';
|
||||
el.style.backgroundPosition = 'center';
|
||||
} else {
|
||||
el.innerHTML = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🐾</div>`;
|
||||
}
|
||||
|
||||
el.onclick = () => {
|
||||
selected = {
|
||||
animalId: props.animalId,
|
||||
species: props.species,
|
||||
breed: props.breed,
|
||||
animalName: props.animalName,
|
||||
sightingCount: props.sightingCount,
|
||||
trail
|
||||
};
|
||||
};
|
||||
|
||||
const marker = new Marker({ element: el })
|
||||
.setLngLat(feature.geometry.coordinates)
|
||||
.addTo(map!);
|
||||
htmlMarkers.push(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
showFilters = false;
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
}
|
||||
function clearFilters() {
|
||||
filterSpecies = '';
|
||||
filterBreed = '';
|
||||
filterName = '';
|
||||
filterReporter = '';
|
||||
filterFrom = '';
|
||||
filterTo = '';
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
}
|
||||
|
||||
const activeFilterCount = $derived(
|
||||
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
|
||||
.length
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Animaldex</title>
|
||||
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative h-screen w-full overflow-hidden">
|
||||
<div bind:this={mapContainer} class="h-full w-full"></div>
|
||||
|
||||
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||
<a
|
||||
href={resolve('/register')}
|
||||
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
|
||||
>
|
||||
+ Log a sighting
|
||||
</a>
|
||||
<button
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
|
||||
>
|
||||
Filters
|
||||
{#if activeFilterCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] text-white"
|
||||
>{activeFilterCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showFilters}
|
||||
<div
|
||||
class="absolute top-16 left-1/2 z-20 w-80 -translate-x-1/2 rounded-2xl bg-white p-4 shadow-xl"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-800">Filter sightings</h2>
|
||||
<button onclick={() => (showFilters = false)} class="text-gray-400 hover:text-gray-600"
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
bind:value={filterSpecies}
|
||||
placeholder="Species"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
<input
|
||||
bind:value={filterBreed}
|
||||
placeholder="Breed"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
<input
|
||||
bind:value={filterName}
|
||||
placeholder="Animal name"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
<input
|
||||
bind:value={filterReporter}
|
||||
placeholder="Reporter name"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label for="filter-from" class="mb-1 block text-xs text-gray-500">From</label>
|
||||
<input
|
||||
id="filter-from"
|
||||
type="date"
|
||||
bind:value={filterFrom}
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-to" class="mb-1 block text-xs text-gray-500">To</label>
|
||||
<input
|
||||
id="filter-to"
|
||||
type="date"
|
||||
bind:value={filterTo}
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
onclick={applyFilters}
|
||||
class="flex-1 rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
|
||||
>Apply</button
|
||||
>
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||
>Clear</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selected}
|
||||
<div class="absolute right-4 bottom-8 z-10 w-80 overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
{#if selected.trail[0]?.photoUrl}
|
||||
<div class="relative h-40 bg-gray-100">
|
||||
<img src={selected.trail[0].photoUrl} alt="" class="h-full w-full object-cover" />
|
||||
<button
|
||||
onclick={() => (selected = null)}
|
||||
class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
<div class="mb-1 flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-gray-900">
|
||||
{selected.animalName ?? selected.species}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700"
|
||||
>{selected.sightingCount}
|
||||
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
|
||||
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||
<span class="text-gray-600"
|
||||
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}</span
|
||||
>
|
||||
{#if s.reporterName}<span class="text-gray-400">by {s.reporterName}</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<a
|
||||
href={resolve(`/register?animalId=${selected.animalId}`)}
|
||||
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
|
||||
>
|
||||
I saw this animal too!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
1
src/routes/projects/animaldex/+page.ts
Normal file
1
src/routes/projects/animaldex/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
53
src/routes/projects/animaldex/admin/+page.server.ts
Normal file
53
src/routes/projects/animaldex/admin/+page.server.ts
Normal file
@@ -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<string>`(
|
||||
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 };
|
||||
};
|
||||
170
src/routes/projects/animaldex/admin/+page.svelte
Normal file
170
src/routes/projects/animaldex/admin/+page.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let password = $state('');
|
||||
let loginError = $state('');
|
||||
let loginLoading = $state(false);
|
||||
|
||||
async function login() {
|
||||
loginLoading = true;
|
||||
loginError = '';
|
||||
const res = await fetch('/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateAll();
|
||||
} else {
|
||||
loginError = 'Wrong password';
|
||||
}
|
||||
loginLoading = false;
|
||||
}
|
||||
|
||||
let actionLoading = $state<string | null>(null);
|
||||
|
||||
async function moderate(type: 'animals' | 'sightings', id: string, action: 'accept' | 'deny') {
|
||||
actionLoading = `${type}-${id}-${action}`;
|
||||
await fetch(`/api/${type}/${id}/moderate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
actionLoading = null;
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
const queue = $derived(data.queue ?? []);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Animaldex - Admin</title></svelte:head>
|
||||
|
||||
{#if !data.admin}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="w-80 rounded-2xl bg-white p-8 shadow-xl">
|
||||
<h1 class="mb-6 text-xl font-semibold text-gray-900">Admin login</h1>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
onkeydown={(e) => 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}
|
||||
<p class="mb-3 text-xs text-red-500">{loginError}</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={login}
|
||||
disabled={loginLoading}
|
||||
class="w-full rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{loginLoading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-6 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
|
||||
<h1 class="font-semibold text-gray-900">Animaldex admin</h1>
|
||||
</div>
|
||||
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
|
||||
{queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-4 px-4 py-6">
|
||||
{#if queue.length === 0}
|
||||
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
|
||||
No submissions yet
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each queue as item (item.animalId)}
|
||||
{@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl bg-white shadow-sm {status === 'denied'
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex gap-4 p-4">
|
||||
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{#if item.photoUrl}
|
||||
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-2xl">🐾</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900">{item.animalName ?? item.species}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{item.species}{item.breed ? ` · ${item.breed}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{status === 'accepted'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: status === 'denied'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'}"
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.aiBreedSuggestion}
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
|
||||
(item.aiBreedConfidence ?? 0) * 100
|
||||
)}%)
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{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}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 border-t border-gray-100 px-4 py-3">
|
||||
<button
|
||||
onclick={() => moderate('animals', item.animalId, 'accept')}
|
||||
disabled={actionLoading !== null || status === 'accepted'}
|
||||
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
|
||||
? 'cursor-default bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
|
||||
>
|
||||
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => moderate('animals', item.animalId, 'deny')}
|
||||
disabled={actionLoading !== null || status === 'denied'}
|
||||
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
|
||||
? 'cursor-default bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
|
||||
>
|
||||
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
25
src/routes/projects/animaldex/admin/login/+server.ts
Normal file
25
src/routes/projects/animaldex/admin/login/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
73
src/routes/projects/animaldex/api/animals/+server.ts
Normal file
73
src/routes/projects/animaldex/api/animals/+server.ts
Normal file
@@ -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<string, unknown>).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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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<string, unknown>).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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
18
src/routes/projects/animaldex/api/detect-breed/+server.ts
Normal file
18
src/routes/projects/animaldex/api/detect-breed/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
127
src/routes/projects/animaldex/api/map/+server.ts
Normal file
127
src/routes/projects/animaldex/api/map/+server.ts
Normal file
@@ -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<string>`(
|
||||
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 });
|
||||
};
|
||||
22
src/routes/projects/animaldex/api/upload/+server.ts
Normal file
22
src/routes/projects/animaldex/api/upload/+server.ts
Normal file
@@ -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);
|
||||
};
|
||||
1
src/routes/projects/animaldex/layout.css
Normal file
1
src/routes/projects/animaldex/layout.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
444
src/routes/projects/animaldex/register/+page.svelte
Normal file
444
src/routes/projects/animaldex/register/+page.svelte
Normal file
@@ -0,0 +1,444 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
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(new Date().toISOString().slice(0, 16));
|
||||
|
||||
let aiSuggestion = $state<{ breed: string; confidence: number } | null>(null);
|
||||
let aiLoading = $state(false);
|
||||
let aiDismissed = $state(false);
|
||||
|
||||
let photoFiles = $state<File[]>([]);
|
||||
let photoPreviews = $state<string[]>([]);
|
||||
|
||||
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 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(async () => {
|
||||
reporterName = localStorage.getItem('reporterName') ?? '';
|
||||
locationLoading = true;
|
||||
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 = 'Location access denied. GPS is required to register a sighting.';
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
async function initMap() {
|
||||
if (!mapContainer || lat === null || lng === null) return;
|
||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public');
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handlePhotoChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const newFiles = Array.from(input.files ?? []).slice(0, 10 - photoFiles.length);
|
||||
photoFiles = [...photoFiles, ...newFiles].slice(0, 10);
|
||||
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
|
||||
if (photoFiles.length === 1 && !isNewSighting) runAIDetection(photoFiles[0]);
|
||||
}
|
||||
|
||||
function removePhoto(index: number) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 presignRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mimeType: file.type })
|
||||
});
|
||||
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');
|
||||
photoKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (reporterName) localStorage.setItem('reporterName', reporterName);
|
||||
|
||||
const endpoint = isNewSighting ? `/api/animals/${animalId}/sightings` : '/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,
|
||||
aiBreedSuggestion: aiSuggestion?.breed,
|
||||
aiBreedConfidence: aiSuggestion?.confidence,
|
||||
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('/?submitted=1'));
|
||||
} catch (e: unknown) {
|
||||
submitError = e instanceof Error ? e.message : 'Something went wrong.';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-5 px-4 py-6">
|
||||
{#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>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<h2 class="mb-3 text-sm font-semibold text-gray-700">
|
||||
Photos
|
||||
{#if isNewSighting}
|
||||
<span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||
{:else}
|
||||
<span class="text-red-400">*</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">
|
||||
<img src={preview} alt="" class="h-full w-full object-cover" />
|
||||
<button
|
||||
onclick={() => removePhoto(i)}
|
||||
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
|
||||
>
|
||||
</div>
|
||||
{/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"
|
||||
>
|
||||
<span class="text-2xl">+</span>
|
||||
<span class="text-xs">Photo</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handlePhotoChange}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">Max 10 photos · JPEG, PNG, WebP, HEIC</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>
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each COMMON_SPECIES as s (s)}
|
||||
<button
|
||||
onclick={() => {
|
||||
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
|
||||
>
|
||||
{/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
|
||||
>
|
||||
</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"
|
||||
/>
|
||||
{/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>
|
||||
</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"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||
When did you see it? <span class="text-red-400">*</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"
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
</h2>
|
||||
{#if locationLoading}
|
||||
<div
|
||||
class="flex h-40 items-center justify-center rounded-xl bg-gray-100 text-sm text-gray-400"
|
||||
>
|
||||
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>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if submitError}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{submitError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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"
|
||||
>
|
||||
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
|
||||
</button>
|
||||
|
||||
<p class="pb-8 text-center text-xs text-gray-400">
|
||||
All submissions are reviewed before appearing on the map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
1
src/routes/projects/animaldex/register/+page.ts
Normal file
1
src/routes/projects/animaldex/register/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user