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,