cars: brand logos everywhere (search cards, brand grid, model headers), stricter logo matching + curated fixes, logo refresh in cars-photos
All checks were successful
Deploy production / deploy (push) Successful in 2m24s

This commit is contained in:
2026-10-24 17:37:43 +00:00
parent fac49b57ba
commit 8442d59b68
14 changed files with 664 additions and 42 deletions

View File

@@ -1,17 +1,34 @@
<script lang="ts">
import { brandHue, monogram } from './shared.js';
import { brandHue, monogram, thumbAt } from './shared.js';
let { name, size = 'md' }: { name: string; size?: 'sm' | 'md' | 'lg' } = $props();
let {
name,
logo = null,
size = 'md'
}: { name: string; logo?: string | null; size?: 'sm' | 'md' | 'lg' | 'xl' } = $props();
const hue = $derived(brandHue(name));
// Logos sit on a light tile so dark wordmarks stay readable on the dark theme; a logo
// that fails to load falls back to the brand-coloured monogram.
let failed = $state(false);
const src = $derived(logo ? thumbAt(logo, size === 'sm' || size === 'md' ? 120 : 250) : null);
</script>
<span class={['badge', size]} style:--hue={hue} aria-hidden="true">{monogram(name)}</span>
{#if src && !failed}
<span class={['tile', size]} aria-hidden="true">
<img {src} alt="" loading="lazy" decoding="async" onerror={() => (failed = true)} />
</span>
{:else}
<span class={['badge', size]} style:--hue={hue} aria-hidden="true">{monogram(name)}</span>
{/if}
<style>
.badge {
.badge,
.tile {
display: inline-grid;
place-items: center;
flex-shrink: 0;
}
.badge {
border-radius: 999px;
font-weight: 700;
letter-spacing: 0.02em;
@@ -25,11 +42,28 @@
inset 0 0 0 2px hsl(var(--hue) 60% 55% / 0.45),
0 2px 8px hsl(var(--hue) 60% 20% / 0.4);
}
.tile {
border-radius: 0.55rem;
background: #fff;
padding: 0.2rem;
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.08),
0 2px 8px rgb(0 0 0 / 0.35);
}
.tile img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sm {
width: 1.9rem;
height: 1.9rem;
font-size: 0.7rem;
}
.tile.sm {
width: 2.6rem;
padding: 0.18rem 0.25rem;
}
.md {
width: 2.6rem;
height: 2.6rem;
@@ -40,4 +74,21 @@
height: 4.5rem;
font-size: 1.4rem;
}
.tile.lg {
border-radius: 1rem;
padding: 0.5rem;
}
.xl {
width: 100%;
height: 100%;
font-size: 1.6rem;
}
.badge.xl {
width: 3.6rem;
height: 3.6rem;
}
.tile.xl {
border-radius: 0.6rem;
padding: 0.6rem 0.8rem;
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import BrandBadge from './BrandBadge.svelte';
import { fmtCompact, type BrandTileData } from './shared.js';
let { brand, compact = false }: { brand: BrandTileData; compact?: boolean } = $props();
</script>
<a href="/trackers/cars/{brand.slug}" class={['tile', { compact }]} title={brand.name}>
<span class="mark"><BrandBadge name={brand.name} logo={brand.logo} size="xl" /></span>
<span class="name">{brand.name}</span>
{#if !compact}
<span class="meta"
>{brand.models}
{brand.models === 1 ? 'model' : 'models'}{#if brand.nlCount}
· 🇳🇱 {fmtCompact(brand.nlCount)}{/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>

View File

@@ -39,7 +39,7 @@
{:else}
<span class="placeholder" aria-hidden="true">{monogram(car.brandName)}</span>
{/if}
<span class="badge"><BrandBadge name={car.brandName} size="sm" /></span>
<span class="badge"><BrandBadge name={car.brandName} logo={car.brandLogo} size="sm" /></span>
<span class="dex">{dexLabel(car.dex)}</span>
</div>
<div class="min-w-0">

View File

@@ -157,6 +157,7 @@ export type CarCard = {
name: string;
brandSlug: string;
brandName: string;
brandLogo: string | null;
firstYear: number | null;
lastYear: number | null;
fuels: string[];
@@ -186,3 +187,15 @@ export type CarPhoto = {
license: string | null;
licenseUrl: string | null;
};
/** A brand in the logo grid (/trackers/cars/brands and the strip on /trackers/cars). */
export type BrandTileData = {
slug: string;
name: string;
models: number;
nlCount: number;
logo: string | null;
};
export const LOGO_NOTE =
'Brand 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 brand.';

View File

@@ -49,6 +49,7 @@ const cardColumns = {
name: carModels.name,
brandSlug: carBrands.slug,
brandName: carBrands.name,
brandLogo: carBrands.logo,
firstYear: carModels.firstYear,
lastYear: carModels.lastYear,
fuels: carModels.fuels,
@@ -102,8 +103,18 @@ export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; tota
return { cars, total };
}
export type BrandTile = {
slug: string;
name: string;
models: number;
nlCount: number;
country: string | null;
logo: string | null;
};
type Overview = {
brands: { slug: string; name: string; models: number }[];
/** Alphabetical (for the filter); `byNl` has the same brands, most common in NL first. */
brands: BrandTile[];
byNl: BrandTile[];
stats: { brands: number; models: number; variants: number; nlCars: number };
};
let overviewCache: { at: number; value: Overview } | null = null;
@@ -113,7 +124,14 @@ export async function carOverview(): Promise<Overview> {
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
const [brands, [counts]] = await Promise.all([
db
.select({ slug: carBrands.slug, name: carBrands.name, models: carBrands.modelCount })
.select({
slug: carBrands.slug,
name: carBrands.name,
models: carBrands.modelCount,
nlCount: carBrands.nlCount,
country: carBrands.country,
logo: carBrands.logo
})
.from(carBrands)
.orderBy(asc(carBrands.name)),
db
@@ -126,6 +144,9 @@ export async function carOverview(): Promise<Overview> {
]);
const value = {
brands,
byNl: [...brands].sort(
(a, b) => b.nlCount - a.nlCount || b.models - a.models || a.name.localeCompare(b.name)
),
stats: {
brands: brands.length,
models: counts.models,

View File

@@ -15,6 +15,7 @@ const staticPaths = [
'/lists',
'/trackers/books',
'/trackers/cars',
'/trackers/cars/brands',
'/trackers/music',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',

View File

@@ -9,7 +9,10 @@ export const load: PageServerLoad = async ({ url }) => {
cars,
total,
pageSize: PAGE_SIZE,
brands: overview.brands,
brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
topBrands: overview.byNl
.slice(0, 16)
.map(({ slug, name, models, nlCount, logo }) => ({ slug, name, models, nlCount, logo })),
stats: overview.stats,
currentYear: new Date().getFullYear(),
seo: {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import BrandTile from '#lib/cars/BrandTile.svelte';
import CarCard from '#lib/cars/CarCard.svelte';
import {
BODIES,
@@ -10,6 +11,7 @@
fuelLabel,
fmtCompact,
fmtNumber,
LOGO_NOTE,
SORTS
} from '#lib/cars/shared.js';
import { Input } from '#lib/components/ui/input/index.js';
@@ -92,6 +94,22 @@
</div>
</dl>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="brands-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 id="brands-heading" class="text-lg font-semibold">brands</h2>
<a href="/trackers/cars/brands" class="text-sm text-[var(--site-muted)] hover:underline"
>all {fmtNumber(data.stats.brands)} brands →</a
>
</div>
<ul class="brands">
{#each data.topBrands as brand (brand.slug)}
<li><BrandTile {brand} compact /></li>
{/each}
</ul>
</section>
{/if}
<form
bind:this={form}
method="GET"
@@ -213,11 +231,17 @@
register, CC0) and
<a href="https://www.fueleconomy.gov/feg/download.shtml" class="underline">fueleconomy.gov</a>
(US EPA/DOE). Models are grouped from registered trade names, so a few odd splits remain. Prices are
average catalogue prices of new cars when first registered in NL. Blank means unknown.
average catalogue prices of new cars when first registered in NL. Blank means unknown. Photos from
Wikimedia Commons, credited on each car'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));

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import CarCard from '#lib/cars/CarCard.svelte';
import { commonsPage, flag, fmtNumber, thumbAt, thumbSrcset, years } from '#lib/cars/shared.js';
import {
commonsPage,
flag,
fmtNumber,
LOGO_NOTE,
thumbAt,
thumbSrcset,
years
} from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
@@ -74,7 +82,9 @@
>{/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.logoLicense}, {b.logoLicense}{/if}{/if}. Photos from Wikimedia Commons.{#if b.logo}
{LOGO_NOTE}{/if}
<a href="/trackers/cars/brands" class="underline">All brands</a>.
</p>
</div>

View File

@@ -34,7 +34,7 @@ export const load: PageServerLoad = async ({ params }) => {
const { model, brand, variants, photos, prev, next } = found;
return {
car: publicCar(model),
brand: { slug: brand.slug, name: brand.name, country: brand.country },
brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
variants: variants.map(publicVariant),
photos,
prev,

View File

@@ -152,7 +152,7 @@
<header class="hero">
<div class="flex items-center gap-4">
<a href="/trackers/cars/{data.brand.slug}" aria-label="{data.brand.name} models"
><BrandBadge name={data.brand.name} size="lg" /></a
><BrandBadge name={data.brand.name} logo={data.brand.logo} size="lg" /></a
>
<div class="min-w-0">
<p class="dex">{dexLabel(car.dex)}</p>

View File

@@ -0,0 +1,20 @@
import { carOverview } from '#lib/server/cars.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byNl, stats } = await carOverview();
return {
brands: byNl.map(({ slug, name, models, nlCount, logo }) => ({
slug,
name,
models,
nlCount,
logo
})),
stats,
seo: {
title: 'Car brands',
description: `All ${stats.brands} car brands in the pokedex with their logos, most common on Dutch roads first.`
}
};
};

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import BrandTile from '#lib/cars/BrandTile.svelte';
import { LOGO_NOTE, fmtNumber } from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const withLogo = $derived(data.brands.filter((b) => b.logo).length);
</script>
<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>
<header class="mt-6 mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">brands</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
All {fmtNumber(data.brands.length)} brands in the pokedex, the ones you see most on Dutch roads
first. Tap one for its models.
</p>
</header>
<ul class="grid">
{#each data.brands as brand (brand.slug)}
<li><BrandTile {brand} /></li>
{/each}
</ul>
<p class="mt-10 text-xs leading-relaxed text-[var(--site-muted)]">
{withLogo} of {data.brands.length} brands have a logo; the rest get a coloured monogram.
{LOGO_NOTE} Each brand 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>