trains: pokedex pages (builders, operators, countries, detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
This commit is contained in:
34
src/routes/trackers/trains/+page.server.ts
Normal file
34
src/routes/trackers/trains/+page.server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PAGE_SIZE, parseFilters, searchTrains, trainOverview } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const filters = parseFilters(url.searchParams);
|
||||
const [{ trains, total }, overview] = await Promise.all([searchTrains(filters), trainOverview()]);
|
||||
return {
|
||||
filters,
|
||||
trains,
|
||||
total,
|
||||
pageSize: PAGE_SIZE,
|
||||
makers: overview.makers
|
||||
.filter((m) => m.types >= 2 || m.slug === filters.maker)
|
||||
.map(({ slug, name, types }) => ({ slug, name, types })),
|
||||
countries: overview.countries
|
||||
.filter((c) => c.types >= 3 || c.name === filters.country)
|
||||
.map(({ name, types }) => ({ name, types })),
|
||||
topMakers: overview.byBuilt
|
||||
.slice(0, 16)
|
||||
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
|
||||
topOperators: overview.operators.slice(0, 16).map(({ slug, name, types, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
logo,
|
||||
href: `/trackers/trains/operators/${slug}`
|
||||
})),
|
||||
stats: overview.stats,
|
||||
seo: {
|
||||
title: 'Train pokedex',
|
||||
description: `Search ${overview.stats.types.toLocaleString('en-GB')} train types from ${overview.stats.makers.toLocaleString('en-GB')} builders: steam, diesel and electric locomotives, high-speed trains, multiple units, metros and trams, with specs and photos.`
|
||||
}
|
||||
};
|
||||
};
|
||||
352
src/routes/trackers/trains/+page.svelte
Normal file
352
src/routes/trackers/trains/+page.svelte
Normal 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>
|
||||
35
src/routes/trackers/trains/[maker]/+page.server.ts
Normal file
35
src/routes/trackers/trains/[maker]/+page.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getMaker } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getMaker(params.maker);
|
||||
if (!found) error(404, 'No such builder in the train pokedex');
|
||||
const { maker, types, hero } = found;
|
||||
return {
|
||||
maker: {
|
||||
slug: maker.slug,
|
||||
name: maker.name,
|
||||
fullName: maker.fullName,
|
||||
country: maker.country,
|
||||
typeCount: maker.typeCount,
|
||||
builtTotal: maker.builtTotal,
|
||||
firstYear: maker.firstYear,
|
||||
lastYear: maker.lastYear,
|
||||
wikidata: maker.wikidata,
|
||||
wikipedia: maker.wikipedia,
|
||||
logo: maker.logo,
|
||||
logoFile: maker.logoFile,
|
||||
logoAuthor: maker.logoAuthor,
|
||||
logoLicense: maker.logoLicense,
|
||||
photo: maker.photo,
|
||||
sources: maker.sources
|
||||
},
|
||||
hero,
|
||||
types,
|
||||
seo: {
|
||||
title: `${maker.name} trains`,
|
||||
description: `All ${maker.typeCount} ${maker.name} train types in Stan's train pokedex${maker.country ? ` (${maker.country})` : ''}, with specs and photos.`
|
||||
}
|
||||
};
|
||||
};
|
||||
151
src/routes/trackers/trains/[maker]/+page.svelte
Normal file
151
src/routes/trackers/trains/[maker]/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||
import {
|
||||
commonsPage,
|
||||
flag,
|
||||
fmtCompact,
|
||||
fmtNumber,
|
||||
LOGO_NOTE,
|
||||
thumbAt,
|
||||
thumbSrcset
|
||||
} from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const b = $derived(data.maker);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||
{#if b.photo}
|
||||
<figure class="hero">
|
||||
<img
|
||||
src={thumbAt(b.photo, 960)}
|
||||
srcset={thumbSrcset(b.photo, [500, 960, 1280])}
|
||||
sizes="(min-width: 1152px) 1104px, 100vw"
|
||||
alt="{b.name} {data.hero?.type ?? ''}"
|
||||
fetchpriority="high"
|
||||
width="1280"
|
||||
height="480"
|
||||
/>
|
||||
{#if data.hero}
|
||||
<figcaption>
|
||||
{b.name}
|
||||
{data.hero.type} ·
|
||||
<a href={commonsPage(data.hero.file)} rel="noopener"
|
||||
>{data.hero.author ?? 'Wikimedia Commons'}</a
|
||||
>{#if data.hero.license}
|
||||
· {#if data.hero.licenseUrl}<a href={data.hero.licenseUrl} rel="noopener license"
|
||||
>{data.hero.license}</a
|
||||
>{:else}{data.hero.license}{/if}{/if}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/if}
|
||||
<header class="mt-6 mb-8 flex items-center gap-5">
|
||||
{#if b.logo}
|
||||
<img
|
||||
class="logo"
|
||||
src={b.logo}
|
||||
alt="{b.name} logo"
|
||||
title="Logo: {b.logoAuthor ?? 'Wikimedia Commons'}{b.logoLicense
|
||||
? ` (${b.logoLicense})`
|
||||
: ''}"
|
||||
width="72"
|
||||
height="72"
|
||||
/>
|
||||
{:else}
|
||||
<BrandBadge name={b.name} size="lg" />
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{b.name}</h1>
|
||||
{#if b.fullName && b.fullName !== b.name}<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||
{b.fullName}
|
||||
</p>{/if}
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{#if b.country}{flag(b.country)} {b.country} ·
|
||||
{/if}{fmtNumber(b.typeCount)}
|
||||
{b.typeCount === 1 ? 'type' : 'types'}{#if b.firstYear}
|
||||
· built {b.firstYear}{#if b.lastYear && b.lastYear !== b.firstYear}–{b.lastYear}{/if}{/if}{#if b.builtTotal}
|
||||
· {fmtCompact(b.builtTotal)} built{/if}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="grid">
|
||||
{#each data.types as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={i < 4} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
Sorted by number built. Sources: {b.sources.join(', ')}.{#if b.wikipedia}
|
||||
· <a href="https://en.wikipedia.org/wiki/{b.wikipedia.replace(/ /g, '_')}" rel="noopener"
|
||||
>{b.name} on Wikipedia</a
|
||||
>{/if}{#if b.logoFile}
|
||||
· Logo: <a href={commonsPage(b.logoFile)} rel="noopener"
|
||||
>{b.logoAuthor ?? 'Wikimedia Commons'}</a
|
||||
>{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo}
|
||||
{LOGO_NOTE}{/if}
|
||||
<a href="/trackers/trains/makers" class="underline">All builders</a>.
|
||||
</p>
|
||||
</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;
|
||||
margin-top: 1.25rem;
|
||||
aspect-ratio: 8 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.hero img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero figcaption {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100%;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
border-top-left-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero figcaption a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.logo {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
object-fit: contain;
|
||||
padding: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--site-border);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||
}
|
||||
</style>
|
||||
50
src/routes/trackers/trains/[maker]/[type]/+page.server.ts
Normal file
50
src/routes/trackers/trains/[maker]/[type]/+page.server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getType } from '#lib/server/trains.js';
|
||||
import type { trainTypes } from '#lib/server/db/schema/index.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
type Type = typeof trainTypes.$inferSelect;
|
||||
|
||||
// Drop internal ids and the search blob before the row goes to the browser.
|
||||
function publicType(t: Type) {
|
||||
const out: Partial<Type> = { ...t };
|
||||
delete out.id;
|
||||
delete out.makerId;
|
||||
delete out.familyId;
|
||||
delete out.search;
|
||||
delete out.createdAt;
|
||||
delete out.updatedAt;
|
||||
delete out.photosCheckedAt;
|
||||
return {
|
||||
...(out as Omit<
|
||||
Type,
|
||||
'id' | 'makerId' | 'familyId' | 'search' | 'createdAt' | 'updatedAt' | 'photosCheckedAt'
|
||||
>),
|
||||
updatedAt: t.updatedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getType(params.maker, params.type);
|
||||
if (!found) error(404, 'No such train in the pokedex');
|
||||
const { type, maker, photos, parent, variants, operators, prev, next } = found;
|
||||
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
||||
const rest: Partial<T> = { ...row };
|
||||
delete rest.id;
|
||||
return rest as Omit<T, 'id'>;
|
||||
};
|
||||
return {
|
||||
train: publicType(type),
|
||||
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
||||
photos,
|
||||
operators,
|
||||
parent: parent ? strip(parent) : null,
|
||||
variants: variants.map(strip),
|
||||
prev,
|
||||
next,
|
||||
seo: {
|
||||
title: type.fullName,
|
||||
description: `${type.fullName}${type.firstBuilt ? ` (built from ${type.firstBuilt})` : ''}${type.description ? `, ${type.description}` : ''}: specs, operators and photos.`
|
||||
}
|
||||
};
|
||||
};
|
||||
502
src/routes/trackers/trains/[maker]/[type]/+page.svelte
Normal file
502
src/routes/trackers/trains/[maker]/[type]/+page.svelte
Normal file
@@ -0,0 +1,502 @@
|
||||
<script lang="ts">
|
||||
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||
import Gallery from '#lib/cars/Gallery.svelte';
|
||||
import {
|
||||
categoryEmoji,
|
||||
categoryHint,
|
||||
categoryLabel,
|
||||
countrySlug,
|
||||
dexLabel,
|
||||
flag,
|
||||
fmtKw,
|
||||
fmtM,
|
||||
fmtNumber,
|
||||
fmtT,
|
||||
statusLabel,
|
||||
tractionHint,
|
||||
tractionLabel
|
||||
} from '#lib/trains/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 p = $derived(data.train);
|
||||
const thisYear = new Date().getFullYear();
|
||||
|
||||
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));
|
||||
// Pokedex-style "base stats": each bar is scaled against the whole railway world.
|
||||
const baseStats = $derived(
|
||||
[
|
||||
p.maxSpeedKmh != null && {
|
||||
label: 'speed',
|
||||
value: `${fmtNumber(p.maxSpeedKmh)} km/h`,
|
||||
hint: 'top speed',
|
||||
score: clamp(p.maxSpeedKmh / 600),
|
||||
hue: 8
|
||||
},
|
||||
p.powerKw != null && {
|
||||
label: 'power',
|
||||
value: fmtKw(p.powerKw),
|
||||
hint: 'power output',
|
||||
score: log(p.powerKw, 20_000),
|
||||
hue: 45
|
||||
},
|
||||
p.weightT != null && {
|
||||
label: 'weight',
|
||||
value: fmtT(p.weightT),
|
||||
hint: 'in working order',
|
||||
score: log(p.weightT, 1000),
|
||||
hue: 280
|
||||
},
|
||||
p.capacity != null && {
|
||||
label: 'seats',
|
||||
value: fmtNumber(p.capacity),
|
||||
hint: 'passengers',
|
||||
score: clamp(p.capacity / 1700),
|
||||
hue: 210
|
||||
},
|
||||
p.numberBuilt != null && {
|
||||
label: 'built',
|
||||
value: fmtNumber(p.numberBuilt),
|
||||
hint: 'made in total',
|
||||
score: log(p.numberBuilt, 20_000),
|
||||
hue: 140
|
||||
}
|
||||
].filter((s) => !!s)
|
||||
);
|
||||
|
||||
const years = (a: number | null, b: number | null) =>
|
||||
a ? (b && b !== a ? `${a}–${b}` : `${a}`) : null;
|
||||
const facts = $derived(
|
||||
[
|
||||
['built', years(p.firstBuilt, p.lastBuilt)],
|
||||
['in service from', p.introducedYear],
|
||||
['retired', p.retiredYear],
|
||||
['status', p.status && (statusLabel[p.status] ?? p.status)],
|
||||
['number built', p.numberBuilt && fmtNumber(p.numberBuilt)],
|
||||
['kind', p.categoryLabel ?? categoryLabel[p.category]],
|
||||
['traction', p.traction && (tractionLabel[p.traction] ?? p.traction)],
|
||||
['origin', p.country && `${flag(p.country)} ${p.country}`],
|
||||
['builders', p.makers.join(', ')],
|
||||
['designer', p.designer],
|
||||
['operators', p.operators.join(', ')],
|
||||
['top speed', p.maxSpeedKmh && `${fmtNumber(p.maxSpeedKmh)} km/h`],
|
||||
['power', p.powerKw && fmtKw(p.powerKw)],
|
||||
['gauge', p.gauge],
|
||||
['electric system', p.electricSystem],
|
||||
['wheel arrangement', p.wheels],
|
||||
['formation', p.formation],
|
||||
['length', p.lengthM && fmtM(p.lengthM)],
|
||||
['weight', p.weightT && fmtT(p.weightT)],
|
||||
['capacity', p.capacity && `${fmtNumber(p.capacity)} passengers`],
|
||||
['nicknames', p.nicknames],
|
||||
['Dutch name', p.nlName]
|
||||
].filter(
|
||||
([label, value]) =>
|
||||
value || ['built', 'number built', 'top speed', 'power'].includes(label as string)
|
||||
) as [string, string | number | null][]
|
||||
);
|
||||
|
||||
// Short fun facts, all computed from the numbers above (no invented trivia).
|
||||
const fun = $derived(
|
||||
[
|
||||
p.firstBuilt && {
|
||||
icon: '🎂',
|
||||
text: `First built ${thisYear - p.firstBuilt} years ago, in ${p.firstBuilt}.`
|
||||
},
|
||||
p.maxSpeedKmh &&
|
||||
p.maxSpeedKmh >= 140 && {
|
||||
icon: '🏎️',
|
||||
text: `Top speed is ${(p.maxSpeedKmh / 130).toFixed(1)}× a car on the Dutch motorway (130 km/h).`
|
||||
},
|
||||
p.maxSpeedKmh &&
|
||||
p.maxSpeedKmh >= 160 && {
|
||||
icon: '⏱️',
|
||||
text: `At full speed it would cover Amsterdam–Utrecht (≈ 40 km) in ${Math.max(1, Math.round((40 / p.maxSpeedKmh) * 60))} minutes.`
|
||||
},
|
||||
p.powerKw &&
|
||||
p.powerKw >= 300 && {
|
||||
icon: '🚗',
|
||||
text: `${fmtKw(p.powerKw)} is about ${fmtNumber(Math.round(p.powerKw / 85))} family cars (~85 kW each) flat out.`
|
||||
},
|
||||
p.weightT &&
|
||||
p.weightT >= 12 && {
|
||||
icon: '🐘',
|
||||
text: `Weighs about as much as ${fmtNumber(Math.round(p.weightT / 6))} African elephants (~6 t each).`
|
||||
},
|
||||
p.lengthM &&
|
||||
p.lengthM >= 9 && {
|
||||
icon: '📏',
|
||||
text: `${fmtM(p.lengthM)} long: about ${Math.round(p.lengthM / 4.5)} cars parked nose to tail.`
|
||||
},
|
||||
p.capacity &&
|
||||
p.capacity >= 100 && {
|
||||
icon: '🧍',
|
||||
text: `Seats ${fmtNumber(p.capacity)}: ${Math.max(1, Math.round(p.capacity / 50))} full city buses (~50 seats each).`
|
||||
},
|
||||
p.introducedYear &&
|
||||
!p.retiredYear &&
|
||||
p.inService &&
|
||||
thisYear - p.introducedYear >= 20 && {
|
||||
icon: '🧓',
|
||||
text: `Still in service after ${thisYear - p.introducedYear} years.`
|
||||
},
|
||||
p.introducedYear &&
|
||||
p.retiredYear &&
|
||||
p.retiredYear > p.introducedYear && {
|
||||
icon: '⌛',
|
||||
text: `Served for ${p.retiredYear - p.introducedYear} years (${p.introducedYear}–${p.retiredYear}).`
|
||||
}
|
||||
].filter((f) => !!f)
|
||||
);
|
||||
const typeHref = (x: { makerSlug: string; slug: string }) =>
|
||||
`/trackers/trains/${x.makerSlug}/${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/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||
<span class="flex items-center gap-1">
|
||||
{#if data.prev}<a href={typeHref(data.prev)} class="step" title={data.prev.name}
|
||||
><ChevronLeft size={15} />{dexLabel(data.prev.dex)}</a
|
||||
>{/if}
|
||||
<a
|
||||
href="/trackers/trains/random"
|
||||
class="step"
|
||||
data-sveltekit-preload-data="off"
|
||||
title="Random train"><Shuffle size={14} /></a
|
||||
>
|
||||
{#if data.next}<a href={typeHref(data.next)} class="step" title={data.next.name}
|
||||
>{dexLabel(data.next.dex)}<ChevronRight size={15} /></a
|
||||
>{/if}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<header class="hero">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/trackers/trains/{data.maker.slug}" aria-label="{data.maker.name} trains"
|
||||
><BrandBadge name={data.maker.name} logo={data.maker.logo} size="lg" /></a
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="dex">
|
||||
{dexLabel(p.dex)}{#if p.nlName}
|
||||
· {p.nlName}{/if}
|
||||
</p>
|
||||
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">
|
||||
{#if p.fullName.startsWith(data.maker.name)}<a
|
||||
href="/trackers/trains/{data.maker.slug}"
|
||||
class="brand">{data.maker.name}</a
|
||||
>
|
||||
{p.fullName.slice(data.maker.name.length).trim()}{:else}{p.fullName}{/if}
|
||||
</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{#if p.country}<a href="/trackers/trains/countries/{countrySlug(p.country)}"
|
||||
>{flag(p.country)} {p.country}</a
|
||||
> ·
|
||||
{/if}built by
|
||||
<a href="/trackers/trains/{data.maker.slug}" class="underline">{data.maker.name}</a
|
||||
>{#if p.firstBuilt}
|
||||
· from {p.firstBuilt}{/if}{#if p.description}
|
||||
· {p.description}{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/trackers/trains?category={encodeURIComponent(p.category)}"
|
||||
class="chip"
|
||||
title={categoryHint[p.category]}
|
||||
>{categoryEmoji[p.category] ?? ''} {categoryLabel[p.category] ?? p.category}</a
|
||||
>
|
||||
{#if p.traction}<a
|
||||
href="/trackers/trains?traction={encodeURIComponent(p.traction)}"
|
||||
class="chip"
|
||||
title={tractionHint[p.traction]}>{tractionLabel[p.traction] ?? p.traction}</a
|
||||
>{/if}
|
||||
{#if p.inService === true}<span class="chip ok">✓ in service</span
|
||||
>{:else if p.status === 'preserved'}<span class="chip">preserved</span
|
||||
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if data.photos.length}
|
||||
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
||||
{/if}
|
||||
|
||||
{#if p.summary}
|
||||
<section class="card" aria-labelledby="about">
|
||||
<h2 id="about">what is it?</h2>
|
||||
<p class="summary">{p.summary}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.operators.length}
|
||||
<section class="card" aria-labelledby="operators">
|
||||
<h2 id="operators">who runs it</h2>
|
||||
<div class="ops">
|
||||
{#each data.operators as o (o.slug)}
|
||||
<a href="/trackers/trains/operators/{o.slug}"
|
||||
><BrandBadge name={o.name} logo={o.logo} size="sm" />{o.name}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if baseStats.length}
|
||||
<section class="card" aria-labelledby="stats">
|
||||
<h2 id="stats">base stats</h2>
|
||||
<ul class="bars">
|
||||
{#each baseStats as s (s.label)}
|
||||
<li>
|
||||
<span class="label">{s.label}</span>
|
||||
<span class="track"
|
||||
><span class="fill" style:width="{s.score * 100}%" style:--hue={s.hue}></span></span
|
||||
>
|
||||
<span class="value">{s.value}<small>{s.hint}</small></span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="note">
|
||||
Bars compare against all trains: 600 km/h (maglev), 1,700 seats; power, weight and number
|
||||
built on a log scale up to 20 MW, 1,000 t and 20,000 built.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if fun.length}
|
||||
<section class="card" aria-labelledby="fun">
|
||||
<h2 id="fun">fun facts</h2>
|
||||
<ul class="fun">
|
||||
{#each fun as f (f.text)}<li>
|
||||
<span aria-hidden="true">{f.icon}</span><span>{f.text}</span>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
<p class="note">{categoryLabel[p.category]}: {categoryHint[p.category]}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="card" aria-labelledby="facts">
|
||||
<h2 id="facts">spec sheet</h2>
|
||||
<dl class="facts">
|
||||
{#each facts as [label, value] (label)}
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd class={[!value && 'blank']}>{value || '—'}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="note">From the Wikipedia infobox and Wikidata. Blank means the sources don't say.</p>
|
||||
</section>
|
||||
|
||||
{#if data.parent || data.variants.length}
|
||||
<section class="card" aria-labelledby="family">
|
||||
<h2 id="family">family</h2>
|
||||
<div class="related">
|
||||
{#if data.parent}<a href={typeHref(data.parent)}
|
||||
>↑ {data.parent.fullName}<small>parent</small></a
|
||||
>{/if}
|
||||
{#each data.variants as v (v.dex)}
|
||||
<a href={typeHref(v)}
|
||||
>{v.fullName}{#if v.firstBuilt}<small>{v.firstBuilt}</small>{/if}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<p class="mt-8 text-xs text-[var(--site-muted)]">
|
||||
Sources: <a href="https://www.wikidata.org/wiki/{p.wikidata}" class="underline"
|
||||
>Wikidata {p.wikidata}</a
|
||||
>{#if p.wikipedia}, <a
|
||||
href="https://en.wikipedia.org/wiki/{encodeURIComponent(p.wikipedia.replace(/ /g, '_'))}"
|
||||
class="underline">Wikipedia: {p.wikipedia}</a
|
||||
> (CC BY-SA 4.0){/if}.{#if p.updatedAt}
|
||||
Updated {p.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);
|
||||
}
|
||||
.brand {
|
||||
color: var(--site-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.brand:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.note {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.bars {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.bars li {
|
||||
display: grid;
|
||||
grid-template-columns: 5.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;
|
||||
}
|
||||
.chip.ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
.fun {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.fun li {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.related {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.related a {
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
}
|
||||
.related a:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ops a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem 0.2rem 0.25rem;
|
||||
}
|
||||
.ops a:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.related small {
|
||||
color: var(--site-muted);
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
14
src/routes/trackers/trains/countries/+page.server.ts
Normal file
14
src/routes/trackers/trains/countries/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { trainOverview } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { countries, stats } = await trainOverview();
|
||||
return {
|
||||
countries,
|
||||
stats,
|
||||
seo: {
|
||||
title: 'Trains by country',
|
||||
description: `Train types from ${stats.countries} countries in the train pokedex: where they were built and where they run.`
|
||||
}
|
||||
};
|
||||
};
|
||||
117
src/routes/trackers/trains/countries/+page.svelte
Normal file
117
src/routes/trackers/trains/countries/+page.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { flag, fmtNumber, monogram, brandHue, thumbAt } from '#lib/trains/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/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||
<header class="mt-6 mb-8">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">countries</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
Train types from {fmtNumber(data.countries.length)} countries, counted where they were built and
|
||||
where they ran. Tap one for its trains, oldest first.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid">
|
||||
{#each data.countries as c, i (c.slug)}
|
||||
<li>
|
||||
<a href="/trackers/trains/countries/{c.slug}" class="tile">
|
||||
<span class="media" style:--hue={brandHue(c.name)}>
|
||||
{#if c.photo}
|
||||
<img
|
||||
src={thumbAt(c.photo, 330)}
|
||||
alt=""
|
||||
loading={i < 8 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
width="330"
|
||||
height="186"
|
||||
/>
|
||||
{:else}
|
||||
<span class="placeholder" aria-hidden="true">{monogram(c.name)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="name">{flag(c.name)} {c.name}</span>
|
||||
<span class="meta">{fmtNumber(c.types)} {c.types === 1 ? 'type' : 'types'}</span>
|
||||
</a>
|
||||
</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(11rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
height: 100%;
|
||||
padding: 0 0 0.65rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--site-surface);
|
||||
transition:
|
||||
transform 0.15s,
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.tile:hover,
|
||||
.tile:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--site-muted);
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.media {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-bottom: 0.3rem;
|
||||
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;
|
||||
}
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: hsl(var(--hue) 50% 60% / 0.25);
|
||||
}
|
||||
.name,
|
||||
.meta {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
.name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getCountry } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getCountry(params.country);
|
||||
if (!found) error(404, 'No trains from that country in the train pokedex');
|
||||
const { country, types } = found;
|
||||
return {
|
||||
country,
|
||||
types,
|
||||
seo: {
|
||||
title: `Trains of ${country.name}`,
|
||||
description: `${country.types} train types built in or running in ${country.name}: locomotives, multiple units, high-speed trains, metros and trams, with specs and photos.`
|
||||
}
|
||||
};
|
||||
};
|
||||
91
src/routes/trackers/trains/countries/[country]/+page.svelte
Normal file
91
src/routes/trackers/trains/countries/[country]/+page.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||
import {
|
||||
CATEGORIES,
|
||||
categoryEmoji,
|
||||
categoryLabel,
|
||||
flag,
|
||||
fmtNumber,
|
||||
type TrainCard as Card
|
||||
} from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const groups = $derived(
|
||||
CATEGORIES.map((cat) => ({
|
||||
cat,
|
||||
types: data.types.filter((t: Card) => t.category === cat)
|
||||
})).filter((g) => g.types.length)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a href="/trackers/trains/countries" class="back"><ArrowLeft size={15} /> all countries</a>
|
||||
<header class="mt-6 mb-8">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||
{flag(data.country.name)}
|
||||
{data.country.name}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
{fmtNumber(data.country.types)} train types built in or running in {data.country.name}, oldest
|
||||
first.
|
||||
<a href="/trackers/trains?country={encodeURIComponent(data.country.name)}" class="underline"
|
||||
>Search and sort them</a
|
||||
>.
|
||||
</p>
|
||||
<nav class="mt-4 flex flex-wrap gap-2" aria-label="Kinds">
|
||||
{#each groups as g (g.cat)}
|
||||
<a href="#{g.cat.replace(/ /g, '-')}" class="quick"
|
||||
>{categoryEmoji[g.cat]} {categoryLabel[g.cat]} ({g.types.length})</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{#each groups as g, gi (g.cat)}
|
||||
<section class="mb-10" id={g.cat.replace(/ /g, '-')}>
|
||||
<h2 class="mb-3 text-lg font-semibold">
|
||||
{categoryEmoji[g.cat]}
|
||||
{categoryLabel[g.cat]}
|
||||
<span class="text-sm font-normal text-[var(--site-muted)]">{g.types.length}</span>
|
||||
</h2>
|
||||
<ul class="grid">
|
||||
{#each g.types as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={gi === 0 && i < 4} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</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);
|
||||
}
|
||||
.quick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.quick:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||
}
|
||||
</style>
|
||||
20
src/routes/trackers/trains/makers/+page.server.ts
Normal file
20
src/routes/trackers/trains/makers/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { trainOverview } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { byBuilt, stats } = await trainOverview();
|
||||
return {
|
||||
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
built,
|
||||
logo
|
||||
})),
|
||||
stats,
|
||||
seo: {
|
||||
title: 'Train builders',
|
||||
description: `All ${stats.makers} train builders in the train pokedex with their logos, most built first.`
|
||||
}
|
||||
};
|
||||
};
|
||||
49
src/routes/trackers/trains/makers/+page.svelte
Normal file
49
src/routes/trackers/trains/makers/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import MakerTile from '#lib/trains/MakerTile.svelte';
|
||||
import { LOGO_NOTE, fmtNumber } from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const withLogo = $derived(data.makers.filter((m) => m.logo).length);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||
<header class="mt-6 mb-8">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">builders</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
All {fmtNumber(data.makers.length)} builders in the train pokedex (manufacturers and railway works),
|
||||
the ones that built the most first. Tap one for its trains.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid">
|
||||
{#each data.makers as maker (maker.slug)}
|
||||
<li><MakerTile {maker} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="mt-10 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||
{withLogo} of {data.makers.length} builders have a logo; the rest get a coloured monogram.
|
||||
{LOGO_NOTE} Each builder page credits its logo's author and licence.
|
||||
</p>
|
||||
</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(9.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
20
src/routes/trackers/trains/operators/+page.server.ts
Normal file
20
src/routes/trackers/trains/operators/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { trainOverview } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { operators, stats } = await trainOverview();
|
||||
return {
|
||||
makers: operators.map(({ slug, name, types, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
logo,
|
||||
href: `/trackers/trains/operators/${slug}`
|
||||
})),
|
||||
stats,
|
||||
seo: {
|
||||
title: 'Train operators',
|
||||
description: `All ${stats.operators} railway operators in the train pokedex with their logos, the ones with the most train types first.`
|
||||
}
|
||||
};
|
||||
};
|
||||
49
src/routes/trackers/trains/operators/+page.svelte
Normal file
49
src/routes/trackers/trains/operators/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import MakerTile from '#lib/trains/MakerTile.svelte';
|
||||
import { LOGO_NOTE, fmtNumber } from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const withLogo = $derived(data.makers.filter((m) => m.logo).length);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||
<header class="mt-6 mb-8">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">operators</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
All {fmtNumber(data.makers.length)} railway companies, transit agencies and other operators in the
|
||||
train pokedex, the ones running the most types first. Tap one for its fleet history.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid">
|
||||
{#each data.makers as maker (maker.slug)}
|
||||
<li><MakerTile {maker} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="mt-10 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||
{withLogo} of {data.makers.length} operators have a logo; the rest get a coloured monogram.
|
||||
{LOGO_NOTE} Each operator page credits its logo's author and licence.
|
||||
</p>
|
||||
</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(9.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getOperator } from '#lib/server/trains.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getOperator(params.operator);
|
||||
if (!found) error(404, 'No such operator in the train pokedex');
|
||||
const { operator, types, hero } = found;
|
||||
return {
|
||||
operator: {
|
||||
slug: operator.slug,
|
||||
name: operator.name,
|
||||
fullName: operator.fullName,
|
||||
country: operator.country,
|
||||
typeCount: types.length,
|
||||
wikidata: operator.wikidata,
|
||||
wikipedia: operator.wikipedia,
|
||||
logo: operator.logo,
|
||||
logoFile: operator.logoFile,
|
||||
logoAuthor: operator.logoAuthor,
|
||||
logoLicense: operator.logoLicense,
|
||||
photo: operator.photo
|
||||
},
|
||||
hero,
|
||||
types,
|
||||
seo: {
|
||||
title: `${operator.name} trains`,
|
||||
description: `The ${types.length} train types run by ${operator.name}${operator.country ? ` (${operator.country})` : ''} in Stan's train pokedex, with specs and photos.`
|
||||
}
|
||||
};
|
||||
};
|
||||
148
src/routes/trackers/trains/operators/[operator]/+page.svelte
Normal file
148
src/routes/trackers/trains/operators/[operator]/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||
import TrainCard from '#lib/trains/TrainCard.svelte';
|
||||
import {
|
||||
commonsPage,
|
||||
flag,
|
||||
fmtNumber,
|
||||
LOGO_NOTE,
|
||||
thumbAt,
|
||||
thumbSrcset
|
||||
} from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const b = $derived(data.operator);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a href="/trackers/trains" class="back"><ArrowLeft size={15} /> all trains</a>
|
||||
{#if b.photo}
|
||||
<figure class="hero">
|
||||
<img
|
||||
src={thumbAt(b.photo, 960)}
|
||||
srcset={thumbSrcset(b.photo, [500, 960, 1280])}
|
||||
sizes="(min-width: 1152px) 1104px, 100vw"
|
||||
alt="{b.name} {data.hero?.type ?? ''}"
|
||||
fetchpriority="high"
|
||||
width="1280"
|
||||
height="480"
|
||||
/>
|
||||
{#if data.hero}
|
||||
<figcaption>
|
||||
{b.name}
|
||||
{data.hero.type} ·
|
||||
<a href={commonsPage(data.hero.file)} rel="noopener"
|
||||
>{data.hero.author ?? 'Wikimedia Commons'}</a
|
||||
>{#if data.hero.license}
|
||||
· {#if data.hero.licenseUrl}<a href={data.hero.licenseUrl} rel="noopener license"
|
||||
>{data.hero.license}</a
|
||||
>{:else}{data.hero.license}{/if}{/if}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/if}
|
||||
<header class="mt-6 mb-8 flex items-center gap-5">
|
||||
{#if b.logo}
|
||||
<img
|
||||
class="logo"
|
||||
src={b.logo}
|
||||
alt="{b.name} logo"
|
||||
title="Logo: {b.logoAuthor ?? 'Wikimedia Commons'}{b.logoLicense
|
||||
? ` (${b.logoLicense})`
|
||||
: ''}"
|
||||
width="72"
|
||||
height="72"
|
||||
/>
|
||||
{:else}
|
||||
<BrandBadge name={b.name} size="lg" />
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{b.name}</h1>
|
||||
{#if b.fullName && b.fullName !== b.name}<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||||
{b.fullName}
|
||||
</p>{/if}
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{#if b.country}{flag(b.country)} {b.country} ·
|
||||
{/if}{fmtNumber(b.typeCount)}
|
||||
{b.typeCount === 1 ? 'type' : 'types'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="grid">
|
||||
{#each data.types as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={i < 4} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
Oldest first. Sources: Wikipedia, Wikidata.{#if b.wikipedia}
|
||||
· <a href="https://en.wikipedia.org/wiki/{b.wikipedia.replace(/ /g, '_')}" rel="noopener"
|
||||
>{b.name} on Wikipedia</a
|
||||
>{/if}{#if b.logoFile}
|
||||
· Logo: <a href={commonsPage(b.logoFile)} rel="noopener"
|
||||
>{b.logoAuthor ?? 'Wikimedia Commons'}</a
|
||||
>{#if b.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo}
|
||||
{LOGO_NOTE}{/if}
|
||||
<a href="/trackers/trains/operators" class="underline">All operators</a>.
|
||||
</p>
|
||||
</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;
|
||||
margin-top: 1.25rem;
|
||||
aspect-ratio: 8 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.hero img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero figcaption {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100%;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
border-top-left-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero figcaption a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.logo {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
object-fit: contain;
|
||||
padding: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--site-border);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
|
||||
}
|
||||
</style>
|
||||
9
src/routes/trackers/trains/random/+server.ts
Normal file
9
src/routes/trackers/trains/random/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { randomTrain } from '#lib/server/trains.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ setHeaders }) => {
|
||||
const train = await randomTrain();
|
||||
setHeaders({ 'cache-control': 'no-store' });
|
||||
redirect(303, train ? `/trackers/trains/${train.makerSlug}/${train.slug}` : '/trackers/trains');
|
||||
};
|
||||
Reference in New Issue
Block a user