add animal modal and page
All checks were successful
Test and deploy staging / staging (push) Successful in 1m54s

This commit is contained in:
2026-09-23 16:56:19 +00:00
parent 3691a85fdf
commit 753b2e5d6a
6 changed files with 302 additions and 90 deletions

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import { goto, preloadData, pushState } from '$app/navigation';
import AnimalDetails from '$lib/animaldex/AnimalDetails.svelte';
import PawPrint from 'lucide-svelte/icons/paw-print';
import X from 'lucide-svelte/icons/x';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
@@ -32,7 +33,7 @@
}
let mapContainer: HTMLDivElement;
let detailsSection = $state<HTMLDivElement | null>(null);
let detailsDialog: HTMLDialogElement;
let map: MaptilerMap | null = null;
let destroyed = false;
let htmlMarkers: MaptilerMarker[] = [];
@@ -52,23 +53,40 @@
let filterTo = $state('');
let showFilters = $state(false);
let selected = $state<{
animalId: number;
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;
}
async function selectAnimal(feature: GeoJSONFeature) {
selected = { ...feature.properties, trail: parseTrail(feature.properties.trail) };
await tick();
detailsSection?.scrollIntoView({ block: 'nearest' });
// Open the animal's page as a modal over the map (shallow routing), so the map keeps its
// position. Reloading or sharing the URL shows the dedicated page instead.
async function openAnimal(animalId: number) {
const id = String(animalId);
const result = await preloadData(resolve('/projects/animaldex/[id=integer]', { id }));
if (result.type === 'loaded' && result.status === 200) {
pushState(resolve('/projects/animaldex/[id=integer]', { id }), {
animal: result.data.animal
});
} else {
goto(resolve('/projects/animaldex/[id=integer]', { id }));
}
}
function onAnimalLinkClick(event: MouseEvent, animalId: number) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
event.preventDefault();
openAnimal(animalId);
}
$effect(() => {
if (page.state.animal) {
if (!detailsDialog.open) detailsDialog.showModal();
} else if (detailsDialog.open) {
detailsDialog.close();
}
});
function onDialogClose() {
if (page.state.animal) history.back();
}
onMount(() => {
@@ -233,7 +251,7 @@
el.style.justifyContent = 'center';
}
el.onclick = () => selectAnimal(feature);
el.onclick = () => openAnimal(props.animalId);
const marker = new Marker({ element: el })
.setLngLat(feature.geometry.coordinates)
@@ -244,7 +262,6 @@
function applyFilters() {
showFilters = false;
selected = null;
if (MapMarker) loadMarkers(MapMarker);
}
function clearFilters() {
@@ -254,7 +271,6 @@
filterReporter = '';
filterFrom = '';
filterTo = '';
selected = null;
if (MapMarker) loadMarkers(MapMarker);
}
@@ -399,72 +415,6 @@
no animals here yet. try another area.
</p>{/if}
{#if selected}
<div
bind:this={detailsSection}
class="mt-8 max-w-2xl border-t border-[var(--site-border)] pt-6"
>
{#if [...selected.trail].reverse().find((point) => point.photoUrl)?.photoUrl}
<div
class="relative mb-5 aspect-video max-h-80 overflow-hidden rounded bg-[var(--site-surface)]"
>
<img
src={[...selected.trail].reverse().find((point) => point.photoUrl)?.photoUrl}
alt={selected.animalName || selected.species}
class="h-full w-full object-cover"
/>
<button
onclick={() => (selected = null)}
aria-label="Close animal details"
class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded bg-black text-white"
><X size={18} aria-hidden="true" /></button
>
</div>
{/if}
<div>
<div class="mb-1 flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold">
{selected.animalName ?? selected.species}
</h2>
<p class="mt-1 text-base text-[var(--site-muted)]">
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
</p>
</div>
<span class="text-base text-[var(--site-muted)]"
>{selected.sightingCount}
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</div>
<button
type="button"
onclick={() => (selected = null)}
class="text-base underline underline-offset-4">close details</button
>
<div class="mt-5 divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
<div class="flex flex-wrap items-center justify-between gap-2 py-3 text-base">
<span
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}</span
>
{#if s.reporterName}<span class="text-[var(--site-muted)]">by {s.reporterName}</span
>{/if}
</div>
{/each}
</div>
<a
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
class="mt-5 inline-flex items-center gap-1 text-base underline underline-offset-4"
>
i saw this animal too <ArrowRight size={16} aria-hidden="true" />
</a>
</div>
</div>
{/if}
{#if visibleAnimals.length}
<div class="mt-10">
<h2 class="mb-3 text-xl font-semibold">animals in this area</h2>
@@ -475,9 +425,9 @@
.reverse()
.find((point) => point.photoUrl)?.photoUrl}
<li class="border-t border-[var(--site-border)]">
<button
type="button"
onclick={() => selectAnimal(feature)}
<a
href={resolve('/projects/animaldex/[id=integer]', { id: String(animal.animalId) })}
onclick={(event) => onAnimalLinkClick(event, animal.animalId)}
class="flex w-full items-center gap-3 py-3 text-left hover:text-[var(--site-muted)]"
>
{#if photo}<img
@@ -495,7 +445,7 @@
{animal.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</span>
</button>
</a>
</li>
{/each}
</ul>
@@ -505,3 +455,35 @@
</div>
{/if}
</section>
<dialog
bind:this={detailsDialog}
onclose={onDialogClose}
onclick={(event) => {
if (event.target === detailsDialog) detailsDialog.close();
}}
aria-label={page.state.animal
? page.state.animal.animalName || page.state.animal.species
: 'Animal details'}
class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-5xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-bg)] p-0 text-[var(--site-text)] backdrop:bg-black/70"
>
{#if page.state.animal}
<div class="sticky top-0 z-10 flex justify-end gap-4 bg-[var(--site-bg)] px-5 py-3 sm:px-8">
<a
href={resolve('/projects/animaldex/[id=integer]', { id: String(page.state.animal.id) })}
data-sveltekit-reload
class="text-base underline underline-offset-4">open page</a
>
<button
type="button"
onclick={() => detailsDialog.close()}
aria-label="Close animal details"
class="flex h-8 w-8 items-center justify-center rounded hover:bg-[var(--site-surface)]"
><X size={20} aria-hidden="true" /></button
>
</div>
<div class="px-5 pb-8 sm:px-8">
<AnimalDetails animal={page.state.animal} />
</div>
{/if}
</dialog>

View File

@@ -0,0 +1,69 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { animals, photos, sightings } from '$lib/server/db/schema';
import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params }) => {
const [animal] = await db
.select({
id: animals.id,
species: animals.species,
breed: animals.breed,
animalName: animals.animalName,
description: animals.description
})
.from(animals)
.where(
and(
eq(animals.id, Number(params.id)),
isNotNull(animals.acceptedAt),
isNull(animals.deniedAt)
)
)
.limit(1);
if (!animal) throw error(404, { message: 'Animal not found' });
const sightingRows = await db
.select({
id: sightings.id,
seenAt: sightings.seenAt,
reporterName: sightings.reporterName,
lat: sightings.lat,
lng: sightings.lng
})
.from(sightings)
.where(
and(
eq(sightings.animalId, animal.id),
isNotNull(sightings.acceptedAt),
isNull(sightings.deniedAt)
)
)
.orderBy(desc(sightings.seenAt));
const photoRows = sightingRows.length
? await db
.select({ id: photos.id, sightingId: photos.sightingId, url: photos.url })
.from(photos)
.where(
inArray(
photos.sightingId,
sightingRows.map((s) => s.id)
)
)
.orderBy(asc(photos.sortOrder))
: [];
return {
animal: {
...animal,
sightings: sightingRows.map((s) => ({
...s,
seenAt: s.seenAt.toISOString(),
photos: photoRows.filter((p) => p.sightingId === s.id)
}))
}
};
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import AnimalDetails from '$lib/animaldex/AnimalDetails.svelte';
let { data } = $props();
</script>
<section class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/animaldex')}
class="mb-6 inline-flex items-center gap-1 text-base underline underline-offset-4"
>
<ArrowLeft size={16} aria-hidden="true" /> back to map
</a>
<AnimalDetails animal={data.animal} />
</section>