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:
parent
52cd156e43
commit
a79f4a32c7
8 changed files with 383 additions and 0 deletions
87
src/components/projects/ProjectDetail.vue
Normal file
87
src/components/projects/ProjectDetail.vue
Normal 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>
|
||||||
38
src/components/projects/ProjectImage.vue
Normal file
38
src/components/projects/ProjectImage.vue
Normal 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
155
src/data/projects.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,39 @@ const routes: RouteRecordRaw[] = [
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: () => import('@/views/HomeView.vue'),
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes,
|
routes,
|
||||||
|
scrollBehavior(_to, _from, saved) {
|
||||||
|
if (saved) return saved
|
||||||
|
return { top: 0 }
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
11
src/views/ContactView.vue
Normal file
11
src/views/ContactView.vue
Normal 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>
|
||||||
48
src/views/PortfolioView.vue
Normal file
48
src/views/PortfolioView.vue
Normal 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>
|
||||||
8
src/views/projects/AshevilleView.vue
Normal file
8
src/views/projects/AshevilleView.vue
Normal 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>
|
||||||
8
src/views/projects/BoulderView.vue
Normal file
8
src/views/projects/BoulderView.vue
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue