add animal modal and page
All checks were successful
Test and deploy staging / staging (push) Successful in 1m54s
All checks were successful
Test and deploy staging / staging (push) Successful in 1m54s
This commit is contained in:
@@ -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>
|
||||
|
||||
69
src/routes/projects/animaldex/[id=integer]/+page.server.ts
Normal file
69
src/routes/projects/animaldex/[id=integer]/+page.server.ts
Normal 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)
|
||||
}))
|
||||
}
|
||||
};
|
||||
};
|
||||
17
src/routes/projects/animaldex/[id=integer]/+page.svelte
Normal file
17
src/routes/projects/animaldex/[id=integer]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user