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

320 lines
9.5 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import MakerTile from '#lib/planes/MakerTile.svelte';
import PlaneCard from '#lib/planes/PlaneCard.svelte';
import {
ENGINES,
engineLabel,
ERAS,
fmtCompact,
fmtNumber,
LOGO_NOTE,
ROLES,
roleEmoji,
roleLabel,
SORTS
} from '#lib/planes/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.role || f.engine || 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/planes${qs ? `?${qs}` : ''}`;
}
const quick = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '🛫 airliners', params: { role: 'airliner', sort: 'built' } },
{ label: '🚁 helicopters', params: { role: 'helicopter', sort: 'built' } },
{ label: '💼 bizjets', params: { role: 'business', sort: 'built' } },
{ label: '🎖️ WW2 warbirds', params: { role: 'military', era: 'ww2', sort: 'built' } },
{ label: '🚀 fastest', params: { sort: 'fastest' } },
{ label: '🐘 heaviest', params: { sort: 'biggest' } },
{ label: '✈️ 737', params: { q: '737' } }
];
</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">planes</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A plane pokedex: airliners, helicopters, business jets, light aircraft, gliders and famous
warbirds that were actually built and flown. Tap one to see what it is and how to spot it.
</p>
</div>
<a href="/trackers/planes/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random plane</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>aircraft types</dt>
<dd>{fmtNumber(data.stats.types)}</dd>
</div>
<div>
<dt>makers</dt>
<dd>{fmtNumber(data.stats.makers)}</dd>
</div>
<div>
<dt>aircraft 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">makers</h2>
<a href="/trackers/planes/makers" class="text-sm text-[var(--site-muted)] hover:underline"
>all {fmtNumber(data.stats.makers)} makers →</a
>
</div>
<ul class="brands">
{#each data.topMakers as maker (maker.slug)}
<li><MakerTile {maker} compact /></li>
{/each}
</ul>
</section>
{/if}
<form
bind:this={form}
method="GET"
action="/trackers/planes"
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 '737', 'a320', 'cessna', 'apache', 'B738'…"
aria-label="Search planes"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
<NativeSelect name="maker" aria-label="Maker" value={f.maker} onchange={submit}>
<option value="">all makers</option>
{#each data.makers as m (m.slug)}<option value={m.slug}>{m.name} ({m.types})</option>{/each}
</NativeSelect>
<NativeSelect name="role" aria-label="Role" value={f.role} onchange={submit}>
<option value="">any role</option>
{#each ROLES as x (x)}<option value={x}>{roleEmoji[x]} {roleLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="engine" aria-label="Engine type" value={f.engine} onchange={submit}>
<option value="">any engine</option>
{#each ENGINES as x (x)}<option value={x}>{engineLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="era" aria-label="Era (first flight)" 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="">flying or retired</option>
<option value="yes">still flying</option>
<option value="no">retired</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/planes" 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.planes.length}
<ul class="grid">
{#each data.planes as plane, i (plane.dex)}
<li><PlaneCard {plane} eager={i < 4} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No planes match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href="/trackers/planes" 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> infoboxes and
specification sections (CC BY-SA 4.0), ICAO codes from Wikipedia's
<a href="https://en.wikipedia.org/wiki/List_of_aircraft_type_designators" class="underline"
>list of aircraft type designators</a
>. Only types that were built in numbers (no prototypes or drones). Era is by first flight.
Blank means unknown. Photos from Wikimedia Commons, credited on each plane'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(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;
}
.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>