326 lines
9.1 KiB
Svelte
326 lines
9.1 KiB
Svelte
<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>
|