refactor: serve project images through Vite's asset pipeline

Moves public/images/ → src/assets/projects/<slug>/ and switches
projects.ts to resolve image URLs via import.meta.glob with the
?url query. Vite content-hashes every file (e.g. 08-DzZjAiN9.jpg);
the HomeView hero img also imports through the pipeline.

Why: the deploy serves /assets/* with `cache-control: public,
immutable, max-age=31536000`. That's correct for content-hashed
files but was being applied to /images/* too, so swapping an image
in place left every cached visitor stuck on the old version for a
year. Hashed filenames means every byte-level change produces a
new URL, browsers fetch fresh automatically, and the immutable
cache stays correct.

The img(slug, file) helper throws at build time if a referenced
asset is missing — typos surface immediately instead of producing
a broken <img> at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-27 20:54:16 +02:00
commit d18319d593
19 changed files with 39 additions and 20 deletions

View file

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 526 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 704 KiB

After

Width:  |  Height:  |  Size: 704 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 497 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 725 KiB

After

Width:  |  Height:  |  Size: 725 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 483 KiB

After

Width:  |  Height:  |  Size: 483 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 662 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 616 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 463 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 712 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Before After
Before After

View file

@ -18,6 +18,24 @@ export interface Project {
images: ProjectImage[] images: ProjectImage[]
} }
// Eager-glob every project image through Vite's asset pipeline so each
// file gets a content-hashed URL (e.g. /assets/08-abc123.jpg). Swapping
// any image's bytes changes its hash, which busts the immutable cache
// the deploy sets on /assets/*. Keys are paths relative to this file.
const assetMap = import.meta.glob<string>(
'../assets/projects/**/*.jpg',
{ eager: true, query: '?url', import: 'default' },
)
function img(slug: string, file: string): string {
const key = `../assets/projects/${slug}/${file}`
const url = assetMap[key]
if (!url) {
throw new Error(`Missing project asset: ${key} — check src/assets/projects/`)
}
return url
}
export const boulder: Project = { export const boulder: Project = {
slug: 'boulder', slug: 'boulder',
name: 'Boulder', name: 'Boulder',
@ -26,77 +44,77 @@ export const boulder: Project = {
'A mid-century ranch reimagined around warm woods, reclaimed barnwood walls, ' + 'A mid-century ranch reimagined around warm woods, reclaimed barnwood walls, ' +
'live-edge counters, and emerald glazed tile. The palette stays in conversation ' + 'live-edge counters, and emerald glazed tile. The palette stays in conversation ' +
'with the trees outside — sunlight does most of the work.', 'with the trees outside — sunlight does most of the work.',
cover: '/images/boulder/05.jpg', cover: img('boulder', '05.jpg'),
coverAlt: 'Walnut kitchen with white embossed tile and warm pendant light', coverAlt: 'Walnut kitchen with white embossed tile and warm pendant light',
images: [ images: [
{ {
src: '/images/boulder/03.jpg', src: img('boulder', '03.jpg'),
alt: 'Kitchen with reclaimed barnwood walls and live-edge bar top', alt: 'Kitchen with reclaimed barnwood walls and live-edge bar top',
orientation: 'landscape', orientation: 'landscape',
feature: 'hero', feature: 'hero',
}, },
{ {
src: '/images/boulder/01.jpg', src: img('boulder', '01.jpg'),
alt: 'Oil-rubbed bronze faucet over a quartz counter, reclaimed wood backsplash', alt: 'Oil-rubbed bronze faucet over a quartz counter, reclaimed wood backsplash',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',
}, },
{ {
src: '/images/boulder/07.jpg', src: img('boulder', '07.jpg'),
alt: 'Kitchen alternate angle showing live-edge counter and Asian carving', alt: 'Kitchen alternate angle showing live-edge counter and Asian carving',
orientation: 'landscape', orientation: 'landscape',
feature: 'wide', feature: 'wide',
}, },
{ {
src: '/images/boulder/02.jpg', src: img('boulder', '02.jpg'),
alt: 'Dining room with reclaimed barnwood feature wall and rush-seat chairs', alt: 'Dining room with reclaimed barnwood feature wall and rush-seat chairs',
orientation: 'landscape', orientation: 'landscape',
feature: 'wide', feature: 'wide',
}, },
{ {
src: '/images/boulder/08.jpg', src: img('boulder', '08.jpg'),
alt: 'Living room with mid-century sofa and emerald tile fireplace surround', alt: 'Living room with mid-century sofa and emerald tile fireplace surround',
orientation: 'landscape', orientation: 'landscape',
feature: 'wide', feature: 'wide',
}, },
{ {
src: '/images/boulder/05.jpg', src: img('boulder', '05.jpg'),
alt: 'Walnut cabinets with white embossed tile and glass pendant', alt: 'Walnut cabinets with white embossed tile and glass pendant',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',
}, },
{ {
src: '/images/boulder/09.jpg', src: img('boulder', '09.jpg'),
alt: 'Bathroom with emerald subway tile shower and walnut vanity', alt: 'Bathroom with emerald subway tile shower and walnut vanity',
orientation: 'portrait', orientation: 'portrait',
feature: 'paired', feature: 'paired',
}, },
{ {
src: '/images/boulder/06.jpg', src: img('boulder', '06.jpg'),
alt: 'Emerald subway tile shower with bronze fittings and white niche', alt: 'Emerald subway tile shower with bronze fittings and white niche',
orientation: 'landscape', orientation: 'landscape',
feature: 'paired', feature: 'paired',
}, },
{ {
src: '/images/boulder/12.jpg', src: img('boulder', '12.jpg'),
alt: 'Walnut bath vanity with reclaimed wood mirror and white tile backsplash', alt: 'Walnut bath vanity with reclaimed wood mirror and white tile backsplash',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',
}, },
{ {
src: '/images/boulder/10.jpg', src: img('boulder', '10.jpg'),
alt: 'Rear deck and patio at golden hour with mountain view', alt: 'Rear deck and patio at golden hour with mountain view',
orientation: 'landscape', orientation: 'landscape',
feature: 'wide', feature: 'wide',
}, },
{ {
src: '/images/boulder/11.jpg', src: img('boulder', '11.jpg'),
alt: 'Rear deck at dusk with warm lit windows', alt: 'Rear deck at dusk with warm lit windows',
orientation: 'landscape', orientation: 'landscape',
feature: 'wide', feature: 'wide',
}, },
{ {
src: '/images/boulder/04.jpg', src: img('boulder', '04.jpg'),
alt: 'Front of the home under a winter moon at dusk', alt: 'Front of the home under a winter moon at dusk',
orientation: 'landscape', orientation: 'landscape',
feature: 'wide', feature: 'wide',
@ -112,35 +130,35 @@ export const asheville: Project = {
'A counterpoint to Boulder — black vertical slats, matte black appliances, ' + 'A counterpoint to Boulder — black vertical slats, matte black appliances, ' +
'and large picture windows held in balance by warm wood floors and layered ' + 'and large picture windows held in balance by warm wood floors and layered ' +
'textiles. A space that is restrained but never cold.', 'textiles. A space that is restrained but never cold.',
cover: '/images/asheville/01-living.jpg', cover: img('asheville', '01-living.jpg'),
coverAlt: 'Living room with black vertical slat wall and picture window', coverAlt: 'Living room with black vertical slat wall and picture window',
images: [ images: [
{ {
src: '/images/asheville/01-living.jpg', src: img('asheville', '01-living.jpg'),
alt: 'Living room with black vertical slat wall and oversized window', alt: 'Living room with black vertical slat wall and oversized window',
orientation: 'portrait', orientation: 'portrait',
feature: 'hero', feature: 'hero',
}, },
{ {
src: '/images/asheville/02-kitchen.jpg', src: img('asheville', '02-kitchen.jpg'),
alt: 'Galley kitchen with matte black appliances and layered Persian rug', alt: 'Galley kitchen with matte black appliances and layered Persian rug',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',
}, },
{ {
src: '/images/asheville/04-dining-wide.jpg', src: img('asheville', '04-dining-wide.jpg'),
alt: 'Dining nook with vertical slat wall and trailing monstera', alt: 'Dining nook with vertical slat wall and trailing monstera',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',
}, },
{ {
src: '/images/asheville/05-dining-detail.jpg', src: img('asheville', '05-dining-detail.jpg'),
alt: 'Dining table beneath a Nelson Saucer pendant against vertical slat wall', alt: 'Dining table beneath a Nelson Saucer pendant against vertical slat wall',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',
}, },
{ {
src: '/images/asheville/03-bath.jpg', src: img('asheville', '03-bath.jpg'),
alt: 'Powder bath with layered glazed tile and matte black accents', alt: 'Powder bath with layered glazed tile and matte black accents',
orientation: 'portrait', orientation: 'portrait',
feature: 'narrow', feature: 'narrow',

View file

@ -6,6 +6,7 @@ import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import Wordmark from '@/components/layout/Wordmark.vue' import Wordmark from '@/components/layout/Wordmark.vue'
import { projects } from '@/data/projects' import { projects } from '@/data/projects'
import heroImage from '@/assets/projects/boulder/08.jpg'
const { t } = useI18n({ useScope: 'global' }) const { t } = useI18n({ useScope: 'global' })
@ -25,7 +26,7 @@ const projectCards = computed(() =>
<!-- Hero --> <!-- Hero -->
<section class="relative -mt-16 overflow-hidden"> <section class="relative -mt-16 overflow-hidden">
<img <img
src="/images/boulder/08.jpg" :src="heroImage"
:alt="t('projects.boulder.coverAlt')" :alt="t('projects.boulder.coverAlt')"
class="h-screen min-h-[640px] w-full object-cover" class="h-screen min-h-[640px] w-full object-cover"
/> />