Files
website/src/routes/trackers/cars/+page.svelte

326 lines
9.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { page } from '$app/state';
import CarCard from '#lib/cars/CarCard.svelte';
import {
BODIES,
bodyLabel,
DRIVES,
driveLabel,
FUELS,
fuelLabel,
fmtCompact,
fmtNumber,
SORTS
} from '#lib/cars/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.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to)
);
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/cars${qs ? `?${qs}` : ''}`;
}
const quick = [
{ label: '🔥 most common in NL', params: { sort: 'popular', market: 'nl' } },
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🐎 Mustang', params: { q: 'mustang' } }
];
</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">cars</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A car pokedex: every brand and model sold as a regular production car that I could find in
Dutch registrations or US fuel-economy tests. Tap one to learn what it is.
</p>
</div>
<a href="/trackers/cars/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random car</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>brands</dt>
<dd>{fmtNumber(data.stats.brands)}</dd>
</div>
<div>
<dt>models</dt>
<dd>{fmtNumber(data.stats.models)}</dd>
</div>
<div>
<dt>US versions</dt>
<dd>{fmtNumber(data.stats.variants)}</dd>
</div>
<div>
<dt>cars on Dutch roads</dt>
<dd>{fmtCompact(data.stats.nlCars)}</dd>
</div>
</dl>
<form
bind:this={form}
method="GET"
action="/trackers/cars"
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 'mustang', 'yaris', 'golf gti'…"
aria-label="Search cars"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
<NativeSelect name="brand" aria-label="Brand" value={f.brand} onchange={submit}>
<option value="">all brands</option>
{#each data.brands as b (b.slug)}<option value={b.slug}>{b.name} ({b.models})</option
>{/each}
</NativeSelect>
<NativeSelect name="fuel" aria-label="Fuel" value={f.fuel} onchange={submit}>
<option value="">any fuel</option>
{#each FUELS as x (x)}<option value={x}>{fuelLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="body" aria-label="Body type" value={f.body} onchange={submit}>
<option value="">any body</option>
{#each BODIES as x (x)}<option value={x}>{bodyLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="drive" aria-label="Drive" value={f.drive} onchange={submit}>
<option value="">any drive</option>
{#each DRIVES as x (x)}<option value={x}>{driveLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="market" aria-label="Market" value={f.market} onchange={submit}>
<option value="">NL + US</option>
<option value="nl">registered in NL</option>
<option value="us">sold in the US</option>
</NativeSelect>
<span class="years">
<Input
type="number"
name="from"
value={f.from ?? ''}
min="1900"
max={data.currentYear}
placeholder="from"
aria-label="Built from year"
onchange={submit}
class="w-20"
/>
<span aria-hidden="true">–</span>
<Input
type="number"
name="to"
value={f.to ?? ''}
min="1900"
max={data.currentYear}
placeholder="to"
aria-label="Built up to year"
onchange={submit}
class="w-20"
/>
</span>
<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/cars" 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 ? 'model' : 'models'}{#if pages > 1}
· page {f.page} of {pages}{/if}
</p>
{#if data.cars.length}
<ul class="grid">
{#each data.cars as car (car.dex)}
<li><CarCard {car} current={data.currentYear} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No cars match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href="/trackers/cars" 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://opendata.rdw.nl" class="underline">RDW open data</a> (Dutch vehicle
register, CC0) and
<a href="https://www.fueleconomy.gov/feg/download.shtml" class="underline">fueleconomy.gov</a>
(US EPA/DOE). Models are grouped from registered trade names, so a few odd splits remain. Prices are
average catalogue prices of new cars when first registered in NL. Blank means unknown.
</footer>
</div>
<style>
.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;
}
.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;
}
.years {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--site-muted);
}
.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>