feat: portfolio scaffolding — index + Boulder + Asheville detail pages

Adds the data layer (src/data/projects.ts) holding curated narrative
order, alt text, and feature tags ('hero'|'wide'|'narrow'|'paired')
for both projects, plus the shared ProjectDetail + ProjectImage
components that read from it. Each ProjectImage opens a Dialog
lightbox on click; lazy-loaded by default.

ProjectDetail's editorial scroll honors the feature tag — full-bleed
hero, narrow centered, side-by-side pairs, etc — so the layout
rhythm is driven by data, not template forks. The two project views
are 4-line shims over the shared component.

PortfolioView is a 2-up 4:5 grid of project covers leading to the
detail pages; the cover ratios are deliberate (portrait crops echo
the editorial spread feel). Router adds the 4 new routes plus a
404→home catch-all and reset-on-navigate scroll behavior. ContactView
ships as a stub; the form lands in a follow-up commit alongside the
Nostr submission helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-27 11:20:30 +02:00
commit a79f4a32c7
8 changed files with 383 additions and 0 deletions

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import ProjectImage from './ProjectImage.vue'
import type { Project } from '@/data/projects'
const props = defineProps<{ project: Project }>()
const hero = computed(() => props.project.images.find((img) => img.feature === 'hero'))
const rest = computed(() => props.project.images.filter((img) => img.feature !== 'hero'))
</script>
<template>
<article class="bg-background">
<!-- Hero -->
<section v-if="hero" class="relative overflow-hidden">
<img
:src="hero.src"
:alt="hero.alt"
class="h-[88vh] w-full object-cover md:h-screen"
/>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/45"
></div>
<div
class="absolute right-0 bottom-10 left-0 mx-auto max-w-[1400px] px-6 text-white md:bottom-16 md:px-10"
>
<p class="eyebrow text-white/80">{{ project.eyebrow }}</p>
<h1 class="mt-3 font-serif text-5xl font-light tracking-tight md:text-7xl">
{{ project.name }}
</h1>
</div>
</section>
<!-- Intro -->
<section class="px-6 py-20 md:py-28">
<p
class="mx-auto max-w-[60ch] text-center text-lg leading-relaxed text-foreground/80 md:text-xl"
>
{{ project.intro }}
</p>
</section>
<!-- Editorial image scroll -->
<section class="space-y-16 px-6 pb-24 md:px-10 md:pb-32">
<div class="mx-auto max-w-[1400px] space-y-16 md:space-y-24">
<template v-for="(img, i) in rest" :key="img.src + i">
<!-- wide: full-bleed within container -->
<ProjectImage v-if="img.feature === 'wide'" :image="img" />
<!-- narrow: centered, narrower max-width -->
<div v-else-if="img.feature === 'narrow'" class="mx-auto max-w-3xl">
<ProjectImage :image="img" />
</div>
<!-- paired: 2-up grid; only the first in a pair renders here -->
<div
v-else-if="img.feature === 'paired' && (rest[i - 1]?.feature !== 'paired')"
class="grid gap-6 md:grid-cols-2 md:gap-10"
>
<ProjectImage :image="img" />
<ProjectImage v-if="rest[i + 1]?.feature === 'paired'" :image="rest[i + 1]" />
</div>
<!-- the second of a pair is rendered as a sibling above; skip -->
<template v-else-if="img.feature === 'paired'"></template>
<ProjectImage v-else :image="img" />
</template>
</div>
</section>
<!-- Closing CTA -->
<section
class="border-border/60 border-t px-6 py-20 text-center md:py-28 md:px-10"
>
<p class="eyebrow">A space of your own</p>
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-4xl">
Begin a conversation about your home.
</h2>
<Button as-child class="mt-8">
<RouterLink to="/contact">Inquire</RouterLink>
</Button>
</section>
</article>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import type { ProjectImage } from '@/data/projects'
defineProps<{ image: ProjectImage }>()
const open = ref(false)
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<button
type="button"
class="group relative block w-full overflow-hidden rounded-sm"
:aria-label="`View larger: ${image.alt}`"
>
<img
:src="image.src"
:alt="image.alt"
loading="lazy"
decoding="async"
class="w-full transition-transform duration-700 ease-out group-hover:scale-[1.015]"
/>
</button>
</DialogTrigger>
<DialogContent
class="border-0 bg-transparent p-0 sm:max-w-[min(95vw,1400px)]"
>
<img
:src="image.src"
:alt="image.alt"
class="h-auto max-h-[88vh] w-full rounded-sm object-contain"
/>
</DialogContent>
</Dialog>
</template>

155
src/data/projects.ts Normal file
View file

@ -0,0 +1,155 @@
export type ImageOrientation = 'landscape' | 'portrait'
export interface ProjectImage {
src: string
alt: string
orientation: ImageOrientation
/** Tag controlling layout slot in ProjectDetail's editorial scroll. */
feature?: 'hero' | 'wide' | 'narrow' | 'paired'
}
export interface Project {
slug: 'boulder' | 'asheville'
name: string
eyebrow: string
intro: string
cover: string
coverAlt: string
images: ProjectImage[]
}
export const boulder: Project = {
slug: 'boulder',
name: 'Boulder',
eyebrow: 'Light-filled organic',
intro:
'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',
coverAlt: 'Walnut kitchen with white embossed tile and warm pendant light',
images: [
{
src: '/images/boulder/03.jpg',
alt: 'Kitchen with reclaimed barnwood walls and live-edge bar top',
orientation: 'landscape',
feature: 'hero',
},
{
src: '/images/boulder/01.jpg',
alt: 'Oil-rubbed bronze faucet over a quartz counter, reclaimed wood backsplash',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/boulder/07.jpg',
alt: 'Kitchen alternate angle showing live-edge counter and Asian carving',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/02.jpg',
alt: 'Dining room with reclaimed barnwood feature wall and rush-seat chairs',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/08.jpg',
alt: 'Living room with mid-century sofa and emerald tile fireplace surround',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/05.jpg',
alt: 'Walnut cabinets with white embossed tile and glass pendant',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/boulder/09.jpg',
alt: 'Bathroom with emerald subway tile shower and walnut vanity',
orientation: 'portrait',
feature: 'paired',
},
{
src: '/images/boulder/06.jpg',
alt: 'Emerald subway tile shower with bronze fittings and white niche',
orientation: 'landscape',
feature: 'paired',
},
{
src: '/images/boulder/12.jpg',
alt: 'Walnut bath vanity with reclaimed wood mirror and white tile backsplash',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/boulder/10.jpg',
alt: 'Rear deck and patio at golden hour with mountain view',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/11.jpg',
alt: 'Rear deck at dusk with warm lit windows',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/04.jpg',
alt: 'Front of the home under a winter moon at dusk',
orientation: 'landscape',
feature: 'wide',
},
],
}
export const asheville: Project = {
slug: 'asheville',
name: 'Asheville',
eyebrow: 'Architectural and moody',
intro:
'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',
coverAlt: 'Living room with black vertical slat wall and picture window',
images: [
{
src: '/images/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',
alt: 'Galley kitchen with matte black appliances and layered Persian rug',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/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',
alt: 'Dining table beneath a Nelson Saucer pendant against vertical slat wall',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/asheville/03-bath.jpg',
alt: 'Powder bath with layered glazed tile and matte black accents',
orientation: 'portrait',
feature: 'narrow',
},
],
}
export const projects: Project[] = [boulder, asheville]
export function getProject(slug: string): Project | undefined {
return projects.find((p) => p.slug === slug)
}

View file

@ -6,11 +6,39 @@ const routes: RouteRecordRaw[] = [
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/portfolio',
name: 'portfolio',
component: () => import('@/views/PortfolioView.vue'),
},
{
path: '/portfolio/boulder',
name: 'portfolio-boulder',
component: () => import('@/views/projects/BoulderView.vue'),
},
{
path: '/portfolio/asheville',
name: 'portfolio-asheville',
component: () => import('@/views/projects/AshevilleView.vue'),
},
{
path: '/contact',
name: 'contact',
component: () => import('@/views/ContactView.vue'),
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(_to, _from, saved) {
if (saved) return saved
return { top: 0 }
},
})
export default router

11
src/views/ContactView.vue Normal file
View file

@ -0,0 +1,11 @@
<script setup lang="ts"></script>
<template>
<div class="mx-auto max-w-2xl px-6 py-20 md:py-28">
<p class="eyebrow">Contact</p>
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
Get in touch.
</h1>
<p class="text-muted-foreground mt-6">Form arriving in the next commit.</p>
</div>
</template>

View file

@ -0,0 +1,48 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { projects } from '@/data/projects'
</script>
<template>
<div class="bg-background">
<section class="mx-auto max-w-[1400px] px-6 pt-20 pb-12 md:px-10 md:pt-28 md:pb-16">
<p class="eyebrow">Portfolio</p>
<h1 class="mt-3 max-w-3xl font-serif text-4xl font-light tracking-tight md:text-6xl">
Two homes, one sensibility.
</h1>
<p class="text-foreground/70 mt-6 max-w-2xl text-base md:text-lg">
Each project responds to its setting Boulder leans into light and warm woods;
Asheville into matte black and architectural shadow. The throughline is restraint
and material honesty.
</p>
</section>
<section class="mx-auto max-w-[1400px] px-6 pb-24 md:px-10 md:pb-32">
<div class="grid gap-10 md:grid-cols-2 md:gap-14">
<RouterLink
v-for="project in projects"
:key="project.slug"
:to="`/portfolio/${project.slug}`"
class="group block"
>
<AspectRatio :ratio="4 / 5" class="overflow-hidden bg-muted">
<img
:src="project.cover"
:alt="project.coverAlt"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.03]"
/>
</AspectRatio>
<div class="mt-6 flex items-baseline justify-between">
<h2 class="font-serif text-2xl font-light tracking-tight md:text-3xl">
{{ project.name }}
</h2>
<p class="eyebrow">{{ project.eyebrow }}</p>
</div>
</RouterLink>
</div>
</section>
</div>
</template>

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
import { asheville } from '@/data/projects'
</script>
<template>
<ProjectDetail :project="asheville" />
</template>

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
import { boulder } from '@/data/projects'
</script>
<template>
<ProjectDetail :project="boulder" />
</template>