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
All checks were successful
Deploy production / deploy (push) Successful in 2m24s
This commit is contained in:
@@ -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>
|
||||
|
||||
71
src/lib/cars/BrandTile.svelte
Normal file
71
src/lib/cars/BrandTile.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user