wildlife: pokedex pages (list, groups, group, species detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m4s

This commit is contained in:
2026-10-24 22:04:15 +00:00
parent ba44365c71
commit 42b6b6568a
23 changed files with 2041 additions and 0 deletions

View File

@@ -142,6 +142,14 @@ const allTrackers = [
href: '/trackers/planes',
external: false,
private: false
},
{
name: 'wildlife',
subtitle:
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
href: '/trackers/wildlife',
external: false,
private: false
}
] as const;

View File

@@ -54,6 +54,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
description:
'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.'
},
'/trackers/wildlife': {
title: 'Wildlife pokedex',
description:
'A wild animal pokedex: thousands of mammals, birds, reptiles, fish, insects and more seen in the Netherlands and around the world, with Dutch names, Red List status and photos.'
},
'/trackers/pc': {
title: 'PC setup',
description:

View File

@@ -43,6 +43,8 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false);
expect(isOwnerWritePath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(true);
expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false);
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);

View File

@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
'/trackers/books',
'/trackers/cars',
'/trackers/planes',
'/trackers/wildlife',
'/trackers/music',
'/trackers/pc'
] as const;

247
src/lib/server/wildlife.ts Normal file
View File

@@ -0,0 +1,247 @@
// Wildlife pokedex queries (/trackers/wildlife). Public, read-only; data is written only by
// scripts/wildlife-import.ts and scripts/wildlife-photos.ts.
import { db } from '#lib/server/db/index.js';
import { wildlifeGroups, wildlifePhotos, wildlifeSpecies } from '#lib/server/db/schema/index.js';
import { and, asc, eq, gt, ilike, inArray, isNotNull, sql, type SQL } from 'drizzle-orm';
import {
SORTS,
STATUSES,
WHERE,
type SpeciesCard,
type WildSort,
type WildWhere
} from '#lib/wildlife/shared.js';
export const PAGE_SIZE = 60;
export type WildFilters = {
q: string;
group: string;
status: string;
where: WildWhere | '';
photos: '' | 'yes';
sort: WildSort;
page: number;
};
const pick = <T extends string>(value: string | null, allowed: readonly T[]): T | '' =>
allowed.includes(value as T) ? (value as T) : '';
export function parseFilters(params: URLSearchParams, group = ''): WildFilters {
const sort = params.get('sort');
const status = params.get('status') ?? '';
return {
q: (params.get('q') ?? '').trim().slice(0, 80),
group: group || (params.get('group') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 60),
status: status === 'threatened' ? status : pick(status, STATUSES),
where: pick(params.get('where'), Object.keys(WHERE) as WildWhere[]),
photos: pick(params.get('photos'), ['yes'] as const),
sort: sort && sort in SORTS ? (sort as WildSort) : 'nl',
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
dex: wildlifeSpecies.dex,
slug: wildlifeSpecies.slug,
groupSlug: wildlifeGroups.slug,
groupEmoji: wildlifeGroups.emoji,
nameEn: wildlifeSpecies.nameEn,
nameNl: wildlifeSpecies.nameNl,
scientificName: wildlifeSpecies.scientificName,
iucn: wildlifeSpecies.iucn,
nlObs: wildlifeSpecies.nlObs,
worldObs: wildlifeSpecies.worldObs,
nlEstablishment: wildlifeSpecies.nlEstablishment,
photo: wildlifeSpecies.photo,
photoCount: wildlifeSpecies.photoCount
};
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
const threatRank = sql`case ${wildlifeSpecies.iucn} when 'EX' then 7 when 'EW' then 6 when 'CR' then 5
when 'EN' then 4 when 'VU' then 3 when 'NT' then 2 when 'LC' then 1 else 0 end`;
export async function searchSpecies(
f: WildFilters
): Promise<{ species: SpeciesCard[]; total: number }> {
const where: SQL[] = [];
for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6))
where.push(ilike(wildlifeSpecies.search, `%${escapeLike(word)}%`));
if (f.group) where.push(eq(wildlifeGroups.slug, f.group));
if (f.status === 'threatened')
where.push(inArray(wildlifeSpecies.iucn, ['VU', 'EN', 'CR', 'EW']));
else if (f.status) where.push(eq(wildlifeSpecies.iucn, f.status));
if (f.where === 'nl') where.push(gt(wildlifeSpecies.nlObs, 0));
if (f.where === 'europe')
where.push(and(eq(wildlifeSpecies.nlObs, 0), gt(wildlifeSpecies.europeObs, 0))!);
if (f.where === 'world')
where.push(and(eq(wildlifeSpecies.nlObs, 0), eq(wildlifeSpecies.europeObs, 0))!);
if (f.photos) where.push(isNotNull(wildlifeSpecies.photo));
const order = {
nl: [
sql`${wildlifeSpecies.nlObs} desc`,
sql`${wildlifeSpecies.worldObs} desc`,
asc(wildlifeSpecies.dex)
],
world: [sql`${wildlifeSpecies.worldObs} desc`, asc(wildlifeSpecies.dex)],
dex: [asc(wildlifeSpecies.dex)],
name: [
sql`coalesce(${wildlifeSpecies.nameEn}, ${wildlifeSpecies.nameNl}, ${wildlifeSpecies.scientificName})`
],
threatened: [sql`${threatRank} desc`, sql`${wildlifeSpecies.worldObs} desc`],
heaviest: [sql`${wildlifeSpecies.massKg} desc nulls last`, asc(wildlifeSpecies.dex)],
longest: [sql`${wildlifeSpecies.lengthM} desc nulls last`, asc(wildlifeSpecies.dex)],
oldest: [sql`${wildlifeSpecies.lifespanYears} desc nulls last`, asc(wildlifeSpecies.dex)]
}[f.sort];
const condition = where.length ? and(...where) : undefined;
const [species, [{ total }]] = await Promise.all([
db
.select(cardColumns)
.from(wildlifeSpecies)
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
.where(condition)
.orderBy(...order)
.limit(PAGE_SIZE)
.offset((f.page - 1) * PAGE_SIZE),
db
.select({ total: sql<number>`count(*)::int` })
.from(wildlifeSpecies)
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
.where(condition)
]);
return { species, total };
}
type Overview = {
groups: {
slug: string;
name: string;
nameNl: string;
emoji: string;
speciesCount: number;
nlCount: number;
photo: string | null;
}[];
stats: { species: number; groups: number; nl: number; photos: number; threatened: number };
};
let overviewCache: { at: number; value: Overview } | null = null;
/** Groups (in display order) and headline counts; cached for 10 minutes. */
export async function wildlifeOverview(): Promise<Overview> {
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
const [groups, [counts]] = await Promise.all([
db
.select({
slug: wildlifeGroups.slug,
name: wildlifeGroups.name,
nameNl: wildlifeGroups.nameNl,
emoji: wildlifeGroups.emoji,
speciesCount: wildlifeGroups.speciesCount,
nlCount: wildlifeGroups.nlCount,
photo: wildlifeGroups.photo
})
.from(wildlifeGroups)
.where(gt(wildlifeGroups.speciesCount, 0))
.orderBy(asc(wildlifeGroups.sortOrder)),
db
.select({
species: sql<number>`count(*)::int`,
nl: sql<number>`count(*) filter (where ${wildlifeSpecies.nlObs} > 0)::int`,
photos: sql<number>`coalesce(sum(${wildlifeSpecies.photoCount}), 0)::int`,
threatened: sql<number>`count(*) filter (where ${wildlifeSpecies.iucn} in ('VU','EN','CR','EW'))::int`
})
.from(wildlifeSpecies)
]);
const value = { groups, stats: { ...counts, groups: groups.length } };
if (value.stats.species) overviewCache = { at: Date.now(), value };
return value;
}
export async function getGroup(slug: string) {
const [group] = await db
.select()
.from(wildlifeGroups)
.where(eq(wildlifeGroups.slug, slug))
.limit(1);
return group ?? null;
}
export async function getSpecies(groupSlug: string, slug: string) {
const [row] = await db
.select({ species: wildlifeSpecies, group: wildlifeGroups })
.from(wildlifeSpecies)
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
.where(and(eq(wildlifeGroups.slug, groupSlug), eq(wildlifeSpecies.slug, slug)))
.limit(1);
if (!row) return null;
const s = row.species;
const [neighbours, photos, relatives] = await Promise.all([
db
.select({
dex: wildlifeSpecies.dex,
slug: wildlifeSpecies.slug,
name: sql<string>`coalesce(${wildlifeSpecies.nameEn}, ${wildlifeSpecies.nameNl}, ${wildlifeSpecies.scientificName})`,
groupSlug: wildlifeGroups.slug
})
.from(wildlifeSpecies)
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
.where(
sql`${wildlifeSpecies.dex} in (
(select max(dex) from wildlife.species where dex < ${s.dex}),
(select min(dex) from wildlife.species where dex > ${s.dex}))`
),
db
.select({
kind: wildlifePhotos.kind,
photoId: wildlifePhotos.photoId,
url: wildlifePhotos.url,
width: wildlifePhotos.width,
height: wildlifePhotos.height,
author: wildlifePhotos.author,
license: wildlifePhotos.license,
pageUrl: wildlifePhotos.pageUrl,
place: wildlifePhotos.place
})
.from(wildlifePhotos)
.where(eq(wildlifePhotos.speciesId, s.id))
.orderBy(asc(wildlifePhotos.position)),
// Same family (else order), most seen first.
s.familyName || s.orderName
? db
.select(cardColumns)
.from(wildlifeSpecies)
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
.where(
and(
s.familyName
? eq(wildlifeSpecies.familyName, s.familyName)
: eq(wildlifeSpecies.orderName, s.orderName!),
sql`${wildlifeSpecies.id} <> ${s.id}`
)
)
.orderBy(sql`${wildlifeSpecies.nlObs} desc`, sql`${wildlifeSpecies.worldObs} desc`)
.limit(8)
: Promise.resolve([] as SpeciesCard[])
]);
return {
...row,
photos,
relatives,
prev: neighbours.find((n) => n.dex < s.dex) ?? null,
next: neighbours.find((n) => n.dex > s.dex) ?? null
};
}
/** A random species with a photo. */
export async function randomSpecies(group?: string) {
const [row] = await db
.select({ slug: wildlifeSpecies.slug, groupSlug: wildlifeGroups.slug })
.from(wildlifeSpecies)
.innerJoin(wildlifeGroups, eq(wildlifeGroups.id, wildlifeSpecies.groupId))
.where(
and(isNotNull(wildlifeSpecies.photo), group ? eq(wildlifeGroups.slug, group) : undefined)
)
.orderBy(sql`random()`)
.limit(1);
return row ?? null;
}

View File

@@ -21,6 +21,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/trackers/books': 'Books',
'/trackers/cars': 'Cars',
'/trackers/planes': 'Planes',
'/trackers/wildlife': 'Wildlife',
'/trackers/pc': 'PC setup',
'/blog': 'Blog',
'/lists': 'Lists',

View File

@@ -122,6 +122,15 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z'
]
},
wildlife: {
from: '#86efac',
to: '#15803d',
ink: WHITE,
paths: [
'M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z',
'M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12'
]
},
lists: {
from: '#94a3b8',
to: '#475569',

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import X from 'lucide-svelte/icons/x';
import { licenseLabel, licenseUrl, photoAt, photoSrcset, type WildPhoto } from './shared.js';
let { photos, name }: { photos: WildPhoto[]; name: string } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let index = $state(0);
const current = $derived(photos[index]);
const alt = (p: WildPhoto, i: number) =>
`${name}${p.place ? `, ${p.place}` : ''}, photo ${i + 1} of ${photos.length}`;
const page = (p: WildPhoto) => p.pageUrl ?? `https://www.inaturalist.org/photos/${p.photoId}`;
// Links go to the photo on iNaturalist; with JS they open the lightbox instead.
function open(event: MouseEvent, i: number) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
event.preventDefault();
index = i;
dialog?.showModal();
}
const step = (d: number) => (index = (index + d + photos.length) % photos.length);
function onkeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight') step(1);
else if (event.key === 'ArrowLeft') step(-1);
else return;
event.preventDefault();
}
function onclick(event: MouseEvent) {
if (event.target === dialog) dialog?.close();
}
</script>
{#snippet credit(p: WildPhoto)}
<a href={page(p)} rel="noopener">© {p.author ?? 'iNaturalist user'}</a> ·
<a href={licenseUrl(p.license)} rel="noopener license">{licenseLabel(p.license)}</a>
{/snippet}
<section class="gallery" aria-label="Photos of {name}">
<ul class:single={photos.length === 1}>
{#each photos as p, i (p.photoId)}
<li class:lead={i === 0}>
<a href={page(p)} onclick={(e) => open(e, i)} title={p.place ?? undefined}>
<img
src={photoAt(p.url, i === 0 ? 'large' : 'medium')}
srcset={i === 0 ? photoSrcset(p.url, ['medium', 'large']) : photoSrcset(p.url)}
sizes={i === 0 ? '(min-width: 1152px) 680px, 100vw' : '(min-width: 1152px) 220px, 50vw'}
alt={alt(p, i)}
loading={i === 0 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
decoding="async"
width={p.width ?? undefined}
height={p.height ?? undefined}
/>
{#if p.place}<span class="cap">📍 {p.place}</span>{/if}
</a>
</li>
{/each}
</ul>
<details class="credits">
<summary>
{photos.length} photo{photos.length === 1 ? '' : 's'} from iNaturalist (Creative Commons) · credits
& licences
</summary>
<ol>
{#each photos as p, i (p.photoId)}
<li>
{i + 1}. {#if p.place}{p.place}:
{/if}{@render credit(p)}
</li>
{/each}
</ol>
</details>
</section>
<dialog bind:this={dialog} {onkeydown} {onclick} aria-label="{name} photos">
{#if current}
<figure>
{#key index}
<img src={photoAt(current.url, 'large')} alt={alt(current, index)} />
{/key}
<figcaption>
<span
>{index + 1}/{photos.length}{#if current.place}
· 📍 {current.place}{/if}</span
>
<span class="credit">{@render credit(current)}</span>
</figcaption>
</figure>
{#if photos.length > 1}
<button class="nav prev" onclick={() => step(-1)} aria-label="Previous photo"
><ChevronLeft size={28} /></button
>
<button class="nav next" onclick={() => step(1)} aria-label="Next photo"
><ChevronRight size={28} /></button
>
{/if}
<button class="close" onclick={() => dialog?.close()} aria-label="Close" autofocus
><X size={22} /></button
>
{/if}
</dialog>
<style>
.gallery ul {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(2, 1fr);
}
.gallery li.lead {
grid-column: 1 / -1;
}
@media (min-width: 768px) {
.gallery ul {
grid-template-columns: repeat(4, 1fr);
}
.gallery li.lead {
grid-column: span 2;
grid-row: span 2;
}
.gallery ul.single {
grid-template-columns: 1fr;
}
}
.gallery a {
position: relative;
display: block;
height: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 0.7rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
}
.gallery li.lead a {
aspect-ratio: auto;
min-height: 16rem;
}
.gallery img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.gallery a:hover img,
.gallery a:focus-visible img {
transform: scale(1.04);
}
.cap {
position: absolute;
left: 0.4rem;
bottom: 0.4rem;
max-width: calc(100% - 0.8rem);
padding: 0.05rem 0.4rem;
border-radius: 0.35rem;
font-size: 0.68rem;
color: #fff;
background: rgb(0 0 0 / 0.55);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.credits {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.credits summary {
cursor: pointer;
}
.credits ol {
margin-top: 0.4rem;
display: grid;
gap: 0.15rem;
}
.credits a,
figcaption a {
text-decoration: underline;
}
dialog {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: rgb(0 0 0 / 0.92);
color: #fff;
}
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
}
figure {
position: absolute;
inset: 3.5rem 1rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
pointer-events: none;
}
figure img {
max-width: 100%;
min-height: 0;
flex: 0 1 auto;
object-fit: contain;
border-radius: 0.4rem;
pointer-events: auto;
}
figcaption {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.3rem 1rem;
font-size: 0.8rem;
color: rgb(255 255 255 / 0.8);
pointer-events: auto;
text-align: center;
}
.nav,
.close {
position: absolute;
display: grid;
place-items: center;
border-radius: 999px;
background: rgb(255 255 255 / 0.12);
color: #fff;
}
.nav:hover,
.close:hover,
.nav:focus-visible,
.close:focus-visible {
background: rgb(255 255 255 / 0.25);
}
.nav {
top: 50%;
width: 3rem;
height: 3rem;
transform: translateY(-50%);
}
.prev {
left: 0.75rem;
}
.next {
right: 0.75rem;
}
.close {
top: 0.75rem;
right: 0.75rem;
width: 2.5rem;
height: 2.5rem;
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { fmtNumber, groupHue, photoAt, type GroupTileData } from './shared.js';
let { group, compact = false }: { group: GroupTileData; compact?: boolean } = $props();
</script>
<a
href="/trackers/wildlife/{group.slug}"
class={['tile', { compact }]}
title="{group.name} · {group.nameNl}"
style:--hue={groupHue(group.slug)}
>
{#if group.photo}
<img
src={photoAt(group.photo, compact ? 'small' : 'medium')}
alt=""
loading="lazy"
decoding="async"
/>
{/if}
<span class="shade"></span>
<span class="emoji" aria-hidden="true">{group.emoji}</span>
<span class="text">
<span class="name">{group.name}</span>
{#if !compact}<span class="nl">{group.nameNl}</span>{/if}
<span class="meta"
>{fmtNumber(group.speciesCount)} species{#if group.nlCount}
· {fmtNumber(group.nlCount)} in NL{/if}</span
>
</span>
</a>
<style>
.tile {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
min-height: 9rem;
overflow: hidden;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: radial-gradient(
circle at 30% 20%,
hsl(var(--hue) 35% 24%),
hsl(var(--hue) 30% 10%)
);
color: #fff;
transition:
transform 0.15s,
border-color 0.15s;
}
.compact {
min-height: 6.5rem;
}
.tile:hover,
.tile:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.tile:hover img {
transform: scale(1.06);
}
.shade {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.8), rgb(0 0 0 / 0.1) 65%);
}
.emoji {
position: absolute;
top: 0.5rem;
left: 0.55rem;
font-size: 1.5rem;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.6));
}
.compact .emoji {
font-size: 1.15rem;
}
.text {
position: relative;
display: flex;
flex-direction: column;
padding: 0.6rem 0.7rem;
min-width: 0;
}
.name {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.2;
}
.compact .name {
font-size: 0.8rem;
}
.nl {
font-size: 0.72rem;
opacity: 0.85;
}
.meta {
margin-top: 0.15rem;
font-size: 0.68rem;
opacity: 0.8;
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { page } from '$app/state';
import SpeciesCard from './SpeciesCard.svelte';
import {
fmtNumber,
SORTS,
STATUSES,
statusLabel,
WHERE,
type SpeciesCard as Card
} from './shared.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
import type { WildFilters } from '#lib/server/wildlife.js';
let {
base,
filters: f,
species,
total,
pageSize,
groups = null,
quick = []
}: {
base: string;
filters: WildFilters;
species: Card[];
total: number;
pageSize: number;
groups?: { slug: string; name: string; emoji: string; speciesCount: number }[] | null;
quick?: { label: string; params: Record<string, string> }[];
} = $props();
const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
const filtering = $derived(
!!(f.q || (groups && f.group) || f.status || f.where || f.photos || f.sort !== 'nl')
);
let form: HTMLFormElement | undefined = $state();
let timer: ReturnType<typeof setTimeout> | undefined;
const submit = () => {
clearTimeout(timer);
form?.requestSubmit();
};
const submitSoon = () => {
clearTimeout(timer);
timer = setTimeout(submit, 350);
};
function withParams(changes: Record<string, string | number | null>) {
const merged: Record<string, string> = Object.fromEntries(page.url.searchParams);
if (!('page' in changes)) delete merged.page;
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') delete merged[key];
else merged[key] = String(value);
}
const qs = new URLSearchParams(merged).toString();
return `${base}${qs ? `?${qs}` : ''}`;
}
</script>
<form
bind:this={form}
method="GET"
action={base}
class="filters"
data-sveltekit-keepfocus
data-sveltekit-noscroll
data-sveltekit-replacestate
>
<label class="search">
<Search size={16} aria-hidden="true" />
<Input
type="search"
name="q"
value={f.q}
oninput={submitSoon}
placeholder="Search 'vos', 'red fox', 'vulpes', 'owl', 'libel'…"
aria-label="Search species"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
{#if groups}
<NativeSelect name="group" aria-label="Group" value={f.group} onchange={submit}>
<option value="">all animals</option>
{#each groups as g (g.slug)}<option value={g.slug}
>{g.emoji} {g.name} ({g.speciesCount})</option
>{/each}
</NativeSelect>
{/if}
<NativeSelect name="status" aria-label="IUCN status" value={f.status} onchange={submit}>
<option value="">any status</option>
<option value="threatened">⚠️ threatened (VU, EN, CR)</option>
{#each STATUSES as s (s)}<option value={s}>{s} · {statusLabel[s]}</option>{/each}
</NativeSelect>
<NativeSelect name="where" aria-label="Where" value={f.where} onchange={submit}>
<option value="">anywhere</option>
{#each Object.entries(WHERE) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<NativeSelect name="sort" aria-label="Sort by" value={f.sort} onchange={submit}>
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<noscript><button type="submit" class="pill">search</button></noscript>
</div>
</form>
<div class="mb-6 flex flex-wrap items-center gap-2">
{#each quick as chip (chip.label)}
<a href={withParams({ ...chip.params })} class="quick">{chip.label}</a>
{/each}
{#if filtering}
<a href={base} class="quick clear"><X size={13} /> clear filters</a>
{/if}
</div>
<p class="mb-4 text-sm text-[var(--site-muted)]" aria-live="polite">
{fmtNumber(total)}
{total === 1 ? 'species' : 'species'}{#if pages > 1}
· page {f.page} of {pages}{/if}
</p>
{#if species.length}
<ul class="grid">
{#each species as s, i (s.dex)}
<li><SpeciesCard species={s} eager={i < 4} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No animals match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href={base} class="underline">clear the filters</a>.
</p>
</div>
{/if}
{#if pages > 1}
<nav class="pager" aria-label="Pages">
{#if f.page > 1}<a href={withParams({ page: f.page - 1 })} class="pill">← prev</a>{/if}
<span class="text-sm text-[var(--site-muted)] tabular-nums">{f.page} / {pages}</span>
{#if f.page < pages}<a href={withParams({ page: f.page + 1 })} class="pill">next →</a>{/if}
</nav>
{/if}
<style>
.filters {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 0.9rem;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0 0.75rem;
color: var(--site-muted);
background: var(--site-surface);
}
.search:focus-within {
border-color: var(--site-muted);
}
.selects {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.selects :global(select) {
max-width: 14rem;
}
.pill,
.quick {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
background: var(--site-surface);
transition: background 0.15s;
}
.quick {
padding: 0.2rem 0.7rem;
font-size: 0.8rem;
font-weight: 400;
}
.pill:hover,
.quick:hover {
background: var(--site-hover);
}
.clear {
color: var(--site-muted);
}
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
}
.empty {
border: 1px dashed var(--site-border);
border-radius: 0.9rem;
padding: 3rem 1rem;
text-align: center;
}
.pager {
margin-top: 2rem;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import StatusChip from './StatusChip.svelte';
import {
dexLabel,
displayName,
fmtCompact,
groupHue,
photoAt,
photoSrcset,
type SpeciesCard
} from './shared.js';
let { species, eager = false }: { species: SpeciesCard; eager?: boolean } = $props();
const href = $derived(`/trackers/wildlife/${species.groupSlug}/${species.slug}`);
const name = $derived(displayName(species));
</script>
<a {href} class="card">
<div class="media" style:--hue={groupHue(species.groupSlug)}>
{#if species.photo}
<img
src={photoAt(species.photo, 'medium')}
srcset={photoSrcset(species.photo)}
sizes="(min-width: 1024px) 270px, (min-width: 640px) 45vw, 100vw"
alt={name}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
width="330"
height="248"
/>
{:else}
<span class="placeholder" aria-hidden="true">{species.groupEmoji}</span>
{/if}
<span class="emoji" aria-hidden="true">{species.groupEmoji}</span>
<span class="dex">{dexLabel(species.dex)}</span>
{#if species.photoCount > 1}<span class="count" title="photos">📷 {species.photoCount}</span
>{/if}
</div>
<div class="min-w-0">
<h3 class="name">{name}</h3>
{#if species.nameNl && species.nameNl !== name}<p class="nl">🇳🇱 {species.nameNl}</p>{/if}
<p class="sci">{species.scientificName}</p>
</div>
<div class="foot">
<StatusChip iucn={species.iucn} />
<span class="seen" title="Research-grade observations on iNaturalist">
{#if species.nlObs > 0}👀 {fmtCompact(species.nlObs)} in NL{:else}🌍 {fmtCompact(
species.worldObs
)} seen{/if}
</span>
</div>
</a>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.55rem;
height: 100%;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
padding: 0.9rem;
transition:
transform 0.15s,
border-color 0.15s,
background 0.15s;
}
.card:hover,
.card:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
background: var(--site-hover);
}
.media {
position: relative;
margin: -0.9rem -0.9rem 0.1rem;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 0.85rem 0.85rem 0 0;
background: radial-gradient(circle at 30% 20%, hsl(var(--hue) 35% 22%), hsl(var(--hue) 30% 9%));
}
.media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.card:hover .media img {
transform: scale(1.05);
}
.placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3.5rem;
opacity: 0.35;
}
.emoji {
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
font-size: 1rem;
line-height: 1;
background: rgb(0 0 0 / 0.5);
border-radius: 999px;
padding: 0.25rem 0.35rem;
backdrop-filter: blur(4px);
}
.dex,
.count {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.72rem;
color: #fff;
background: rgb(0 0 0 / 0.55);
border-radius: 999px;
padding: 0.05rem 0.45rem;
backdrop-filter: blur(4px);
}
.count {
top: auto;
bottom: 0.55rem;
}
.name {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.nl {
margin-top: 0.1rem;
font-size: 0.78rem;
}
.sci {
margin-top: 0.1rem;
font-size: 0.75rem;
font-style: italic;
color: var(--site-muted);
overflow-wrap: anywhere;
}
.foot {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
border-top: 1px solid var(--site-border);
padding-top: 0.55rem;
min-height: 2rem;
}
.seen {
font-size: 0.72rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { statusColor, statusLabel, statusLabelNl } from './shared.js';
let { iucn, size = 'sm' }: { iucn: string | null; size?: 'sm' | 'md' } = $props();
</script>
{#if iucn}
<span
class={['status', size]}
style:--c={statusColor[iucn] ?? '#94a3b8'}
title="IUCN Red List: {statusLabel[iucn] ?? iucn} ({statusLabelNl[iucn] ?? ''})"
><b>{iucn}</b>{#if size === 'md'}
{statusLabel[iucn] ?? iucn}{/if}</span
>
{/if}
<style>
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--c) 60%, transparent);
background: color-mix(in srgb, var(--c) 18%, transparent);
white-space: nowrap;
line-height: 1;
}
.status b {
font-weight: 700;
color: var(--c);
}
.sm {
font-size: 0.68rem;
padding: 0.22rem 0.45rem;
}
.md {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
}
</style>

129
src/lib/wildlife/shared.ts Normal file
View File

@@ -0,0 +1,129 @@
// Shared (client + server) labels and helpers for the wildlife pokedex (/trackers/wildlife).
export { dexLabel, fmtCompact, fmtNumber } from '#lib/cars/shared.js';
export const STATUSES = ['LC', 'NT', 'VU', 'EN', 'CR', 'EW', 'EX', 'DD'] as const;
export const statusLabel: Record<string, string> = {
LC: 'least concern',
NT: 'near threatened',
VU: 'vulnerable',
EN: 'endangered',
CR: 'critically endangered',
EW: 'extinct in the wild',
EX: 'extinct',
DD: 'data deficient'
};
/** Dutch IUCN labels. */
export const statusLabelNl: Record<string, string> = {
LC: 'niet bedreigd',
NT: 'gevoelig',
VU: 'kwetsbaar',
EN: 'bedreigd',
CR: 'ernstig bedreigd',
EW: 'uitgestorven in het wild',
EX: 'uitgestorven',
DD: 'onvoldoende gegevens'
};
/** Red List colours (roughly the official palette). */
export const statusColor: Record<string, string> = {
LC: '#16a34a',
NT: '#84cc16',
VU: '#eab308',
EN: '#f97316',
CR: '#dc2626',
EW: '#7c3aed',
EX: '#52525b',
DD: '#94a3b8'
};
/** 0 (safe) .. 1 (gone), for the "threat" bar. */
export const statusThreat: Record<string, number> = {
LC: 0.08,
NT: 0.25,
VU: 0.45,
EN: 0.65,
CR: 0.85,
EW: 0.95,
EX: 1
};
export const WHERE = {
nl: '🇳🇱 seen in NL',
europe: '🇪🇺 Europe, not NL',
world: '🌍 rest of the world'
} as const;
export type WildWhere = keyof typeof WHERE;
export const SORTS = {
nl: 'most seen in NL',
world: 'most seen worldwide',
dex: 'dex #',
name: 'name',
threatened: 'most threatened',
heaviest: 'heaviest',
longest: 'longest',
oldest: 'longest living'
} as const;
export type WildSort = keyof typeof SORTS;
export type SpeciesCard = {
dex: number;
slug: string;
groupSlug: string;
groupEmoji: string;
nameEn: string | null;
nameNl: string | null;
scientificName: string;
iucn: string | null;
nlObs: number;
worldObs: number;
nlEstablishment: string | null;
photo: string | null;
photoCount: number;
};
export type GroupTileData = {
slug: string;
name: string;
nameNl: string;
emoji: string;
speciesCount: number;
nlCount: number;
photo: string | null;
};
export type WildPhoto = {
kind: string;
photoId: number;
url: string;
width: number | null;
height: number | null;
author: string | null;
license: string;
pageUrl: string | null;
place: string | null;
};
/** iNaturalist photo URLs come in fixed sizes: small 240, medium 500, large 1024 px. */
export const photoAt = (url: string, size: 'small' | 'medium' | 'large') =>
url.replace(/\/(square|small|medium|large|original)\.(\w+)(\?.*)?$/, `/${size}.$2`);
export const photoSrcset = (
url: string,
sizes: ('small' | 'medium' | 'large')[] = ['small', 'medium']
) =>
sizes.map((s) => `${photoAt(url, s)} ${{ small: 240, medium: 500, large: 1024 }[s]}w`).join(', ');
export const licenseLabel = (code: string) =>
code === 'cc0' ? 'CC0' : `${code.toUpperCase().replace(/^CC-/, 'CC ').replace(/-/g, '-')} 4.0`;
export const licenseUrl = (code: string) =>
code === 'cc0'
? 'https://creativecommons.org/publicdomain/zero/1.0/'
: `https://creativecommons.org/licenses/${code.replace(/^cc-/, '')}/4.0/`;
/** Main display name: English, else Dutch, else scientific. */
export const displayName = (s: {
nameEn: string | null;
nameNl: string | null;
scientificName: string;
}) => s.nameEn ?? s.nameNl ?? s.scientificName;
/** Stable hue per group for placeholders. */
export function groupHue(slug: string): number {
let h = 0;
for (const c of slug) h = (h * 31 + c.charCodeAt(0)) % 360;
return h;
}