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>
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 704 KiB After Width: | Height: | Size: 704 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 497 KiB After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 725 KiB After Width: | Height: | Size: 725 KiB |
|
Before Width: | Height: | Size: 483 KiB After Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 662 KiB After Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 616 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 712 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
|
@ -18,6 +18,24 @@ export interface Project {
|
|||
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 = {
|
||||
slug: 'boulder',
|
||||
name: 'Boulder',
|
||||
|
|
@ -26,77 +44,77 @@ export const boulder: Project = {
|
|||
'A mid-century ranch reimagined around warm woods, reclaimed barnwood walls, ' +
|
||||
'live-edge counters, and emerald glazed tile. The palette stays in conversation ' +
|
||||
'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',
|
||||
images: [
|
||||
{
|
||||
src: '/images/boulder/03.jpg',
|
||||
src: img('boulder', '03.jpg'),
|
||||
alt: 'Kitchen with reclaimed barnwood walls and live-edge bar top',
|
||||
orientation: 'landscape',
|
||||
feature: 'hero',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/01.jpg',
|
||||
src: img('boulder', '01.jpg'),
|
||||
alt: 'Oil-rubbed bronze faucet over a quartz counter, reclaimed wood backsplash',
|
||||
orientation: 'portrait',
|
||||
feature: 'narrow',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/07.jpg',
|
||||
src: img('boulder', '07.jpg'),
|
||||
alt: 'Kitchen alternate angle showing live-edge counter and Asian carving',
|
||||
orientation: 'landscape',
|
||||
feature: 'wide',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/02.jpg',
|
||||
src: img('boulder', '02.jpg'),
|
||||
alt: 'Dining room with reclaimed barnwood feature wall and rush-seat chairs',
|
||||
orientation: 'landscape',
|
||||
feature: 'wide',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/08.jpg',
|
||||
src: img('boulder', '08.jpg'),
|
||||
alt: 'Living room with mid-century sofa and emerald tile fireplace surround',
|
||||
orientation: 'landscape',
|
||||
feature: 'wide',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/05.jpg',
|
||||
src: img('boulder', '05.jpg'),
|
||||
alt: 'Walnut cabinets with white embossed tile and glass pendant',
|
||||
orientation: 'portrait',
|
||||
feature: 'narrow',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/09.jpg',
|
||||
src: img('boulder', '09.jpg'),
|
||||
alt: 'Bathroom with emerald subway tile shower and walnut vanity',
|
||||
orientation: 'portrait',
|
||||
feature: 'paired',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/06.jpg',
|
||||
src: img('boulder', '06.jpg'),
|
||||
alt: 'Emerald subway tile shower with bronze fittings and white niche',
|
||||
orientation: 'landscape',
|
||||
feature: 'paired',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/12.jpg',
|
||||
src: img('boulder', '12.jpg'),
|
||||
alt: 'Walnut bath vanity with reclaimed wood mirror and white tile backsplash',
|
||||
orientation: 'portrait',
|
||||
feature: 'narrow',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/10.jpg',
|
||||
src: img('boulder', '10.jpg'),
|
||||
alt: 'Rear deck and patio at golden hour with mountain view',
|
||||
orientation: 'landscape',
|
||||
feature: 'wide',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/11.jpg',
|
||||
src: img('boulder', '11.jpg'),
|
||||
alt: 'Rear deck at dusk with warm lit windows',
|
||||
orientation: 'landscape',
|
||||
feature: 'wide',
|
||||
},
|
||||
{
|
||||
src: '/images/boulder/04.jpg',
|
||||
src: img('boulder', '04.jpg'),
|
||||
alt: 'Front of the home under a winter moon at dusk',
|
||||
orientation: 'landscape',
|
||||
feature: 'wide',
|
||||
|
|
@ -112,35 +130,35 @@ export const asheville: Project = {
|
|||
'A counterpoint to Boulder — black vertical slats, matte black appliances, ' +
|
||||
'and large picture windows held in balance by warm wood floors and layered ' +
|
||||
'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',
|
||||
images: [
|
||||
{
|
||||
src: '/images/asheville/01-living.jpg',
|
||||
src: img('asheville', '01-living.jpg'),
|
||||
alt: 'Living room with black vertical slat wall and oversized window',
|
||||
orientation: 'portrait',
|
||||
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',
|
||||
orientation: 'portrait',
|
||||
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',
|
||||
orientation: 'portrait',
|
||||
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',
|
||||
orientation: 'portrait',
|
||||
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',
|
||||
orientation: 'portrait',
|
||||
feature: 'narrow',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AspectRatio } from '@/components/ui/aspect-ratio'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import Wordmark from '@/components/layout/Wordmark.vue'
|
||||
import { projects } from '@/data/projects'
|
||||
import heroImage from '@/assets/projects/boulder/08.jpg'
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ const projectCards = computed(() =>
|
|||
<!-- Hero -->
|
||||
<section class="relative -mt-16 overflow-hidden">
|
||||
<img
|
||||
src="/images/boulder/08.jpg"
|
||||
:src="heroImage"
|
||||
:alt="t('projects.boulder.coverAlt')"
|
||||
class="h-screen min-h-[640px] w-full object-cover"
|
||||
/>
|
||||
|
|
|
|||