Files
personal-website/src/lib/animaldex/AnimalDetails.svelte
Stan Runge 54924b39f0
Some checks failed
Test and deploy staging / staging (push) Failing after 43s
feat: upgrade to SvelteKit 3 and adapter-bun
Run the sveltekit-3 migration and finish its manual tasks:

- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path

Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:30 +00:00

140 lines
4.4 KiB
Svelte

<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>