planes: pokedex tracker pages, cards, makers, random
This commit is contained in:
71
src/lib/planes/MakerTile.svelte
Normal file
71
src/lib/planes/MakerTile.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import { fmtCompact, type MakerTileData } from './shared.js';
|
||||||
|
|
||||||
|
let { maker, compact = false }: { maker: MakerTileData; compact?: boolean } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a href="/trackers/planes/{maker.slug}" class={['tile', { compact }]} title={maker.name}>
|
||||||
|
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
|
||||||
|
<span class="name">{maker.name}</span>
|
||||||
|
{#if !compact}
|
||||||
|
<span class="meta"
|
||||||
|
>{maker.types}
|
||||||
|
{maker.types === 1 ? 'type' : 'types'}{#if maker.built}
|
||||||
|
· {fmtCompact(maker.built)} built{/if}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.7rem 0.6rem 0.65rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
text-align: center;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
.compact .mark {
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.compact .name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
211
src/lib/planes/PlaneCard.svelte
Normal file
211
src/lib/planes/PlaneCard.svelte
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import {
|
||||||
|
brandHue,
|
||||||
|
dexLabel,
|
||||||
|
engineLabel,
|
||||||
|
fmtCompact,
|
||||||
|
monogram,
|
||||||
|
roleEmoji,
|
||||||
|
roleLabel,
|
||||||
|
thumbAt,
|
||||||
|
thumbSrcset,
|
||||||
|
type PlaneCard
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
let { plane, eager = false }: { plane: PlaneCard; eager?: boolean } = $props();
|
||||||
|
const href = $derived(`/trackers/planes/${plane.makerSlug}/${plane.slug}`);
|
||||||
|
const engines = $derived(
|
||||||
|
plane.engineType && plane.engineType !== 'none'
|
||||||
|
? `${plane.engineCount && plane.engineCount > 1 ? `${plane.engineCount}× ` : ''}${engineLabel[plane.engineType] ?? plane.engineType}`
|
||||||
|
: plane.engineType === 'none'
|
||||||
|
? 'no engine'
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a {href} class="card">
|
||||||
|
<div class="media" style:--hue={brandHue(plane.makerName)}>
|
||||||
|
{#if plane.photo}
|
||||||
|
<img
|
||||||
|
src={thumbAt(plane.photo, 330)}
|
||||||
|
srcset={thumbSrcset(plane.photo)}
|
||||||
|
sizes="(min-width: 1024px) 270px, (min-width: 640px) 45vw, 100vw"
|
||||||
|
alt={plane.fullName}
|
||||||
|
loading={eager ? 'eager' : 'lazy'}
|
||||||
|
decoding="async"
|
||||||
|
width="330"
|
||||||
|
height="206"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder" aria-hidden="true">{monogram(plane.makerName)}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="badge"><BrandBadge name={plane.makerName} logo={plane.makerLogo} size="sm" /></span
|
||||||
|
>
|
||||||
|
<span class="dex">{dexLabel(plane.dex)}</span>
|
||||||
|
{#if plane.icao[0]}<span class="icao" title="ICAO type designator">{plane.icao[0]}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="brand">{plane.makerName}</p>
|
||||||
|
<h3 class="name">{plane.name}</h3>
|
||||||
|
<p class="meta">
|
||||||
|
{plane.firstFlightYear
|
||||||
|
? `first flew ${plane.firstFlightYear}`
|
||||||
|
: '—'}{#if plane.inService === true}
|
||||||
|
· <span class="flying">still flying</span>{:else if plane.inService === false}
|
||||||
|
· retired{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
{#if plane.role}<span class="chip"
|
||||||
|
>{roleEmoji[plane.role] ?? ''} {roleLabel[plane.role] ?? plane.role}</span
|
||||||
|
>{/if}
|
||||||
|
{#if engines}<span class="chip">{engines}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<dl class="nums">
|
||||||
|
<div title="Number built (Wikipedia / Wikidata)">
|
||||||
|
<dt>built</dt>
|
||||||
|
<dd>{plane.numberBuilt ? fmtCompact(plane.numberBuilt) : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div title={plane.maxSpeedKmh ? 'Maximum speed' : 'Cruise speed'}>
|
||||||
|
<dt>{plane.maxSpeedKmh || !plane.cruiseKmh ? 'top speed' : 'cruise'}</dt>
|
||||||
|
<dd>
|
||||||
|
{(plane.maxSpeedKmh ?? plane.cruiseKmh)
|
||||||
|
? `${plane.maxSpeedKmh ?? plane.cruiseKmh} km/h`
|
||||||
|
: '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div title="Passengers (typical maximum)">
|
||||||
|
<dt>seats</dt>
|
||||||
|
<dd>{plane.passengers ? plane.passengers : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 0.9rem;
|
||||||
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
.card:hover,
|
||||||
|
.card:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.media {
|
||||||
|
position: relative;
|
||||||
|
margin: -0.9rem -0.9rem 0.1rem;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.85rem 0.85rem 0 0;
|
||||||
|
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;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.card:hover .media img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: hsl(var(--hue) 50% 60% / 0.25);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.6rem;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.dex,
|
||||||
|
.icao {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.icao {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.flying {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-height: 1.3rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.28rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
color: var(--site-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nums {
|
||||||
|
margin-top: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
border-top: 1px solid var(--site-border);
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
.nums dt {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nums dd {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
188
src/lib/planes/shared.ts
Normal file
188
src/lib/planes/shared.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// Shared (client + server) labels and helpers for the plane pokedex (/trackers/planes).
|
||||||
|
export {
|
||||||
|
dexLabel,
|
||||||
|
monogram,
|
||||||
|
brandHue,
|
||||||
|
fmtCompact,
|
||||||
|
fmtNumber,
|
||||||
|
thumbAt,
|
||||||
|
thumbSrcset,
|
||||||
|
commonsPage
|
||||||
|
} from '#lib/cars/shared.js';
|
||||||
|
export type { CarPhoto as PlanePhoto } from '#lib/cars/shared.js';
|
||||||
|
|
||||||
|
export const ROLES = [
|
||||||
|
'airliner',
|
||||||
|
'regional',
|
||||||
|
'cargo',
|
||||||
|
'business',
|
||||||
|
'general aviation',
|
||||||
|
'helicopter',
|
||||||
|
'military',
|
||||||
|
'glider',
|
||||||
|
'airship'
|
||||||
|
] as const;
|
||||||
|
export const ENGINES = [
|
||||||
|
'jet',
|
||||||
|
'turboprop',
|
||||||
|
'piston',
|
||||||
|
'turboshaft',
|
||||||
|
'electric',
|
||||||
|
'rocket',
|
||||||
|
'none'
|
||||||
|
] as const;
|
||||||
|
export const ERAS = {
|
||||||
|
pioneer: { label: 'pioneers (–1918)', from: null, to: 1918 },
|
||||||
|
golden: { label: 'golden age (1919–1938)', from: 1919, to: 1938 },
|
||||||
|
ww2: { label: 'WW2 (1939–1945)', from: 1939, to: 1945 },
|
||||||
|
jet: { label: 'jet age (1946–1969)', from: 1946, to: 1969 },
|
||||||
|
modern: { label: 'modern (1970–1999)', from: 1970, to: 1999 },
|
||||||
|
new: { label: '21st century (2000–)', from: 2000, to: null }
|
||||||
|
} as const;
|
||||||
|
export type PlaneEra = keyof typeof ERAS;
|
||||||
|
export const SORTS = {
|
||||||
|
built: 'most built',
|
||||||
|
dex: 'dex #',
|
||||||
|
name: 'name',
|
||||||
|
newest: 'newest',
|
||||||
|
oldest: 'oldest',
|
||||||
|
fastest: 'fastest',
|
||||||
|
biggest: 'heaviest',
|
||||||
|
range: 'longest range',
|
||||||
|
seats: 'most seats'
|
||||||
|
} as const;
|
||||||
|
export type PlaneSort = keyof typeof SORTS;
|
||||||
|
|
||||||
|
export const roleLabel: Record<string, string> = {
|
||||||
|
airliner: 'airliner',
|
||||||
|
regional: 'regional',
|
||||||
|
cargo: 'cargo',
|
||||||
|
business: 'business jet',
|
||||||
|
'general aviation': 'light / GA',
|
||||||
|
helicopter: 'helicopter',
|
||||||
|
military: 'military',
|
||||||
|
glider: 'glider',
|
||||||
|
airship: 'airship'
|
||||||
|
};
|
||||||
|
export const roleEmoji: Record<string, string> = {
|
||||||
|
airliner: '🛫',
|
||||||
|
regional: '🛩️',
|
||||||
|
cargo: '📦',
|
||||||
|
business: '💼',
|
||||||
|
'general aviation': '🛩️',
|
||||||
|
helicopter: '🚁',
|
||||||
|
military: '🎖️',
|
||||||
|
glider: '🪂',
|
||||||
|
airship: '🎈'
|
||||||
|
};
|
||||||
|
export const engineLabel: Record<string, string> = {
|
||||||
|
jet: 'jet',
|
||||||
|
turboprop: 'turboprop',
|
||||||
|
piston: 'piston',
|
||||||
|
turboshaft: 'turboshaft',
|
||||||
|
electric: 'electric',
|
||||||
|
rocket: 'rocket',
|
||||||
|
none: 'no engine'
|
||||||
|
};
|
||||||
|
/** One beginner-friendly line per engine type. */
|
||||||
|
export const engineHint: Record<string, string> = {
|
||||||
|
jet: 'Gas turbine pushing air out the back (turbofan or turbojet): fast and high-flying.',
|
||||||
|
turboprop: 'A jet-style turbine turning a propeller: efficient on short hops.',
|
||||||
|
piston:
|
||||||
|
'A car-like piston engine turning a propeller: simple and cheap, the classic small plane.',
|
||||||
|
turboshaft: 'A turbine that turns a shaft, usually a helicopter rotor.',
|
||||||
|
electric: 'Battery-powered electric motors.',
|
||||||
|
rocket: 'Rocket propulsion: very fast, very short flights.',
|
||||||
|
none: 'No engine: it glides on rising air.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLAGS: Record<string, string> = {
|
||||||
|
'United States': '🇺🇸',
|
||||||
|
'United States of America': '🇺🇸',
|
||||||
|
'United Kingdom': '🇬🇧',
|
||||||
|
France: '🇫🇷',
|
||||||
|
Germany: '🇩🇪',
|
||||||
|
'Soviet Union': '🇷🇺',
|
||||||
|
Russia: '🇷🇺',
|
||||||
|
Italy: '🇮🇹',
|
||||||
|
Japan: '🇯🇵',
|
||||||
|
Canada: '🇨🇦',
|
||||||
|
Brazil: '🇧🇷',
|
||||||
|
China: '🇨🇳',
|
||||||
|
"People's Republic of China": '🇨🇳',
|
||||||
|
Netherlands: '🇳🇱',
|
||||||
|
Sweden: '🇸🇪',
|
||||||
|
Spain: '🇪🇸',
|
||||||
|
Czechoslovakia: '🇨🇿',
|
||||||
|
'Czech Republic': '🇨🇿',
|
||||||
|
Poland: '🇵🇱',
|
||||||
|
Switzerland: '🇨🇭',
|
||||||
|
Austria: '🇦🇹',
|
||||||
|
Australia: '🇦🇺',
|
||||||
|
Ukraine: '🇺🇦',
|
||||||
|
India: '🇮🇳',
|
||||||
|
Israel: '🇮🇱',
|
||||||
|
Romania: '🇷🇴',
|
||||||
|
Indonesia: '🇮🇩',
|
||||||
|
Argentina: '🇦🇷',
|
||||||
|
'South Korea': '🇰🇷',
|
||||||
|
Turkey: '🇹🇷',
|
||||||
|
Belgium: '🇧🇪',
|
||||||
|
'New Zealand': '🇳🇿',
|
||||||
|
Finland: '🇫🇮',
|
||||||
|
Slovenia: '🇸🇮',
|
||||||
|
Hungary: '🇭🇺',
|
||||||
|
Denmark: '🇩🇰',
|
||||||
|
Norway: '🇳🇴',
|
||||||
|
'South Africa': '🇿🇦',
|
||||||
|
Iran: '🇮🇷',
|
||||||
|
Taiwan: '🇹🇼'
|
||||||
|
};
|
||||||
|
export const flag = (country: string | null) => (country ? (FLAGS[country] ?? '') : '');
|
||||||
|
|
||||||
|
const compact = new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 });
|
||||||
|
export const fmtKg = (kg: number) =>
|
||||||
|
kg >= 10_000
|
||||||
|
? `${compact.format(kg / 1000)} t`
|
||||||
|
: `${new Intl.NumberFormat('en-GB').format(kg)} kg`;
|
||||||
|
export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m * 10) / 10} m`;
|
||||||
|
|
||||||
|
export type PlaneCard = {
|
||||||
|
dex: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
makerSlug: string;
|
||||||
|
makerName: string;
|
||||||
|
makerLogo: string | null;
|
||||||
|
role: string | null;
|
||||||
|
engineType: string | null;
|
||||||
|
engineCount: number | null;
|
||||||
|
firstFlightYear: number | null;
|
||||||
|
inService: boolean | null;
|
||||||
|
status: string | null;
|
||||||
|
numberBuilt: number | null;
|
||||||
|
passengers: number | null;
|
||||||
|
maxSpeedKmh: number | null;
|
||||||
|
cruiseKmh: number | null;
|
||||||
|
icao: string[];
|
||||||
|
photo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MakerTileData = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
types: number;
|
||||||
|
built: number;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusLabel: Record<string, string> = {
|
||||||
|
'in production': 'in production',
|
||||||
|
'in service': 'still flying',
|
||||||
|
retired: 'retired',
|
||||||
|
'in development': 'in development'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOGO_NOTE =
|
||||||
|
'Maker logos come from Wikimedia Commons. Most are public domain as simple text or shapes, but they remain trademarks of their owners and are shown only to identify the maker.';
|
||||||
@@ -135,6 +135,13 @@ const allTrackers = [
|
|||||||
href: '/lists',
|
href: '/lists',
|
||||||
external: false,
|
external: false,
|
||||||
private: false
|
private: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'planes',
|
||||||
|
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
|
||||||
|
href: '/trackers/planes',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.'
|
'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.'
|
||||||
},
|
},
|
||||||
|
'/trackers/planes': {
|
||||||
|
title: 'Plane pokedex',
|
||||||
|
description:
|
||||||
|
'Search every production aircraft type: airliners, helicopters, business jets, light aircraft and warbirds, with specs, photos and how to recognise them.'
|
||||||
|
},
|
||||||
'/trackers/pc': {
|
'/trackers/pc': {
|
||||||
title: 'PC setup',
|
title: 'PC setup',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
|
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
||||||
|
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const ownerWritePrefixes = [
|
|||||||
'/trackers/houses',
|
'/trackers/houses',
|
||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
|
'/trackers/planes',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
'/trackers/pc'
|
'/trackers/pc'
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
275
src/lib/server/planes.ts
Normal file
275
src/lib/server/planes.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
// Plane pokedex queries (/trackers/planes). Public, read-only; data is written only by
|
||||||
|
// scripts/planes-import.ts and scripts/planes-photos.ts.
|
||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import { planeMakers, planePhotos, planeTypes } from '#lib/server/db/schema/index.js';
|
||||||
|
import { and, arrayContains, asc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
ENGINES,
|
||||||
|
ERAS,
|
||||||
|
ROLES,
|
||||||
|
SORTS,
|
||||||
|
type PlaneCard,
|
||||||
|
type PlaneEra,
|
||||||
|
type PlaneSort
|
||||||
|
} from '#lib/planes/shared.js';
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 60;
|
||||||
|
|
||||||
|
export type PlaneFilters = {
|
||||||
|
q: string;
|
||||||
|
maker: string;
|
||||||
|
role: string;
|
||||||
|
engine: string;
|
||||||
|
era: PlaneEra | '';
|
||||||
|
service: '' | 'yes' | 'no';
|
||||||
|
sort: PlaneSort;
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = <T extends string>(value: string | null, allowed: readonly T[]): T | '' =>
|
||||||
|
allowed.includes(value as T) ? (value as T) : '';
|
||||||
|
|
||||||
|
export function parseFilters(params: URLSearchParams): PlaneFilters {
|
||||||
|
const sort = params.get('sort');
|
||||||
|
return {
|
||||||
|
q: (params.get('q') ?? '').trim().slice(0, 80),
|
||||||
|
maker: (params.get('maker') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 60),
|
||||||
|
role: pick(params.get('role'), ROLES),
|
||||||
|
engine: pick(params.get('engine'), ENGINES),
|
||||||
|
era: pick(params.get('era'), Object.keys(ERAS) as PlaneEra[]),
|
||||||
|
service: pick(params.get('service'), ['yes', 'no'] as const),
|
||||||
|
sort: sort && sort in SORTS ? (sort as PlaneSort) : 'built',
|
||||||
|
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardColumns = {
|
||||||
|
dex: planeTypes.dex,
|
||||||
|
slug: planeTypes.slug,
|
||||||
|
name: planeTypes.name,
|
||||||
|
fullName: planeTypes.fullName,
|
||||||
|
makerSlug: planeMakers.slug,
|
||||||
|
makerName: planeMakers.name,
|
||||||
|
makerLogo: planeMakers.logo,
|
||||||
|
role: planeTypes.role,
|
||||||
|
engineType: planeTypes.engineType,
|
||||||
|
engineCount: planeTypes.engineCount,
|
||||||
|
firstFlightYear: planeTypes.firstFlightYear,
|
||||||
|
inService: planeTypes.inService,
|
||||||
|
status: planeTypes.status,
|
||||||
|
numberBuilt: planeTypes.numberBuilt,
|
||||||
|
passengers: planeTypes.passengers,
|
||||||
|
maxSpeedKmh: planeTypes.maxSpeedKmh,
|
||||||
|
cruiseKmh: planeTypes.cruiseKmh,
|
||||||
|
icao: planeTypes.icao,
|
||||||
|
photo: planeTypes.photo
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||||
|
|
||||||
|
export async function searchPlanes(
|
||||||
|
f: PlaneFilters
|
||||||
|
): Promise<{ planes: PlaneCard[]; total: number }> {
|
||||||
|
const where: SQL[] = [];
|
||||||
|
for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6)) {
|
||||||
|
// "a320" also matches "a 320" / "a-320" in names via the compact form stored in search.
|
||||||
|
const compact = word.replace(/[^a-z0-9]/g, '');
|
||||||
|
const like = ilike(planeTypes.search, `%${escapeLike(word)}%`);
|
||||||
|
where.push(
|
||||||
|
compact && compact !== word ? or(like, ilike(planeTypes.search, `%${compact}%`))! : like
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.maker) where.push(eq(planeMakers.slug, f.maker));
|
||||||
|
if (f.role) where.push(arrayContains(planeTypes.roles, [f.role]));
|
||||||
|
if (f.engine) where.push(eq(planeTypes.engineType, f.engine));
|
||||||
|
if (f.era) {
|
||||||
|
const era = ERAS[f.era];
|
||||||
|
if (era.from != null) where.push(gte(planeTypes.firstFlightYear, era.from));
|
||||||
|
if (era.to != null) where.push(lte(planeTypes.firstFlightYear, era.to));
|
||||||
|
}
|
||||||
|
if (f.service === 'yes') where.push(eq(planeTypes.inService, true));
|
||||||
|
if (f.service === 'no') where.push(eq(planeTypes.inService, false));
|
||||||
|
const order = {
|
||||||
|
built: [sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.dex)],
|
||||||
|
dex: [asc(planeTypes.dex)],
|
||||||
|
name: [asc(planeTypes.fullName)],
|
||||||
|
newest: [sql`${planeTypes.firstFlightYear} desc nulls last`, asc(planeTypes.dex)],
|
||||||
|
oldest: [sql`${planeTypes.firstFlightYear} asc nulls last`, asc(planeTypes.dex)],
|
||||||
|
fastest: [sql`${planeTypes.maxSpeedKmh} desc nulls last`, asc(planeTypes.dex)],
|
||||||
|
biggest: [sql`${planeTypes.mtowKg} desc nulls last`, asc(planeTypes.dex)],
|
||||||
|
range: [sql`${planeTypes.rangeKm} desc nulls last`, asc(planeTypes.dex)],
|
||||||
|
seats: [sql`${planeTypes.passengers} desc nulls last`, asc(planeTypes.dex)]
|
||||||
|
}[f.sort];
|
||||||
|
const condition = where.length ? and(...where) : undefined;
|
||||||
|
const [planes, [{ total }]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select(cardColumns)
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(condition)
|
||||||
|
.orderBy(...order)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((f.page - 1) * PAGE_SIZE),
|
||||||
|
db
|
||||||
|
.select({ total: sql<number>`count(*)::int` })
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(condition)
|
||||||
|
]);
|
||||||
|
return { planes, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MakerTile = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
types: number;
|
||||||
|
built: number;
|
||||||
|
country: string | null;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
type Overview = {
|
||||||
|
/** Alphabetical (for the filter); `byBuilt` has the same makers, most aircraft built first. */
|
||||||
|
makers: MakerTile[];
|
||||||
|
byBuilt: MakerTile[];
|
||||||
|
stats: { makers: number; types: number; built: number; photos: number };
|
||||||
|
};
|
||||||
|
let overviewCache: { at: number; value: Overview } | null = null;
|
||||||
|
|
||||||
|
/** Maker list for the filter and headline counts; cached for 10 minutes. */
|
||||||
|
export async function planeOverview(): Promise<Overview> {
|
||||||
|
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
|
||||||
|
const [makers, [counts]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
slug: planeMakers.slug,
|
||||||
|
name: planeMakers.name,
|
||||||
|
types: planeMakers.typeCount,
|
||||||
|
built: planeMakers.builtTotal,
|
||||||
|
country: planeMakers.country,
|
||||||
|
logo: planeMakers.logo
|
||||||
|
})
|
||||||
|
.from(planeMakers)
|
||||||
|
.orderBy(asc(planeMakers.name)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
types: sql<number>`count(*)::int`,
|
||||||
|
built: sql<number>`coalesce(sum(${planeTypes.numberBuilt}) filter (where ${planeTypes.familyId} is null), 0)::bigint`,
|
||||||
|
photos: sql<number>`coalesce(sum(${planeTypes.photoCount}), 0)::int`
|
||||||
|
})
|
||||||
|
.from(planeTypes)
|
||||||
|
]);
|
||||||
|
const value = {
|
||||||
|
makers,
|
||||||
|
byBuilt: [...makers].sort(
|
||||||
|
(a, b) => b.built - a.built || b.types - a.types || a.name.localeCompare(b.name)
|
||||||
|
),
|
||||||
|
stats: {
|
||||||
|
makers: makers.length,
|
||||||
|
types: counts.types,
|
||||||
|
built: Number(counts.built),
|
||||||
|
photos: counts.photos
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (value.stats.types) overviewCache = { at: Date.now(), value };
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMaker(slug: string) {
|
||||||
|
const [maker] = await db.select().from(planeMakers).where(eq(planeMakers.slug, slug)).limit(1);
|
||||||
|
if (!maker) return null;
|
||||||
|
const types = await db
|
||||||
|
.select(cardColumns)
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(eq(planeTypes.makerId, maker.id))
|
||||||
|
.orderBy(sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.fullName));
|
||||||
|
const [hero] = maker.photo
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
file: planePhotos.file,
|
||||||
|
author: planePhotos.author,
|
||||||
|
license: planePhotos.license,
|
||||||
|
licenseUrl: planePhotos.licenseUrl,
|
||||||
|
type: planeTypes.name
|
||||||
|
})
|
||||||
|
.from(planePhotos)
|
||||||
|
.innerJoin(planeTypes, eq(planeTypes.id, planePhotos.typeId))
|
||||||
|
.where(and(eq(planeTypes.makerId, maker.id), eq(planePhotos.thumb, maker.photo)))
|
||||||
|
.limit(1)
|
||||||
|
: [];
|
||||||
|
return { maker, types, hero: hero ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getType(makerSlug: string, typeSlug: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ type: planeTypes, maker: planeMakers })
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(and(eq(planeMakers.slug, makerSlug), eq(planeTypes.slug, typeSlug)))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) return null;
|
||||||
|
const t = row.type;
|
||||||
|
const [neighbours, photos, family] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
dex: planeTypes.dex,
|
||||||
|
slug: planeTypes.slug,
|
||||||
|
name: planeTypes.fullName,
|
||||||
|
makerSlug: planeMakers.slug
|
||||||
|
})
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(
|
||||||
|
sql`${planeTypes.dex} in (
|
||||||
|
(select max(dex) from planes.types where dex < ${t.dex}),
|
||||||
|
(select min(dex) from planes.types where dex > ${t.dex}))`
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
file: planePhotos.file,
|
||||||
|
kind: planePhotos.kind,
|
||||||
|
caption: planePhotos.caption,
|
||||||
|
thumb: planePhotos.thumb,
|
||||||
|
width: planePhotos.width,
|
||||||
|
height: planePhotos.height,
|
||||||
|
author: planePhotos.author,
|
||||||
|
license: planePhotos.license,
|
||||||
|
licenseUrl: planePhotos.licenseUrl
|
||||||
|
})
|
||||||
|
.from(planePhotos)
|
||||||
|
.where(eq(planePhotos.typeId, t.id))
|
||||||
|
.orderBy(asc(planePhotos.position)),
|
||||||
|
// Parent (if any) and direct variants.
|
||||||
|
db
|
||||||
|
.select({ ...cardColumns, id: planeTypes.id })
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(
|
||||||
|
t.familyId != null
|
||||||
|
? or(eq(planeTypes.familyId, t.id), eq(planeTypes.id, t.familyId))
|
||||||
|
: eq(planeTypes.familyId, t.id)
|
||||||
|
)
|
||||||
|
.orderBy(sql`${planeTypes.firstFlightYear} asc nulls last`, asc(planeTypes.fullName))
|
||||||
|
]);
|
||||||
|
const parent = family.find((f) => f.id === t.familyId) ?? null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
photos,
|
||||||
|
parent,
|
||||||
|
variants: family.filter((f) => f.id !== t.familyId),
|
||||||
|
prev: neighbours.find((n) => n.dex < t.dex) ?? null,
|
||||||
|
next: neighbours.find((n) => n.dex > t.dex) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A random type with a photo, favouring ones built in numbers. */
|
||||||
|
export async function randomPlane() {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ slug: planeTypes.slug, makerSlug: planeMakers.slug })
|
||||||
|
.from(planeTypes)
|
||||||
|
.innerJoin(planeMakers, eq(planeMakers.id, planeTypes.makerId))
|
||||||
|
.where(sql`${planeTypes.photo} is not null and coalesce(${planeTypes.numberBuilt}, 0) >= 20`)
|
||||||
|
.orderBy(sql`random()`)
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/trackers': 'Trackers',
|
'/trackers': 'Trackers',
|
||||||
'/trackers/books': 'Books',
|
'/trackers/books': 'Books',
|
||||||
'/trackers/cars': 'Cars',
|
'/trackers/cars': 'Cars',
|
||||||
|
'/trackers/planes': 'Planes',
|
||||||
'/trackers/pc': 'PC setup',
|
'/trackers/pc': 'PC setup',
|
||||||
'/blog': 'Blog',
|
'/blog': 'Blog',
|
||||||
'/lists': 'Lists',
|
'/lists': 'Lists',
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
|||||||
'M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.8 1C6.5 17.5 4.5 17 4.5 17A3.5 3.5 0 0 1 5.5 10Z'
|
'M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.8 1C6.5 17.5 4.5 17 4.5 17A3.5 3.5 0 0 1 5.5 10Z'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
planes: {
|
||||||
|
from: '#7dd3fc',
|
||||||
|
to: '#0284c7',
|
||||||
|
ink: WHITE,
|
||||||
|
paths: [
|
||||||
|
'M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z'
|
||||||
|
]
|
||||||
|
},
|
||||||
lists: {
|
lists: {
|
||||||
from: '#94a3b8',
|
from: '#94a3b8',
|
||||||
to: '#475569',
|
to: '#475569',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const staticPaths = [
|
|||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/cars/brands',
|
'/trackers/cars/brands',
|
||||||
|
'/trackers/planes',
|
||||||
|
'/trackers/planes/makers',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
'/trackers/pc',
|
'/trackers/pc',
|
||||||
'/projects/game-tracker',
|
'/projects/game-tracker',
|
||||||
|
|||||||
22
src/routes/trackers/planes/+page.server.ts
Normal file
22
src/routes/trackers/planes/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { PAGE_SIZE, parseFilters, planeOverview, searchPlanes } from '#lib/server/planes.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const filters = parseFilters(url.searchParams);
|
||||||
|
const [{ planes, total }, overview] = await Promise.all([searchPlanes(filters), planeOverview()]);
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
planes,
|
||||||
|
total,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
makers: overview.makers.map(({ slug, name, types }) => ({ slug, name, types })),
|
||||||
|
topMakers: overview.byBuilt
|
||||||
|
.slice(0, 16)
|
||||||
|
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
|
||||||
|
stats: overview.stats,
|
||||||
|
seo: {
|
||||||
|
title: 'Plane pokedex',
|
||||||
|
description: `Search ${overview.stats.types.toLocaleString('en-GB')} aircraft types from ${overview.stats.makers} makers: airliners, helicopters, bizjets, light aircraft and warbirds, with specs and photos.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
319
src/routes/trackers/planes/+page.svelte
Normal file
319
src/routes/trackers/planes/+page.svelte
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<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>
|
||||||
34
src/routes/trackers/planes/[maker]/+page.server.ts
Normal file
34
src/routes/trackers/planes/[maker]/+page.server.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getMaker } from '#lib/server/planes.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const found = await getMaker(params.maker);
|
||||||
|
if (!found) error(404, 'No such maker in the plane 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
|
||||||
|
},
|
||||||
|
hero,
|
||||||
|
types,
|
||||||
|
seo: {
|
||||||
|
title: `${maker.name} aircraft`,
|
||||||
|
description: `All ${maker.typeCount} ${maker.name} aircraft types in Stan's plane pokedex${maker.country ? ` (${maker.country})` : ''}, with specs and photos.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
151
src/routes/trackers/planes/[maker]/+page.svelte
Normal file
151
src/routes/trackers/planes/[maker]/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import PlaneCard from '#lib/planes/PlaneCard.svelte';
|
||||||
|
import {
|
||||||
|
commonsPage,
|
||||||
|
flag,
|
||||||
|
fmtCompact,
|
||||||
|
fmtNumber,
|
||||||
|
LOGO_NOTE,
|
||||||
|
thumbAt,
|
||||||
|
thumbSrcset
|
||||||
|
} from '#lib/planes/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/planes" class="back"><ArrowLeft size={15} /> all planes</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}
|
||||||
|
· first flights {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 plane, i (plane.dex)}
|
||||||
|
<li><PlaneCard {plane} 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/planes/makers" class="underline">All makers</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>
|
||||||
49
src/routes/trackers/planes/[maker]/[type]/+page.server.ts
Normal file
49
src/routes/trackers/planes/[maker]/[type]/+page.server.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getType } from '#lib/server/planes.js';
|
||||||
|
import type { planeTypes } from '#lib/server/db/schema/index.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
type Type = typeof planeTypes.$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 aircraft in the pokedex');
|
||||||
|
const { type, maker, photos, parent, variants, 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 {
|
||||||
|
plane: publicType(type),
|
||||||
|
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
||||||
|
photos,
|
||||||
|
parent: parent ? strip(parent) : null,
|
||||||
|
variants: variants.map(strip),
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
seo: {
|
||||||
|
title: type.fullName,
|
||||||
|
description: `${type.fullName}${type.firstFlightYear ? ` (first flight ${type.firstFlightYear})` : ''}${type.roleLabel ? `, ${type.roleLabel.toLowerCase()}` : ''}: specs, photos and how to recognise it.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
490
src/routes/trackers/planes/[maker]/[type]/+page.svelte
Normal file
490
src/routes/trackers/planes/[maker]/[type]/+page.svelte
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||||
|
import Gallery from '#lib/cars/Gallery.svelte';
|
||||||
|
import {
|
||||||
|
dexLabel,
|
||||||
|
engineHint,
|
||||||
|
engineLabel,
|
||||||
|
flag,
|
||||||
|
fmtKg,
|
||||||
|
fmtM,
|
||||||
|
fmtNumber,
|
||||||
|
roleEmoji,
|
||||||
|
roleLabel,
|
||||||
|
statusLabel
|
||||||
|
} from '#lib/planes/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.plane);
|
||||||
|
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 aircraft world.
|
||||||
|
const baseStats = $derived(
|
||||||
|
[
|
||||||
|
(p.maxSpeedKmh ?? p.cruiseKmh) != null && {
|
||||||
|
label: 'speed',
|
||||||
|
value: `${fmtNumber((p.maxSpeedKmh ?? p.cruiseKmh)!)} km/h`,
|
||||||
|
hint: p.maxSpeedKmh != null ? 'top speed' : 'cruise speed',
|
||||||
|
score: clamp((p.maxSpeedKmh ?? p.cruiseKmh)! / 3500),
|
||||||
|
hue: 8
|
||||||
|
},
|
||||||
|
p.rangeKm != null && {
|
||||||
|
label: 'range',
|
||||||
|
value: `${fmtNumber(p.rangeKm)} km`,
|
||||||
|
hint: 'without refuelling',
|
||||||
|
score: clamp(p.rangeKm / 20_000),
|
||||||
|
hue: 140
|
||||||
|
},
|
||||||
|
p.passengers != null && {
|
||||||
|
label: 'seats',
|
||||||
|
value: `${fmtNumber(p.passengers)}`,
|
||||||
|
hint: 'passengers',
|
||||||
|
score: clamp(p.passengers / 850),
|
||||||
|
hue: 210
|
||||||
|
},
|
||||||
|
p.mtowKg != null && {
|
||||||
|
label: 'size',
|
||||||
|
value: fmtKg(p.mtowKg),
|
||||||
|
hint: 'max take-off weight',
|
||||||
|
score: log(p.mtowKg, 640_000),
|
||||||
|
hue: 280
|
||||||
|
},
|
||||||
|
p.numberBuilt != null && {
|
||||||
|
label: 'built',
|
||||||
|
value: fmtNumber(p.numberBuilt),
|
||||||
|
hint: 'made in total',
|
||||||
|
score: log(p.numberBuilt, 45_000),
|
||||||
|
hue: 45
|
||||||
|
}
|
||||||
|
].filter((s) => !!s)
|
||||||
|
);
|
||||||
|
|
||||||
|
const engines = $derived(
|
||||||
|
p.engineType === 'none'
|
||||||
|
? 'none (glider)'
|
||||||
|
: [
|
||||||
|
p.engineCount ? `${p.engineCount}×` : '',
|
||||||
|
p.engines.length ? p.engines.join(' / ') : p.engineType ? engineLabel[p.engineType] : ''
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.trim() || null
|
||||||
|
);
|
||||||
|
const facts = $derived(
|
||||||
|
[
|
||||||
|
['first flight', p.firstFlight],
|
||||||
|
['introduced', p.introducedYear],
|
||||||
|
['retired', p.retiredYear],
|
||||||
|
['status', p.status && (statusLabel[p.status] ?? p.status)],
|
||||||
|
['number built', p.numberBuilt && fmtNumber(p.numberBuilt)],
|
||||||
|
['role', p.roleLabel],
|
||||||
|
['origin', p.country && `${flag(p.country)} ${p.country}`],
|
||||||
|
['makers', p.makers.join(', ')],
|
||||||
|
['engines', engines],
|
||||||
|
['crew', p.crew],
|
||||||
|
['passengers', p.passengers],
|
||||||
|
['length', p.lengthM && fmtM(p.lengthM)],
|
||||||
|
['wingspan', p.wingspanM && fmtM(p.wingspanM)],
|
||||||
|
['rotor diameter', p.rotorM && fmtM(p.rotorM)],
|
||||||
|
['height', p.heightM && fmtM(p.heightM)],
|
||||||
|
['max take-off weight', p.mtowKg && fmtKg(p.mtowKg)],
|
||||||
|
['top speed', p.maxSpeedKmh && `${fmtNumber(p.maxSpeedKmh)} km/h`],
|
||||||
|
['cruise speed', p.cruiseKmh && `${fmtNumber(p.cruiseKmh)} km/h`],
|
||||||
|
['range', p.rangeKm && `${fmtNumber(p.rangeKm)} km`],
|
||||||
|
['ceiling', p.ceilingM && `${fmtNumber(p.ceilingM)} m`],
|
||||||
|
['wings', p.wing],
|
||||||
|
['landing gear', p.gear],
|
||||||
|
['ICAO code', p.icao.join(', ')]
|
||||||
|
].filter(
|
||||||
|
([label, value]) =>
|
||||||
|
value ||
|
||||||
|
['first flight', 'number built', 'engines', 'top speed', 'range'].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.firstFlightYear && {
|
||||||
|
icon: '🎂',
|
||||||
|
text: `First flew ${thisYear - p.firstFlightYear} years ago, in ${p.firstFlightYear}.`
|
||||||
|
},
|
||||||
|
p.maxSpeedKmh &&
|
||||||
|
p.maxSpeedKmh >= 200 && {
|
||||||
|
icon: '🏎️',
|
||||||
|
text: `Top speed is ${(p.maxSpeedKmh / 130).toFixed(1)}× a car on the Dutch motorway (130 km/h).`
|
||||||
|
},
|
||||||
|
p.maxSpeedKmh &&
|
||||||
|
p.maxSpeedKmh > 1235 && {
|
||||||
|
icon: '💥',
|
||||||
|
text: `Faster than sound: up to Mach ${(p.maxSpeedKmh / 1235).toFixed(1)} (sea-level speed of sound ≈ 1,235 km/h).`
|
||||||
|
},
|
||||||
|
(p.wingspanM ?? p.rotorM) &&
|
||||||
|
(p.wingspanM ?? p.rotorM)! >= 9 && {
|
||||||
|
icon: '🚗',
|
||||||
|
text: `${p.wingspanM ? 'Wingspan' : 'Rotor'} of ${fmtM((p.wingspanM ?? p.rotorM)!)}: about ${Math.round((p.wingspanM ?? p.rotorM)! / 4.5)} cars parked nose to tail.`
|
||||||
|
},
|
||||||
|
p.mtowKg &&
|
||||||
|
p.mtowKg >= 12_000 && {
|
||||||
|
icon: '🐘',
|
||||||
|
text: `Fully loaded it can weigh as much as ${fmtNumber(Math.round(p.mtowKg / 6000))} African elephants (~6 t each).`
|
||||||
|
},
|
||||||
|
p.rangeKm &&
|
||||||
|
p.rangeKm >= 1000 && {
|
||||||
|
icon: '🌍',
|
||||||
|
text:
|
||||||
|
p.rangeKm >= 5900
|
||||||
|
? `Range of ${fmtNumber(p.rangeKm)} km: Amsterdam to New York (≈5,850 km) without stopping.`
|
||||||
|
: `Range of ${fmtNumber(p.rangeKm)} km: ${(p.rangeKm / 180).toFixed(0)}× Amsterdam to Brussels.`
|
||||||
|
},
|
||||||
|
p.ceilingM &&
|
||||||
|
p.ceilingM > 8849 && {
|
||||||
|
icon: '🏔️',
|
||||||
|
text: `Can fly ${(p.ceilingM / 8849).toFixed(1)}× higher than Mount Everest (8,849 m).`
|
||||||
|
}
|
||||||
|
].filter((f) => !!f)
|
||||||
|
);
|
||||||
|
const typeHref = (x: { makerSlug: string; slug: string }) =>
|
||||||
|
`/trackers/planes/${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/planes" class="back"><ArrowLeft size={15} /> all planes</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/planes/random"
|
||||||
|
class="step"
|
||||||
|
data-sveltekit-preload-data="off"
|
||||||
|
title="Random plane"><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/planes/{data.maker.slug}" aria-label="{data.maker.name} aircraft"
|
||||||
|
><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.icao.length}
|
||||||
|
· ICAO {p.icao.join(', ')}{/if}
|
||||||
|
</p>
|
||||||
|
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">
|
||||||
|
{#if p.fullName.startsWith(data.maker.name)}<a
|
||||||
|
href="/trackers/planes/{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}{flag(p.country)} {p.country} ·
|
||||||
|
{/if}{p.firstFlightYear
|
||||||
|
? `first flew ${p.firstFlightYear}`
|
||||||
|
: 'first flight unknown'}{#if p.roleLabel}
|
||||||
|
· {p.roleLabel}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
{#if p.role}<a href="/trackers/planes?role={encodeURIComponent(p.role)}" class="chip"
|
||||||
|
>{roleEmoji[p.role] ?? ''} {roleLabel[p.role] ?? p.role}</a
|
||||||
|
>{/if}
|
||||||
|
{#if p.engineType}<a
|
||||||
|
href="/trackers/planes?engine={p.engineType}"
|
||||||
|
class="chip"
|
||||||
|
title={engineHint[p.engineType]}
|
||||||
|
>{p.engineCount && p.engineCount > 1 ? `${p.engineCount}× ` : ''}{engineLabel[
|
||||||
|
p.engineType
|
||||||
|
] ?? p.engineType}</a
|
||||||
|
>{/if}
|
||||||
|
{#if p.inService === true}<span class="chip ok">✓ still flying</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 || p.recognize}
|
||||||
|
<section class="card" aria-labelledby="about">
|
||||||
|
<h2 id="about">what is it?</h2>
|
||||||
|
{#if p.summary}<p class="summary">{p.summary}</p>{/if}
|
||||||
|
{#if p.recognize}
|
||||||
|
<p class="recognize">
|
||||||
|
<span aria-hidden="true">🔎</span><span
|
||||||
|
><strong>How to recognise it:</strong> {p.recognize}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</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 aircraft: 3,500 km/h, 20,000 km, 850 seats; size and number built
|
||||||
|
on a log scale up to 640 t (An-225) and 45,000 built (Cessna 172).
|
||||||
|
</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>
|
||||||
|
{#if p.engineType && engineHint[p.engineType]}
|
||||||
|
<p class="note">{engineLabel[p.engineType]}: {engineHint[p.engineType]}</p>
|
||||||
|
{/if}
|
||||||
|
</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">
|
||||||
|
Specs are for the version in the Wikipedia article's specification section. 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.firstFlightYear}<small>{v.firstFlightYear}</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.icao.length}, ICAO code from
|
||||||
|
<a href="https://en.wikipedia.org/wiki/List_of_aircraft_type_designators" class="underline"
|
||||||
|
>list of aircraft type designators</a
|
||||||
|
>{/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;
|
||||||
|
}
|
||||||
|
.recognize {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
border: 1px dashed var(--site-border);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.related small {
|
||||||
|
color: var(--site-muted);
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
src/routes/trackers/planes/makers/+page.server.ts
Normal file
20
src/routes/trackers/planes/makers/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { planeOverview } from '#lib/server/planes.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const { byBuilt, stats } = await planeOverview();
|
||||||
|
return {
|
||||||
|
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
types,
|
||||||
|
built,
|
||||||
|
logo
|
||||||
|
})),
|
||||||
|
stats,
|
||||||
|
seo: {
|
||||||
|
title: 'Plane makers',
|
||||||
|
description: `All ${stats.makers} aircraft makers in the plane pokedex with their logos, most aircraft built first.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
49
src/routes/trackers/planes/makers/+page.svelte
Normal file
49
src/routes/trackers/planes/makers/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MakerTile from '#lib/planes/MakerTile.svelte';
|
||||||
|
import { LOGO_NOTE, fmtNumber } from '#lib/planes/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/planes" class="back"><ArrowLeft size={15} /> all planes</a>
|
||||||
|
<header class="mt-6 mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">makers</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
All {fmtNumber(data.makers.length)} makers in the plane pokedex, the ones that built the most aircraft
|
||||||
|
first. Tap one for its aircraft.
|
||||||
|
</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} makers have a logo; the rest get a coloured monogram.
|
||||||
|
{LOGO_NOTE} Each maker 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>
|
||||||
9
src/routes/trackers/planes/random/+server.ts
Normal file
9
src/routes/trackers/planes/random/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { randomPlane } from '#lib/server/planes.js';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ setHeaders }) => {
|
||||||
|
const plane = await randomPlane();
|
||||||
|
setHeaders({ 'cache-control': 'no-store' });
|
||||||
|
redirect(303, plane ? `/trackers/planes/${plane.makerSlug}/${plane.slug}` : '/trackers/planes');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user