379 lines
12 KiB
Svelte
379 lines
12 KiB
Svelte
<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 AreaSelect from '#lib/spotting/AreaSelect.svelte';
|
|
import { GOT } from '#lib/spotting/areas.js';
|
|
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
|
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
|
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 || f.got)
|
|
);
|
|
|
|
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' } },
|
|
{ label: '⬜ still to go', params: { got: 'no' } }
|
|
];
|
|
</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>
|
|
|
|
<div class="mb-8">
|
|
<SpotProgress
|
|
seen={data.collected.seen}
|
|
total={data.collected.total}
|
|
label="types collected"
|
|
href={withParams({ got: 'no' })}
|
|
/>
|
|
</div>
|
|
|
|
{#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>
|
|
{#if f.sort === 'active'}<AreaSelect
|
|
value={f.area}
|
|
available={data.areas}
|
|
onchange={submit}
|
|
/>{/if}
|
|
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
|
|
{#each Object.entries(GOT) 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>
|
|
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
|
<TrainCard {train} eager={i < 4} />
|
|
</SpotItem>
|
|
</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>
|