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',
|
||||
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
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