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,
|
||||
|
||||
@@ -15,6 +15,7 @@ const staticPaths = [
|
||||
'/lists',
|
||||
'/trackers/books',
|
||||
'/trackers/cars',
|
||||
'/trackers/cars/brands',
|
||||
'/trackers/music',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
src/routes/trackers/cars/brands/+page.server.ts
Normal file
20
src/routes/trackers/cars/brands/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
49
src/routes/trackers/cars/brands/+page.svelte
Normal file
49
src/routes/trackers/cars/brands/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user