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:
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
@@ -10,7 +10,9 @@ declare global {
|
||||
session: typeof import('$lib/server/auth').auth.$Infer.Session.session | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
interface PageState {
|
||||
animal?: import('$lib/animaldex/AnimalDetails.svelte').AnimalDetail;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
139
src/lib/animaldex/AnimalDetails.svelte
Normal file
139
src/lib/animaldex/AnimalDetails.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" module>
|
||||
export interface AnimalDetail {
|
||||
id: number;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
animalName: string | null;
|
||||
description: string | null;
|
||||
sightings: {
|
||||
id: number;
|
||||
seenAt: string;
|
||||
reporterName: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
photos: { id: number; url: string }[];
|
||||
}[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import PawPrint from 'lucide-svelte/icons/paw-print';
|
||||
|
||||
let { animal }: { animal: AnimalDetail } = $props();
|
||||
|
||||
const title = $derived(animal.animalName || animal.species);
|
||||
const allPhotos = $derived(animal.sightings.flatMap((s) => s.photos));
|
||||
let activePhotoId = $state<number | null>(null);
|
||||
const activePhoto = $derived(allPhotos.find((p) => p.id === activePhotoId) ?? allPhotos[0]);
|
||||
const firstSeen = $derived(animal.sightings.at(-1)?.seenAt);
|
||||
const lastSeen = $derived(animal.sightings[0]?.seenAt);
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
<div>
|
||||
<div class="aspect-[4/3] overflow-hidden rounded bg-[var(--site-surface)]">
|
||||
{#if activePhoto}
|
||||
<img src={activePhoto.url} alt={title} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-[var(--site-muted)]">
|
||||
<PawPrint size={64} aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if allPhotos.length > 1}
|
||||
<ul class="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="Photos">
|
||||
{#each allPhotos as photo (photo.id)}
|
||||
<li class="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activePhotoId = photo.id)}
|
||||
aria-label="Show photo"
|
||||
aria-pressed={photo.id === activePhoto?.id}
|
||||
class="block h-16 w-16 overflow-hidden rounded border-2 {photo.id === activePhoto?.id
|
||||
? 'border-[var(--site-text)]'
|
||||
: 'border-transparent opacity-70 hover:opacity-100'}"
|
||||
>
|
||||
<img src={photo.url} alt="" class="h-full w-full object-cover" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-3xl font-semibold tracking-tight sm:text-4xl">{title}</h2>
|
||||
<p class="mt-2 text-lg text-[var(--site-muted)]">
|
||||
{animal.species}{animal.breed ? ` · ${animal.breed}` : ''}
|
||||
</p>
|
||||
|
||||
{#if animal.description}
|
||||
<p class="mt-5 text-base whitespace-pre-line">{animal.description}</p>
|
||||
{/if}
|
||||
|
||||
<dl class="mt-6 grid grid-cols-3 gap-4 border-y border-[var(--site-border)] py-4">
|
||||
<div>
|
||||
<dt class="text-sm text-[var(--site-muted)]">sightings</dt>
|
||||
<dd class="text-xl font-semibold">{animal.sightings.length}</dd>
|
||||
</div>
|
||||
{#if firstSeen}
|
||||
<div>
|
||||
<dt class="text-sm text-[var(--site-muted)]">first seen</dt>
|
||||
<dd class="text-base font-medium">{formatDate(firstSeen)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lastSeen}
|
||||
<div>
|
||||
<dt class="text-sm text-[var(--site-muted)]">last seen</dt>
|
||||
<dd class="text-base font-medium">{formatDate(lastSeen)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<a
|
||||
href={resolve(`/projects/animaldex/register?animalId=${animal.id}`)}
|
||||
class="mt-6 inline-flex items-center gap-2 rounded bg-[var(--site-text)] px-4 py-2.5 text-base font-medium text-[var(--site-bg)]"
|
||||
>
|
||||
i saw this animal too <ArrowRight size={16} aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">sighting history</h3>
|
||||
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
|
||||
{#each animal.sightings as s (s.id)}
|
||||
<li class="flex items-center gap-3 py-3 text-base">
|
||||
{#if s.photos[0]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activePhotoId = s.photos[0].id)}
|
||||
aria-label="Show photo from {formatDate(s.seenAt)}"
|
||||
class="h-10 w-10 shrink-0 overflow-hidden rounded"
|
||||
>
|
||||
<img src={s.photos[0].url} alt="" class="h-full w-full object-cover" />
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-[var(--site-surface)] text-[var(--site-muted)]"
|
||||
><PawPrint size={18} aria-hidden="true" /></span
|
||||
>
|
||||
{/if}
|
||||
<span class="min-w-0">
|
||||
<span class="block">{formatDate(s.seenAt)}</span>
|
||||
{#if s.reporterName}<span class="block text-[var(--site-muted)]"
|
||||
>by {s.reporterName}</span
|
||||
>{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
3
src/params/integer.ts
Normal file
3
src/params/integer.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => /^\d+$/.test(param);
|
||||
@@ -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