trains: pokedex pages (builders, operators, countries, detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m9s

This commit is contained in:
2026-10-25 00:11:50 +00:00
parent 8c0fc74283
commit 5f940e8196
28 changed files with 2630 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
<script lang="ts">
import { page } from '$app/state';
import MakerTile from '#lib/trains/MakerTile.svelte';
import TrainCard from '#lib/trains/TrainCard.svelte';
import {
CATEGORIES,
categoryEmoji,
categoryLabel,
ERAS,
flag,
fmtCompact,
fmtNumber,
LOGO_NOTE,
SORTS,
TRACTIONS,
tractionLabel
} from '#lib/trains/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 Shuffle from 'lucide-svelte/icons/shuffle';
import X from 'lucide-svelte/icons/x';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const f = $derived(data.filters);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const filtering = $derived(
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service)
);
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 `/trackers/trains${qs ? `?${qs}` : ''}`;
}
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '🇳🇱 Dutch trains', params: { country: 'Netherlands', sort: 'oldest' } },
{ label: '🚄 high-speed', params: { category: 'high-speed', sort: 'fastest' } },
{ label: '🚂 steam', params: { category: 'steam', sort: 'built' } },
{ label: '⚡ electric locos', params: { category: 'electric', sort: 'built' } },
{ label: '🚇 metros', params: { category: 'metro', sort: 'built' } },
{ label: '🚋 trams', params: { category: 'tram', sort: 'built' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🟡 VIRM', params: { q: 'virm' } }
];
</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">trains</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A train pokedex: steam, diesel and electric locomotives, high-speed trains, multiple units,
metros and trams from all over the world and every era, from Dutch Sprinters to the
Shinkansen. Tap one to see what it is, who built it and where it runs.
</p>
</div>
<a href="/trackers/trains/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random train</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>train types</dt>
<dd>{fmtNumber(data.stats.types)}</dd>
</div>
<div>
<dt><a href="/trackers/trains/makers" class="hover:underline">builders</a></dt>
<dd>{fmtNumber(data.stats.makers)}</dd>
</div>
<div>
<dt><a href="/trackers/trains/operators" class="hover:underline">operators</a></dt>
<dd>{fmtNumber(data.stats.operators)}</dd>
</div>
<div>
<dt><a href="/trackers/trains/countries" class="hover:underline">countries</a></dt>
<dd>{fmtNumber(data.stats.countries)}</dd>
</div>
<div>
<dt>vehicles built</dt>
<dd>{fmtCompact(data.stats.built)}</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="makers-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 id="makers-heading" class="text-lg font-semibold">builders</h2>
<a href="/trackers/trains/makers" class="text-sm text-[var(--site-muted)] hover:underline"
>all {fmtNumber(data.stats.makers)} builders →</a
>
</div>
<ul class="brands">
{#each data.topMakers as maker (maker.slug)}
<li><MakerTile {maker} compact /></li>
{/each}
</ul>
</section>
<section class="mb-8" aria-labelledby="operators-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 id="operators-heading" class="text-lg font-semibold">operators</h2>
<a
href="/trackers/trains/operators"
class="text-sm text-[var(--site-muted)] hover:underline"
>all {fmtNumber(data.stats.operators)} operators →</a
>
</div>
<ul class="brands">
{#each data.topOperators as maker (maker.slug)}
<li><MakerTile {maker} compact /></li>
{/each}
</ul>
</section>
{/if}
<form
bind:this={form}
method="GET"
action="/trackers/trains"
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 'virm', 'sprinter', 'ice 3', 'tgv', 'class 66', 'mallard'…"
aria-label="Search trains"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
<NativeSelect name="category" aria-label="Kind" value={f.category} onchange={submit}>
<option value="">any kind</option>
{#each CATEGORIES as x (x)}<option value={x}>{categoryEmoji[x]} {categoryLabel[x]}</option
>{/each}
</NativeSelect>
<NativeSelect name="traction" aria-label="Traction" value={f.traction} onchange={submit}>
<option value="">any traction</option>
{#each TRACTIONS as x (x)}<option value={x}>{tractionLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="maker" aria-label="Builder" value={f.maker} onchange={submit}>
<option value="">all builders</option>
{#each data.makers as m (m.slug)}<option value={m.slug}>{m.name} ({m.types})</option>{/each}
</NativeSelect>
<NativeSelect name="country" aria-label="Country" value={f.country} onchange={submit}>
<option value="">all countries</option>
{#each data.countries as c (c.name)}<option value={c.name}
>{flag(c.name)} {c.name} ({c.types})</option
>{/each}
</NativeSelect>
<NativeSelect name="era" aria-label="Era (first built)" value={f.era} onchange={submit}>
<option value="">any era</option>
{#each Object.entries(ERAS) as [key, era] (key)}<option value={key}>{era.label}</option
>{/each}
</NativeSelect>
<NativeSelect name="service" aria-label="In service" value={f.service} onchange={submit}>
<option value="">running or retired</option>
<option value="yes">in service</option>
<option value="no">retired / preserved</option>
</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="/trackers/trains" 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(data.total)}
{data.total === 1 ? 'type' : 'types'}{#if pages > 1}
· page {f.page} of {pages}{/if}
</p>
{#if data.trains.length}
<ul class="grid">
{#each data.trains as train, i (train.dex)}
<li><TrainCard {train} eager={i < 4} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No trains match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href="/trackers/trains" 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}
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
Data: <a href="https://www.wikidata.org" class="underline">Wikidata</a> (CC0) and the
<a href="https://en.wikipedia.org" class="underline">English Wikipedia</a> locomotive, train and
tram infoboxes (CC BY-SA 4.0). Classes and types only, no individual locomotives. Era is by
first built. Blank means unknown. Photos from Wikimedia Commons, credited on each train's page.
{LOGO_NOTE}
</footer>
</div>
<style>
.brands {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.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(3, 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;
}
.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: 12rem;
}
.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>