cars: Wikimedia Commons photos (cars.photos, card heroes, model gallery + lightbox, brand hero/logo), cars:photos + cars:refresh with --report-new, fold duplicate names (Golf VI, Peugot, Mustang Mach 1, Model 3/Y, i10/i30, Ceed, Lynk & Co)
All checks were successful
Deploy production / deploy (push) Successful in 3m0s
All checks were successful
Deploy production / deploy (push) Successful in 3m0s
This commit is contained in:
@@ -6,18 +6,40 @@
|
||||
dexLabel,
|
||||
fmtCompact,
|
||||
fmtEuro,
|
||||
brandHue,
|
||||
kwToHp,
|
||||
monogram,
|
||||
thumbAt,
|
||||
thumbSrcset,
|
||||
years,
|
||||
type CarCard
|
||||
} from './shared.js';
|
||||
|
||||
let { car, current }: { car: CarCard; current: number } = $props();
|
||||
let {
|
||||
car,
|
||||
current,
|
||||
eager = false
|
||||
}: { car: CarCard; current: number; eager?: boolean } = $props();
|
||||
const href = $derived(`/trackers/cars/${car.brandSlug}/${car.slug}`);
|
||||
</script>
|
||||
|
||||
<a {href} class="card">
|
||||
<div class="top">
|
||||
<BrandBadge name={car.brandName} />
|
||||
<div class="media" style:--hue={brandHue(car.brandName)}>
|
||||
{#if car.photo}
|
||||
<img
|
||||
src={thumbAt(car.photo, 330)}
|
||||
srcset={thumbSrcset(car.photo)}
|
||||
sizes="(min-width: 1024px) 270px, (min-width: 640px) 45vw, 100vw"
|
||||
alt="{car.brandName} {car.name}"
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
width="330"
|
||||
height="206"
|
||||
/>
|
||||
{:else}
|
||||
<span class="placeholder" aria-hidden="true">{monogram(car.brandName)}</span>
|
||||
{/if}
|
||||
<span class="badge"><BrandBadge name={car.brandName} size="sm" /></span>
|
||||
<span class="dex">{dexLabel(car.dex)}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -68,15 +90,49 @@
|
||||
border-color: var(--site-muted);
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
.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 {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
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);
|
||||
}
|
||||
.brand {
|
||||
font-size: 0.75rem;
|
||||
|
||||
268
src/lib/cars/Gallery.svelte
Normal file
268
src/lib/cars/Gallery.svelte
Normal file
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import { commonsPage, thumbAt, thumbSrcset, type CarPhoto } from './shared.js';
|
||||
|
||||
let {
|
||||
photos,
|
||||
name,
|
||||
wikipedia = null
|
||||
}: { photos: CarPhoto[]; name: string; wikipedia?: string | null } = $props();
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let index = $state(0);
|
||||
const current = $derived(photos[index]);
|
||||
const large = (p: CarPhoto) => thumbAt(p.thumb, p.width > 1280 ? 1280 : 960);
|
||||
const alt = (p: CarPhoto, i: number) =>
|
||||
`${p.caption ?? name}, photo ${i + 1} of ${photos.length}`;
|
||||
|
||||
// Links go to the Commons file page; with JS they open the lightbox instead.
|
||||
function open(event: MouseEvent, i: number) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
index = i;
|
||||
dialog?.showModal();
|
||||
}
|
||||
const step = (d: number) => (index = (index + d + photos.length) % photos.length);
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight') step(1);
|
||||
else if (event.key === 'ArrowLeft') step(-1);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
// Click on the backdrop (outside the figure) closes.
|
||||
function onclick(event: MouseEvent) {
|
||||
if (event.target === dialog) dialog?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet credit(p: CarPhoto)}
|
||||
<a href={commonsPage(p.file)} rel="noopener">{p.author ?? 'Wikimedia Commons'}</a>{#if p.license}
|
||||
· {#if p.licenseUrl}<a href={p.licenseUrl} rel="noopener license">{p.license}</a
|
||||
>{:else}{p.license}{/if}{/if}
|
||||
{/snippet}
|
||||
|
||||
<section class="gallery" aria-label="Photos of {name}">
|
||||
<ul class:single={photos.length === 1}>
|
||||
{#each photos as p, i (p.file)}
|
||||
<li class:lead={i === 0}>
|
||||
<a href={commonsPage(p.file)} onclick={(e) => open(e, i)} title={p.caption ?? undefined}>
|
||||
<img
|
||||
src={thumbAt(p.thumb, i === 0 ? 960 : 330)}
|
||||
srcset={i === 0
|
||||
? thumbSrcset(p.thumb, [500, 960, 1280])
|
||||
: thumbSrcset(p.thumb, [330, 500])}
|
||||
sizes={i === 0 ? '(min-width: 1152px) 680px, 100vw' : '(min-width: 1152px) 220px, 50vw'}
|
||||
alt={alt(p, i)}
|
||||
loading={i === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={i === 0 ? 'high' : undefined}
|
||||
decoding="async"
|
||||
width={p.width}
|
||||
height={p.height}
|
||||
/>
|
||||
{#if p.caption}<span class="cap">{p.caption}</span>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<details class="credits">
|
||||
<summary>
|
||||
{photos.length} photo{photos.length === 1 ? '' : 's'} from Wikimedia Commons{#if wikipedia}
|
||||
· <a href="https://en.wikipedia.org/wiki/{wikipedia.replace(/ /g, '_')}" rel="noopener"
|
||||
>Wikipedia</a
|
||||
>{/if} · credits
|
||||
</summary>
|
||||
<ol>
|
||||
{#each photos as p, i (p.file)}
|
||||
<li>{i + 1}. {p.caption ?? p.file.replace(/\.\w+$/, '')}: {@render credit(p)}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<dialog bind:this={dialog} {onkeydown} {onclick} aria-label="{name} photos">
|
||||
{#if current}
|
||||
<figure>
|
||||
{#key index}
|
||||
<img src={large(current)} alt={alt(current, index)} />
|
||||
{/key}
|
||||
<figcaption>
|
||||
<span
|
||||
>{index + 1}/{photos.length}{#if current.caption}
|
||||
· {current.caption}{/if}</span
|
||||
>
|
||||
<span class="credit">{@render credit(current)}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
{#if photos.length > 1}
|
||||
<button class="nav prev" onclick={() => step(-1)} aria-label="Previous photo"
|
||||
><ChevronLeft size={28} /></button
|
||||
>
|
||||
<button class="nav next" onclick={() => step(1)} aria-label="Next photo"
|
||||
><ChevronRight size={28} /></button
|
||||
>
|
||||
{/if}
|
||||
<button class="close" onclick={() => dialog?.close()} aria-label="Close" autofocus
|
||||
><X size={22} /></button
|
||||
>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.gallery ul {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.gallery ul {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.gallery li.lead {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.gallery ul.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.gallery li.lead {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.gallery li.lead {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
.gallery a {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.gallery li.lead a {
|
||||
aspect-ratio: auto;
|
||||
min-height: 14rem;
|
||||
}
|
||||
.gallery img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.gallery a:hover img,
|
||||
.gallery a:focus-visible img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.cap {
|
||||
position: absolute;
|
||||
left: 0.4rem;
|
||||
bottom: 0.4rem;
|
||||
max-width: calc(100% - 0.8rem);
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.credits {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.credits summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.credits ol {
|
||||
margin-top: 0.4rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.credits a,
|
||||
figcaption a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
dialog {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgb(0 0 0 / 0.92);
|
||||
color: #fff;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.6);
|
||||
}
|
||||
figure {
|
||||
position: absolute;
|
||||
inset: 3.5rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
figure img {
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
flex: 0 1 auto;
|
||||
object-fit: contain;
|
||||
border-radius: 0.4rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
figcaption {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.3rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(255 255 255 / 0.8);
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
}
|
||||
.nav,
|
||||
.close {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.nav:hover,
|
||||
.close:hover,
|
||||
.nav:focus-visible,
|
||||
.close:focus-visible {
|
||||
background: rgb(255 255 255 / 0.25);
|
||||
}
|
||||
.nav {
|
||||
top: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.prev {
|
||||
left: 0.75rem;
|
||||
}
|
||||
.next {
|
||||
right: 0.75rem;
|
||||
}
|
||||
.close {
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -164,4 +164,25 @@ export type CarCard = {
|
||||
nlCount: number;
|
||||
nlPriceLatest: number | null;
|
||||
powerKwMax: number | null;
|
||||
photo: string | null;
|
||||
};
|
||||
|
||||
/** Same Wikimedia thumbnail at another standard width (120/250/330/500/960/1280). */
|
||||
export const thumbAt = (url: string, width: number) =>
|
||||
url.replace(/\/\d+px-([^/]+)$/, `/${width}px-$1`);
|
||||
export const thumbSrcset = (url: string, widths = [330, 500, 960]) =>
|
||||
widths.map((w) => `${thumbAt(url, w)} ${w}w`).join(', ');
|
||||
export const commonsPage = (file: string) =>
|
||||
`https://commons.wikimedia.org/wiki/File:${encodeURIComponent(file.replace(/ /g, '_'))}`;
|
||||
|
||||
export type CarPhoto = {
|
||||
file: string;
|
||||
kind: string;
|
||||
caption: string | null;
|
||||
thumb: string;
|
||||
width: number;
|
||||
height: number;
|
||||
author: string | null;
|
||||
license: string | null;
|
||||
licenseUrl: string | null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Car pokedex queries (/trackers/cars). Public, read-only; data is written only by
|
||||
// scripts/cars-import.ts.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { carBrands, carModels, carVariants } from '#lib/server/db/schema/index.js';
|
||||
import { carBrands, carModels, carPhotos, carVariants } from '#lib/server/db/schema/index.js';
|
||||
import { and, arrayContains, asc, desc, eq, gt, gte, ilike, lte, sql, type SQL } from 'drizzle-orm';
|
||||
import { BODIES, DRIVES, FUELS, SORTS, type CarCard, type CarSort } from '#lib/cars/shared.js';
|
||||
|
||||
@@ -55,7 +55,8 @@ const cardColumns = {
|
||||
bodies: carModels.bodies,
|
||||
nlCount: carModels.nlCount,
|
||||
nlPriceLatest: carModels.nlPriceLatest,
|
||||
powerKwMax: carModels.powerKwMax
|
||||
powerKwMax: carModels.powerKwMax,
|
||||
photo: carModels.photo
|
||||
};
|
||||
|
||||
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
@@ -145,7 +146,21 @@ export async function getBrand(slug: string) {
|
||||
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
|
||||
.where(eq(carModels.brandId, brand.id))
|
||||
.orderBy(desc(carModels.nlCount), desc(carModels.variantCount), asc(carModels.name));
|
||||
return { brand, models };
|
||||
const [hero] = brand.photo
|
||||
? await db
|
||||
.select({
|
||||
file: carPhotos.file,
|
||||
author: carPhotos.author,
|
||||
license: carPhotos.license,
|
||||
licenseUrl: carPhotos.licenseUrl,
|
||||
model: carModels.name
|
||||
})
|
||||
.from(carPhotos)
|
||||
.innerJoin(carModels, eq(carModels.id, carPhotos.modelId))
|
||||
.where(and(eq(carModels.brandId, brand.id), eq(carPhotos.thumb, brand.photo)))
|
||||
.limit(1)
|
||||
: [];
|
||||
return { brand, models, hero: hero ?? null };
|
||||
}
|
||||
|
||||
export async function getModel(brandSlug: string, modelSlug: string) {
|
||||
@@ -156,7 +171,7 @@ export async function getModel(brandSlug: string, modelSlug: string) {
|
||||
.where(and(eq(carBrands.slug, brandSlug), eq(carModels.slug, modelSlug)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const [variants, neighbours] = await Promise.all([
|
||||
const [variants, neighbours, photos] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(carVariants)
|
||||
@@ -175,11 +190,27 @@ export async function getModel(brandSlug: string, modelSlug: string) {
|
||||
sql`${carModels.dex} in (
|
||||
(select max(dex) from cars.models where dex < ${row.model.dex}),
|
||||
(select min(dex) from cars.models where dex > ${row.model.dex}))`
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
file: carPhotos.file,
|
||||
kind: carPhotos.kind,
|
||||
caption: carPhotos.caption,
|
||||
thumb: carPhotos.thumb,
|
||||
width: carPhotos.width,
|
||||
height: carPhotos.height,
|
||||
author: carPhotos.author,
|
||||
license: carPhotos.license,
|
||||
licenseUrl: carPhotos.licenseUrl
|
||||
})
|
||||
.from(carPhotos)
|
||||
.where(eq(carPhotos.modelId, row.model.id))
|
||||
.orderBy(asc(carPhotos.position))
|
||||
]);
|
||||
return {
|
||||
...row,
|
||||
variants,
|
||||
photos,
|
||||
prev: neighbours.find((n) => n.dex < row.model.dex) ?? null,
|
||||
next: neighbours.find((n) => n.dex > row.model.dex) ?? null
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
real,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { carsSchema } from './schemas';
|
||||
@@ -38,6 +39,16 @@ export const carBrands = carsSchema.table(
|
||||
firstYear: smallint('first_year'),
|
||||
lastYear: smallint('last_year'),
|
||||
sources: text('sources').array().notNull().default([]),
|
||||
/** Wikidata item of the brand/manufacturer and its enwiki article (scripts/cars-photos.ts). */
|
||||
wikidata: text('wikidata'),
|
||||
wikipedia: text('wikipedia'),
|
||||
/** Logo thumbnail (Wikimedia Commons, 250px) and its credit. */
|
||||
logo: text('logo'),
|
||||
logoFile: text('logo_file'),
|
||||
logoAuthor: text('logo_author'),
|
||||
logoLicense: text('logo_license'),
|
||||
/** Hero photo: lead photo of the brand's most common model. */
|
||||
photo: text('photo'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [uniqueIndex('brands_slug_key').on(t.slug)]
|
||||
@@ -96,6 +107,13 @@ export const carModels = carsSchema.table(
|
||||
usYears: smallint('us_years').array().notNull().default([]),
|
||||
variantCount: integer('variant_count').notNull().default(0),
|
||||
sources: text('sources').array().notNull().default([]),
|
||||
/** Matched Wikidata item / enwiki article (scripts/cars-photos.ts); null = no confident match. */
|
||||
wikidata: text('wikidata'),
|
||||
wikipedia: text('wikipedia'),
|
||||
/** Lead photo thumbnail URL (960px), denormalised from cars.photos for the card grid. */
|
||||
photo: text('photo'),
|
||||
photoCount: smallint('photo_count').notNull().default(0),
|
||||
photosCheckedAt: timestamp('photos_checked_at', { withTimezone: true }),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
@@ -135,3 +153,28 @@ export const carVariants = carsSchema.table(
|
||||
},
|
||||
(t) => [index('variants_model_year_idx').on(t.modelId, t.year)]
|
||||
);
|
||||
|
||||
/** Freely licensed photos from Wikimedia Commons; only thumbnail URLs are stored, nothing is downloaded. */
|
||||
export const carPhotos = carsSchema.table(
|
||||
'photos',
|
||||
{
|
||||
id: id(),
|
||||
modelId: integer('model_id')
|
||||
.notNull()
|
||||
.references(() => carModels.id, { onDelete: 'cascade' }),
|
||||
position: smallint('position').notNull(),
|
||||
/** lead (Wikidata image), generation, article (enwiki), commons (Commons category). */
|
||||
kind: text('kind').notNull(),
|
||||
/** Commons file name without the "File:" prefix. */
|
||||
file: text('file').notNull(),
|
||||
caption: text('caption'),
|
||||
/** 960px thumbnail on upload.wikimedia.org; other standard widths are derived from it. */
|
||||
thumb: text('thumb').notNull(),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height').notNull(),
|
||||
author: text('author'),
|
||||
license: text('license'),
|
||||
licenseUrl: text('license_url')
|
||||
},
|
||||
(t) => [uniqueIndex('photos_model_position_key').on(t.modelId, t.position)]
|
||||
);
|
||||
|
||||
@@ -187,8 +187,8 @@
|
||||
|
||||
{#if data.cars.length}
|
||||
<ul class="grid">
|
||||
{#each data.cars as car (car.dex)}
|
||||
<li><CarCard {car} current={data.currentYear} /></li>
|
||||
{#each data.cars as car, i (car.dex)}
|
||||
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getBrand(params.brand);
|
||||
if (!found) error(404, 'No such brand in the car pokedex');
|
||||
const { brand, models } = found;
|
||||
const { brand, models, hero } = found;
|
||||
return {
|
||||
brand: {
|
||||
slug: brand.slug,
|
||||
@@ -15,8 +15,15 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
nlCount: brand.nlCount,
|
||||
firstYear: brand.firstYear,
|
||||
lastYear: brand.lastYear,
|
||||
sources: brand.sources
|
||||
sources: brand.sources,
|
||||
wikipedia: brand.wikipedia,
|
||||
logo: brand.logo,
|
||||
logoFile: brand.logoFile,
|
||||
logoAuthor: brand.logoAuthor,
|
||||
logoLicense: brand.logoLicense,
|
||||
photo: brand.photo
|
||||
},
|
||||
hero,
|
||||
models,
|
||||
currentYear: new Date().getFullYear(),
|
||||
seo: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||
import CarCard from '#lib/cars/CarCard.svelte';
|
||||
import { flag, fmtNumber, years } from '#lib/cars/shared.js';
|
||||
import { commonsPage, flag, fmtNumber, thumbAt, thumbSrcset, years } from '#lib/cars/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -11,8 +11,46 @@
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a href="/trackers/cars" class="back"><ArrowLeft size={15} /> all cars</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?.model ?? ''}"
|
||||
fetchpriority="high"
|
||||
width="1280"
|
||||
height="480"
|
||||
/>
|
||||
{#if data.hero}
|
||||
<figcaption>
|
||||
{b.name}
|
||||
{data.hero.model} ·
|
||||
<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">
|
||||
<BrandBadge name={b.name} size="lg" />
|
||||
{#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>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
@@ -25,12 +63,18 @@
|
||||
</header>
|
||||
|
||||
<ul class="grid">
|
||||
{#each data.models as car (car.dex)}
|
||||
<li><CarCard {car} current={data.currentYear} /></li>
|
||||
{#each data.models as car, i (car.dex)}
|
||||
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
Sorted by how many are registered in NL. Sources: {b.sources.join(', ')}.
|
||||
Sorted by how many are registered in NL. 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +89,46 @@
|
||||
.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;
|
||||
|
||||
@@ -14,6 +14,7 @@ function publicCar(m: Model) {
|
||||
delete out.search;
|
||||
delete out.createdAt;
|
||||
delete out.updatedAt;
|
||||
delete out.photosCheckedAt;
|
||||
return {
|
||||
...(out as Omit<Model, 'id' | 'brandId' | 'search' | 'createdAt' | 'updatedAt'>),
|
||||
updatedAt: m.updatedAt?.toISOString() ?? null
|
||||
@@ -30,11 +31,12 @@ function publicVariant(v: Variant) {
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getModel(params.brand, params.model);
|
||||
if (!found) error(404, 'No such car in the pokedex');
|
||||
const { model, brand, variants, prev, next } = found;
|
||||
const { model, brand, variants, photos, prev, next } = found;
|
||||
return {
|
||||
car: publicCar(model),
|
||||
brand: { slug: brand.slug, name: brand.name, country: brand.country },
|
||||
variants: variants.map(publicVariant),
|
||||
photos,
|
||||
prev,
|
||||
next,
|
||||
currentYear: new Date().getFullYear(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import BrandBadge from '#lib/cars/BrandBadge.svelte';
|
||||
import FuelChip from '#lib/cars/FuelChip.svelte';
|
||||
import Gallery from '#lib/cars/Gallery.svelte';
|
||||
import {
|
||||
bodyLabel,
|
||||
dexLabel,
|
||||
@@ -174,6 +175,10 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if data.photos.length}
|
||||
<Gallery photos={data.photos} name="{data.brand.name} {car.name}" wikipedia={car.wikipedia} />
|
||||
{/if}
|
||||
|
||||
{#if baseStats.length}
|
||||
<section class="card" aria-labelledby="stats">
|
||||
<h2 id="stats">base stats</h2>
|
||||
|
||||
Reference in New Issue
Block a user