Some checks failed
Test and deploy staging / staging (push) Failing after 43s
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>
140 lines
4.4 KiB
Svelte
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>
|