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

@@ -0,0 +1,22 @@
import { PAGE_SIZE, parseFilters, searchSpecies, wildlifeOverview } from '#lib/server/wildlife.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ species, total }, overview] = await Promise.all([
searchSpecies(filters),
wildlifeOverview()
]);
return {
filters,
species,
total,
pageSize: PAGE_SIZE,
groups: overview.groups,
stats: overview.stats,
seo: {
title: 'Wildlife pokedex',
description: `Search ${overview.stats.species.toLocaleString('en-GB')} wild animal species: everything you can spot in the Netherlands plus famous animals worldwide, with Dutch names, Red List status and photos.`
}
};
};

View File

@@ -0,0 +1,143 @@
<script lang="ts">
import GroupTile from '#lib/wildlife/GroupTile.svelte';
import SpeciesBrowser from '#lib/wildlife/SpeciesBrowser.svelte';
import { fmtNumber } from '#lib/wildlife/shared.js';
import Shuffle from 'lucide-svelte/icons/shuffle';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const f = $derived(data.filters);
const filtering = $derived(!!(f.q || f.group || f.status || f.where || f.sort !== 'nl'));
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '🇳🇱 Dutch birds', params: { group: 'birds', where: 'nl', sort: 'nl' } },
{ label: '🦊 Dutch mammals', params: { group: 'mammals', where: 'nl', sort: 'nl' } },
{ label: '⚠️ threatened', params: { status: 'threatened', sort: 'world' } },
{ label: '🌍 famous worldwide', params: { where: 'world', sort: 'world' } },
{ label: '🐘 heaviest', params: { sort: 'heaviest' } },
{ label: '🐢 longest living', params: { sort: 'oldest' } },
{ label: '🦉 owls', params: { q: 'owl' } }
];
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">wildlife</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A wild animal pokedex: the mammals, birds, reptiles, fish, insects and other creatures you
can actually spot in the Netherlands and Europe, plus famous animals from around the world.
Dutch and English names, how endangered they are and lots of photos.
</p>
</div>
<a href="/trackers/wildlife/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random animal</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>species</dt>
<dd>{fmtNumber(data.stats.species)}</dd>
</div>
<div>
<dt>seen in NL</dt>
<dd>{fmtNumber(data.stats.nl)}</dd>
</div>
<div>
<dt>threatened</dt>
<dd>{fmtNumber(data.stats.threatened)}</dd>
</div>
<div>
<dt>photos</dt>
<dd>{fmtNumber(data.stats.photos)}</dd>
</div>
</dl>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="groups-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 id="groups-heading" class="text-lg font-semibold">groups</h2>
<a href="/trackers/wildlife/groups" class="text-sm text-[var(--site-muted)] hover:underline"
>all {data.stats.groups} groups →</a
>
</div>
<ul class="groups">
{#each data.groups as group (group.slug)}
<li><GroupTile {group} compact /></li>
{/each}
</ul>
</section>
{/if}
<SpeciesBrowser
base="/trackers/wildlife"
filters={data.filters}
species={data.species}
total={data.total}
pageSize={data.pageSize}
groups={data.groups}
{quick}
/>
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
Data: <a href="https://www.inaturalist.org" class="underline">iNaturalist</a> (species lists,
names, research-grade wild observations, IUCN Red List status and Wikipedia intros),
<a href="https://www.wikidata.org" class="underline">Wikidata</a> (CC0: Dutch names, size,
weight, lifespan, who named it) and <a href="https://www.gbif.org" class="underline">GBIF</a> (Dutch
occurrence records). Pets, livestock and captive animals are left out. "Seen in NL" counts research-grade
iNaturalist observations. Photos are Creative Commons licensed iNaturalist photos, shown from iNaturalist
and credited on each animal's page.
</footer>
</div>
<style>
.groups {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: 0.5rem;
}
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (min-width: 768px) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.9rem 1.1rem;
}
.stats dt {
font-size: 0.8rem;
color: var(--site-muted);
}
.stats dd {
margin-top: 0.15rem;
font-size: 1.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.pill {
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;
}
.pill:hover {
background: var(--site-hover);
}
</style>

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import { getGroup, PAGE_SIZE, parseFilters, searchSpecies } from '#lib/server/wildlife.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, url }) => {
const group = await getGroup(params.group);
if (!group) error(404, 'No such group in the wildlife pokedex');
const filters = parseFilters(url.searchParams, group.slug);
const { species, total } = await searchSpecies(filters);
return {
group: {
slug: group.slug,
name: group.name,
nameNl: group.nameNl,
emoji: group.emoji,
speciesCount: group.speciesCount,
nlCount: group.nlCount,
photo: group.photo
},
filters,
species,
total,
pageSize: PAGE_SIZE,
seo: {
title: `${group.name} · wildlife pokedex`,
description: `${group.speciesCount} ${group.name.toLowerCase()} (${group.nameNl.toLowerCase()}) in Stan's wildlife pokedex: ${group.nlCount} seen in the Netherlands, with Dutch names, Red List status and photos.`
}
};
};

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import SpeciesBrowser from '#lib/wildlife/SpeciesBrowser.svelte';
import { fmtNumber, photoAt } from '#lib/wildlife/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Shuffle from 'lucide-svelte/icons/shuffle';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const g = $derived(data.group);
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '🇳🇱 seen in NL', params: { where: 'nl' } },
{ label: '⚠️ threatened', params: { status: 'threatened', sort: 'world' } },
{ label: '🌍 most seen worldwide', params: { sort: 'world' } },
{ label: '📖 dex order', params: { sort: 'dex' } }
];
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a href="/trackers/wildlife" class="back"><ArrowLeft size={15} /> all animals</a>
<header class="hero mt-6 mb-8">
{#if g.photo}<img src={photoAt(g.photo, 'large')} alt="" />{/if}
<div class="shade"></div>
<div class="text">
<p class="text-sm opacity-80">{g.nameNl}</p>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{g.emoji} {g.name}</h1>
<p class="mt-2 opacity-90">
{fmtNumber(g.speciesCount)} species · {fmtNumber(g.nlCount)} seen in the Netherlands
</p>
</div>
<a
href="/trackers/wildlife/random?group={g.slug}"
class="rand"
data-sveltekit-preload-data="off"><Shuffle size={15} /> random</a
>
</header>
<SpeciesBrowser
base="/trackers/wildlife/{g.slug}"
filters={data.filters}
species={data.species}
total={data.total}
pageSize={data.pageSize}
{quick}
/>
</div>
<style>
.back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.back:hover {
color: var(--site-text);
}
.hero {
position: relative;
overflow: hidden;
min-height: 13rem;
display: flex;
align-items: flex-end;
border-radius: 1rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
color: #fff;
}
.hero img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.shade {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.8), rgb(0 0 0 / 0.15) 70%);
}
.text {
position: relative;
padding: 1.25rem 1.4rem;
}
.rand {
position: absolute;
top: 0.8rem;
right: 0.8rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.rand:hover {
background: rgb(0 0 0 / 0.7);
}
</style>

View File

@@ -0,0 +1,61 @@
import { error } from '@sveltejs/kit';
import { getSpecies } from '#lib/server/wildlife.js';
import { displayName, photoAt } from '#lib/wildlife/shared.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const found = await getSpecies(params.group, params.species);
if (!found) error(404, 'No such animal in the wildlife pokedex');
const { species: s, group, photos, relatives, prev, next } = found;
const name = displayName(s);
const alt = [s.nameNl && s.nameNl !== name ? s.nameNl : null, s.scientificName]
.filter(Boolean)
.join(', ');
return {
species: {
dex: s.dex,
slug: s.slug,
scientificName: s.scientificName,
nameEn: s.nameEn,
nameNl: s.nameNl,
inatId: s.inatId,
gbifKey: s.gbifKey,
wikidata: s.wikidata,
wikipediaEn: s.wikipediaEn,
wikipediaNl: s.wikipediaNl,
className: s.className,
orderName: s.orderName,
familyName: s.familyName,
iucn: s.iucn,
nlStatus: s.nlStatus,
nlEstablishment: s.nlEstablishment,
extinct: s.extinct,
nlObs: s.nlObs,
europeObs: s.europeObs,
worldObs: s.worldObs,
gbifNl: s.gbifNl,
scope: s.scope,
summary: s.summary,
summaryNl: s.summaryNl,
facts: s.facts,
massKg: s.massKg,
lengthM: s.lengthM,
lifespanYears: s.lifespanYears,
namedYear: s.namedYear,
namedBy: s.namedBy,
updatedAt: s.updatedAt?.toISOString() ?? null
},
group: { slug: group.slug, name: group.name, nameNl: group.nameNl, emoji: group.emoji },
photos,
relatives,
prev,
next,
seo: {
title: `${name}${alt ? ` (${alt})` : ''} · wildlife pokedex`,
description:
(s.summary ? s.summary.slice(0, 150).replace(/\s+\S*$/, '') + '…' : '') ||
`${name} (${s.scientificName}): photos, Dutch name, Red List status and how often it is seen in the Netherlands.`,
image: photos[0] ? photoAt(photos[0].url, 'large') : undefined
}
};
};

View File

@@ -0,0 +1,424 @@
<script lang="ts">
import Gallery from '#lib/wildlife/Gallery.svelte';
import SpeciesCard from '#lib/wildlife/SpeciesCard.svelte';
import StatusChip from '#lib/wildlife/StatusChip.svelte';
import {
dexLabel,
displayName,
fmtNumber,
statusLabel,
statusLabelNl,
statusThreat
} from '#lib/wildlife/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const s = $derived(data.species);
const g = $derived(data.group);
const name = $derived(displayName(s));
let lang = $state<'en' | 'nl'>('en');
const clamp = (n: number) => Math.max(0.03, Math.min(1, n));
const log = (n: number, max: number) => clamp(Math.log10(Math.max(1, n)) / Math.log10(max));
const fmtKg = (kg: number) =>
kg >= 1000
? `${fmtNumber(Math.round(kg / 100) / 10)} t`
: kg >= 1
? `${fmtNumber(Math.round(kg * 10) / 10)} kg`
: `${fmtNumber(Math.round(kg * 10000) / 10)} g`;
const fmtLen = (m: number) =>
m >= 1
? `${fmtNumber(Math.round(m * 100) / 100)} m`
: m >= 0.01
? `${fmtNumber(Math.round(m * 1000) / 10)} cm`
: `${fmtNumber(Math.round(m * 10000) / 10)} mm`;
// Pokedex-style "base stats", scaled against the whole animal kingdom (log scales).
const baseStats = $derived(
[
{
label: 'NL sightings',
value: fmtNumber(s.nlObs),
hint: 'iNaturalist, research grade',
score: s.nlObs ? log(s.nlObs, 300_000) : 0,
hue: 25
},
{
label: 'worldwide',
value: fmtNumber(s.worldObs),
hint: 'iNaturalist observations',
score: s.worldObs ? log(s.worldObs, 600_000) : 0,
hue: 200
},
s.massKg != null && {
label: 'weight',
value: fmtKg(s.massKg),
hint: 'adult, up to',
score: log(s.massKg * 1_000_000, 150_000_000_000_000),
hue: 280
},
s.lengthM != null && {
label: 'length',
value: fmtLen(s.lengthM),
hint: 'adult, up to',
score: log(s.lengthM * 10_000, 300_000),
hue: 140
},
s.lifespanYears != null && {
label: 'lifespan',
value: `${fmtNumber(Math.round(s.lifespanYears))} yr`,
hint: 'maximum recorded',
score: log(s.lifespanYears * 10, 4000),
hue: 45
},
s.iucn &&
statusThreat[s.iucn] != null && {
label: 'threat',
value: s.iucn,
hint: statusLabel[s.iucn],
score: statusThreat[s.iucn],
hue: 0
}
].filter((x) => !!x)
);
const funFacts = $derived(
s.facts.map((f) => {
const m = f.match(/^(\p{Extended_Pictographic}\uFE0F?|\p{Regional_Indicator}{2})\s*(.*)$/u);
return m ? { icon: m[1], text: m[2] } : { icon: '•', text: f };
})
);
const where = $derived(
s.nlObs > 0
? s.nlEstablishment === 'introduced'
? 'in the Netherlands (introduced)'
: 'in the Netherlands'
: s.europeObs > 0
? 'in Europe, not in NL'
: 'outside Europe'
);
const facts = $derived(
[
['English', s.nameEn],
['Nederlands', s.nameNl],
['scientific name', s.scientificName],
['class', s.className],
['order', s.orderName],
['family', s.familyName],
['IUCN Red List', s.iucn && `${s.iucn} · ${statusLabel[s.iucn] ?? s.iucn}`],
['Dutch status', s.nlStatus],
['in NL', s.nlEstablishment],
['sightings in NL', fmtNumber(s.nlObs)],
['sightings in Europe', s.europeObs ? fmtNumber(s.europeObs) : null],
['sightings worldwide', fmtNumber(s.worldObs)],
['GBIF records in NL', s.gbifNl != null ? fmtNumber(s.gbifNl) : null],
['weight', s.massKg != null && fmtKg(s.massKg)],
['length', s.lengthM != null && fmtLen(s.lengthM)],
['lifespan', s.lifespanYears != null && `${fmtNumber(Math.round(s.lifespanYears))} years`],
['named', s.namedYear && `${s.namedYear}${s.namedBy ? ` by ${s.namedBy}` : ''}`]
].filter(
([label, value]) => value || ['weight', 'length', 'lifespan'].includes(label as string)
) as [string, string | number | null][]
);
const summary = $derived(lang === 'nl' && s.summaryNl ? s.summaryNl : (s.summary ?? s.summaryNl));
const wiki = (lang: string, title: string) =>
`https://${lang}.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
const href = (x: { groupSlug: string; slug: string }) =>
`/trackers/wildlife/${x.groupSlug}/${x.slug}`;
</script>
<div class="mx-auto max-w-5xl px-4 py-12 sm:px-6 sm:py-16">
<nav class="flex items-center justify-between gap-2 text-sm">
<a href="/trackers/wildlife/{g.slug}" class="back"><ArrowLeft size={15} /> {g.name}</a>
<span class="flex items-center gap-1">
{#if data.prev}<a href={href(data.prev)} class="step" title={data.prev.name}
><ChevronLeft size={15} />{dexLabel(data.prev.dex)}</a
>{/if}
<a
href="/trackers/wildlife/random"
class="step"
data-sveltekit-preload-data="off"
title="Random animal"><Shuffle size={14} /></a
>
{#if data.next}<a href={href(data.next)} class="step" title={data.next.name}
>{dexLabel(data.next.dex)}<ChevronRight size={15} /></a
>{/if}
</span>
</nav>
<header class="hero">
<p class="dex">{dexLabel(s.dex)} · {g.emoji} {g.name.toLowerCase()}</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">{name}</h1>
<p class="mt-2 text-lg text-[var(--site-muted)]">
{#if s.nameNl && s.nameNl !== name}<span lang="nl">🇳🇱 {s.nameNl}</span> ·
{/if}<i>{s.scientificName}</i>
</p>
<div class="mt-4 flex flex-wrap items-center gap-2">
<StatusChip iucn={s.iucn} size="md" />
<a href="/trackers/wildlife/{g.slug}" class="chip">{g.emoji} {g.name}</a>
<span class="chip">📍 {where}</span>
{#if s.extinct}<span class="chip">🪦 extinct</span>{/if}
</div>
</header>
{#if data.photos.length}
<Gallery photos={data.photos} {name} />
{:else}
<p class="card text-sm text-[var(--site-muted)]">No Creative Commons photos found yet.</p>
{/if}
{#if summary}
<section class="card mt-4" aria-labelledby="about">
<div class="mb-3 flex items-center justify-between gap-3">
<h2 id="about" class="!mb-0">what is it?</h2>
{#if s.summary && s.summaryNl}
<div class="toggle" role="group" aria-label="Language">
<button class:on={lang === 'en'} onclick={() => (lang = 'en')}>EN</button>
<button class:on={lang === 'nl'} onclick={() => (lang = 'nl')}>NL</button>
</div>
{/if}
</div>
<p class="summary" lang={lang === 'nl' && s.summaryNl ? 'nl' : 'en'}>{summary}</p>
</section>
{/if}
<section class="card" aria-labelledby="stats">
<h2 id="stats">base stats</h2>
<ul class="bars">
{#each baseStats as b (b.label)}
<li>
<span class="label">{b.label}</span>
<span class="track"
><span class="fill" style:width="{b.score * 100}%" style:--hue={b.hue}></span></span
>
<span class="value">{b.value}<small>{b.hint}</small></span>
</li>
{/each}
</ul>
<p class="note">
Bars compare against all animals on log scales: up to 300,000 Dutch and 600,000 worldwide
sightings (the most-seen species), 150 t (blue whale), 30 m and 400 years (Greenland shark).
</p>
</section>
{#if funFacts.length}
<section class="card" aria-labelledby="fun">
<h2 id="fun">fun facts</h2>
<ul class="fun">
{#each funFacts as f (f.text)}<li>
<span aria-hidden="true">{f.icon}</span><span>{f.text}</span>
</li>{/each}
</ul>
</section>
{/if}
<section class="card" aria-labelledby="facts">
<h2 id="facts">fact sheet</h2>
<dl class="facts">
{#each facts as [label, value] (label)}
<div>
<dt>{label}</dt>
<dd class={[!value && 'blank']}>{value || '—'}</dd>
</div>
{/each}
</dl>
{#if s.iucn}
<p class="note">
IUCN in Dutch: {statusLabelNl[s.iucn] ?? s.iucn}. Blank means the sources don't say.
</p>
{/if}
</section>
{#if data.relatives.length}
<section class="mt-8" aria-labelledby="relatives">
<h2 id="relatives">
relatives{#if s.familyName}<span class="muted"> · {s.familyName}</span>{/if}
</h2>
<ul class="grid">
{#each data.relatives as r (r.dex)}<li><SpeciesCard species={r} /></li>{/each}
</ul>
</section>
{/if}
<p class="mt-8 text-xs leading-relaxed text-[var(--site-muted)]">
Sources: <a href="https://www.inaturalist.org/taxa/{s.inatId}" class="underline"
>iNaturalist taxon {s.inatId}</a
>{#if s.wikidata}, <a href="https://www.wikidata.org/wiki/{s.wikidata}" class="underline"
>Wikidata {s.wikidata}</a
>{/if}{#if s.gbifKey}, <a href="https://www.gbif.org/species/{s.gbifKey}" class="underline"
>GBIF {s.gbifKey}</a
>{/if}{#if s.wikipediaEn}, <a href={wiki('en', s.wikipediaEn)} class="underline"
>Wikipedia (EN)</a
>{/if}{#if s.wikipediaNl}, <a href={wiki('nl', s.wikipediaNl)} class="underline"
>Wikipedia (NL)</a
>{/if}. Intro text from Wikipedia (CC BY-SA 4.0).{#if s.updatedAt}
Updated {s.updatedAt.slice(0, 10)}.{/if}
</p>
</div>
<style>
.back,
.step {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--site-muted);
}
.step {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
.back:hover,
.step:hover {
color: var(--site-text);
}
.hero {
margin: 2rem 0 1.5rem;
}
.dex {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--site-muted);
}
.card {
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
padding: 1.1rem 1.2rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.8rem;
}
.muted {
color: var(--site-muted);
font-weight: 400;
font-style: italic;
}
.note {
margin-top: 0.6rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.toggle {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 999px;
overflow: hidden;
font-size: 0.75rem;
}
.toggle button {
padding: 0.15rem 0.6rem;
color: var(--site-muted);
}
.toggle button.on {
background: var(--site-hover);
color: var(--site-text);
font-weight: 600;
}
.bars {
display: grid;
gap: 0.55rem;
}
.bars li {
display: grid;
grid-template-columns: 6.5rem 1fr minmax(7rem, auto);
align-items: center;
gap: 0.75rem;
}
.label {
font-size: 0.8rem;
color: var(--site-muted);
}
.track {
height: 0.6rem;
border-radius: 999px;
background: var(--site-hover);
overflow: hidden;
}
.fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, hsl(var(--hue) 70% 45%), hsl(var(--hue) 85% 62%));
animation: grow 0.6s ease-out;
transform-origin: left;
}
@keyframes grow {
from {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
animation: none;
}
}
.value {
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
display: flex;
flex-direction: column;
line-height: 1.2;
}
.value small {
font-size: 0.68rem;
font-weight: 400;
color: var(--site-muted);
}
.facts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
gap: 0.75rem 1.25rem;
}
.facts dt {
font-size: 0.72rem;
color: var(--site-muted);
}
.facts dd {
font-size: 0.9rem;
font-weight: 500;
}
.facts dd.blank {
color: var(--site-muted);
font-weight: 400;
}
.summary {
font-size: 0.95rem;
line-height: 1.6;
}
.chip {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--site-border);
background: var(--site-surface);
white-space: nowrap;
}
.fun {
display: grid;
gap: 0.5rem;
font-size: 0.88rem;
}
.fun li {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr));
}
</style>

View File

@@ -0,0 +1,14 @@
import { wildlifeOverview } from '#lib/server/wildlife.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { groups, stats } = await wildlifeOverview();
return {
groups,
stats,
seo: {
title: 'Wildlife groups',
description: `All ${groups.length} animal groups in the wildlife pokedex: mammals, birds, reptiles, amphibians, fish, insects, spiders and more.`
}
};
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import GroupTile from '#lib/wildlife/GroupTile.svelte';
import { fmtNumber } from '#lib/wildlife/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a href="/trackers/wildlife" class="back"><ArrowLeft size={15} /> all animals</a>
<header class="mt-6 mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groups</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
{fmtNumber(data.stats.species)} species in {data.groups.length} groups. Tap one to see them all.
</p>
</header>
<ul class="grid">
{#each data.groups as group (group.slug)}
<li><GroupTile {group} /></li>
{/each}
</ul>
</div>
<style>
.back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.back:hover {
color: var(--site-text);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import { randomSpecies } from '#lib/server/wildlife.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ setHeaders, url }) => {
const group = (url.searchParams.get('group') ?? '').replace(/[^a-z0-9-]/g, '') || undefined;
const s = await randomSpecies(group);
setHeaders({ 'cache-control': 'no-store' });
redirect(303, s ? `/trackers/wildlife/${s.groupSlug}/${s.slug}` : '/trackers/wildlife');
};