feat: i18n migration + French + Spanish translations + locale switcher
Every visitor-facing string now flows through namespaced i18n keys (home.*, portfolio.*, projects.<slug>.*, contact.*, form.*, etc.), including alt text and form-validation error messages. Project copy (name / eyebrow / intro / coverAlt) is looked up by slug from the locale bundle rather than hard-coded in src/data/projects.ts — project shape stays English-default for fallback values, but the runtime resolves via i18n. Adds a complete fr.json and rewrites es.json from the boilerplate demo content. Three locales total: en (default), fr, es. The initial locale picks the persisted `ewd:locale` value, falls back to a navigator.language match, then to en. LocaleSwitcher (a small EN/FR/ES dropdown in the header next to the theme toggle) writes to localStorage so the choice survives navigation and reload. Visible at both desktop and mobile breakpoints. The ContactForm's zod schema is rebuilt as a computed so error messages re-translate live when the locale changes. The PrivacyBlurb uses <i18n-t> with a #nostr slot so "Nostr network" can be the emphasized inline span across all three locales. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0980d7fb5
commit
00e77ecf05
15 changed files with 649 additions and 145 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
@ -24,35 +25,44 @@ import {
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { submitInquiry, type ContactMethod } from '@/features/nostr/submitInquiry'
|
import { submitInquiry, type ContactMethod } from '@/features/nostr/submitInquiry'
|
||||||
|
|
||||||
const METHODS: { value: ContactMethod; label: string; placeholder: string }[] = [
|
const { t } = useI18n()
|
||||||
{ value: 'email', label: 'Email', placeholder: 'you@example.com' },
|
|
||||||
{ value: 'whatsapp', label: 'WhatsApp', placeholder: '+1 555 123 4567' },
|
const METHODS: ContactMethod[] = ['email', 'whatsapp', 'signal', 'telegram', 'nostr']
|
||||||
{ value: 'signal', label: 'Signal', placeholder: '+1 555 123 4567 or @username' },
|
|
||||||
{ value: 'telegram', label: 'Telegram', placeholder: '@yourhandle' },
|
const methodItems = computed(() =>
|
||||||
{ value: 'nostr', label: 'Nostr', placeholder: 'npub1…' },
|
METHODS.map((m) => ({
|
||||||
]
|
value: m,
|
||||||
|
label: t(`methods.${m}`),
|
||||||
|
placeholder: t(`methodPlaceholders.${m}`),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
const PHONE_RE = /^\+?[\d\s().-]{7,}$/
|
const PHONE_RE = /^\+?[\d\s().-]{7,}$/
|
||||||
const HANDLE_RE = /^@?[A-Za-z0-9_.]{3,32}$/
|
const HANDLE_RE = /^@?[A-Za-z0-9_.]{3,32}$/
|
||||||
|
|
||||||
const Schema = z
|
const Schema = computed(() =>
|
||||||
|
z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(80, 'Keep it under 80 characters.')
|
.max(80, t('form.errors.nameTooLong'))
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal('')),
|
.or(z.literal('')),
|
||||||
contactMethod: z.enum(['email', 'whatsapp', 'signal', 'telegram', 'nostr'], {
|
contactMethod: z.enum(['email', 'whatsapp', 'signal', 'telegram', 'nostr'], {
|
||||||
message: 'Choose how the studio should reach you.',
|
message: t('form.errors.methodMissing'),
|
||||||
}),
|
}),
|
||||||
contactValue: z.string().trim().min(2, 'Required.').max(200, 'Too long.'),
|
contactValue: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, t('form.errors.valueRequired'))
|
||||||
|
.max(200, t('form.errors.valueTooLong')),
|
||||||
message: z
|
message: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(10, 'A sentence or two helps the studio respond well.')
|
.min(10, t('form.errors.messageTooShort'))
|
||||||
.max(2000, 'Keep it under 2000 characters.'),
|
.max(2000, t('form.errors.messageTooLong')),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
const v = data.contactValue
|
const v = data.contactValue
|
||||||
|
|
@ -60,26 +70,31 @@ const Schema = z
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contactValue'], message: msg })
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contactValue'], message: msg })
|
||||||
switch (data.contactMethod) {
|
switch (data.contactMethod) {
|
||||||
case 'email':
|
case 'email':
|
||||||
if (!EMAIL_RE.test(v)) fail('That does not look like an email address.')
|
if (!EMAIL_RE.test(v)) fail(t('form.errors.badEmail'))
|
||||||
break
|
break
|
||||||
case 'whatsapp':
|
case 'whatsapp':
|
||||||
case 'signal':
|
case 'signal':
|
||||||
if (!PHONE_RE.test(v) && !HANDLE_RE.test(v))
|
if (!PHONE_RE.test(v) && !HANDLE_RE.test(v)) fail(t('form.errors.badPhoneOrHandle'))
|
||||||
fail('Use a phone number or a handle.')
|
|
||||||
break
|
break
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
if (!HANDLE_RE.test(v)) fail('Use a Telegram handle, like @yourname.')
|
if (!HANDLE_RE.test(v)) fail(t('form.errors.badTelegram'))
|
||||||
break
|
break
|
||||||
case 'nostr':
|
case 'nostr':
|
||||||
if (!/^npub1[0-9a-z]{30,}$/.test(v)) fail('That does not look like an npub.')
|
if (!/^npub1[0-9a-z]{30,}$/.test(v)) fail(t('form.errors.badNpub'))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
type FormValues = z.input<typeof Schema>
|
type FormValues = {
|
||||||
|
name?: string
|
||||||
|
contactMethod: ContactMethod
|
||||||
|
contactValue: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
const { handleSubmit, isSubmitting, resetForm, values } = useForm<FormValues>({
|
const { handleSubmit, isSubmitting, resetForm, values } = useForm<FormValues>({
|
||||||
validationSchema: toTypedSchema(Schema),
|
validationSchema: computed(() => toTypedSchema(Schema.value)),
|
||||||
initialValues: { name: '', contactMethod: 'email', contactValue: '', message: '' },
|
initialValues: { name: '', contactMethod: 'email', contactValue: '', message: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -90,26 +105,29 @@ const onSubmit = handleSubmit(async (vals) => {
|
||||||
try {
|
try {
|
||||||
const result = await submitInquiry({
|
const result = await submitInquiry({
|
||||||
name: vals.name?.trim() || undefined,
|
name: vals.name?.trim() || undefined,
|
||||||
contactMethod: vals.contactMethod as ContactMethod,
|
contactMethod: vals.contactMethod,
|
||||||
contactValue: vals.contactValue.trim(),
|
contactValue: vals.contactValue.trim(),
|
||||||
message: vals.message.trim(),
|
message: vals.message.trim(),
|
||||||
})
|
})
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
toast.success('Inquiry sent', {
|
toast.success(t('form.toast.successTitle'), {
|
||||||
description:
|
description:
|
||||||
result.acceptedBy === result.attempted
|
result.acceptedBy === result.attempted
|
||||||
? 'The studio will be in touch.'
|
? t('form.toast.successFull')
|
||||||
: `Delivered to ${result.acceptedBy} of ${result.attempted} relays — the studio will still receive it.`,
|
: t('form.toast.successPartial', {
|
||||||
|
accepted: result.acceptedBy,
|
||||||
|
attempted: result.attempted,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
resetForm()
|
resetForm()
|
||||||
} else {
|
} else {
|
||||||
toast.error('Could not reach any relay', {
|
toast.error(t('form.toast.noRelaysTitle'), {
|
||||||
description: 'Check your network and try again.',
|
description: t('form.toast.noRelaysBody'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Inquiry failed', {
|
toast.error(t('form.toast.failedTitle'), {
|
||||||
description: err instanceof Error ? err.message : 'Unknown error.',
|
description: err instanceof Error ? err.message : t('form.toast.unknownError'),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|
@ -117,7 +135,7 @@ const onSubmit = handleSubmit(async (vals) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function placeholderFor(method: string | undefined): string {
|
function placeholderFor(method: string | undefined): string {
|
||||||
return METHODS.find((m) => m.value === method)?.placeholder ?? ''
|
return methodItems.value.find((m) => m.value === method)?.placeholder ?? ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -125,12 +143,15 @@ function placeholderFor(method: string | undefined): string {
|
||||||
<form class="space-y-7" novalidate @submit="onSubmit">
|
<form class="space-y-7" novalidate @submit="onSubmit">
|
||||||
<FormField v-slot="{ componentField }" name="name">
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Your name <span class="text-muted-foreground">(optional)</span></FormLabel>
|
<FormLabel>
|
||||||
|
{{ t('form.name.label') }}
|
||||||
|
<span class="text-muted-foreground">{{ t('form.name.optional') }}</span>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
placeholder="First name, or however you'd like to be called"
|
:placeholder="t('form.name.placeholder')"
|
||||||
v-bind="componentField"
|
v-bind="componentField"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
@ -141,15 +162,15 @@ function placeholderFor(method: string | undefined): string {
|
||||||
<div class="grid gap-7 sm:grid-cols-[180px_1fr]">
|
<div class="grid gap-7 sm:grid-cols-[180px_1fr]">
|
||||||
<FormField v-slot="{ componentField }" name="contactMethod">
|
<FormField v-slot="{ componentField }" name="contactMethod">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Reach me via</FormLabel>
|
<FormLabel>{{ t('form.method.label') }}</FormLabel>
|
||||||
<Select v-bind="componentField">
|
<Select v-bind="componentField">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choose…" />
|
<SelectValue :placeholder="t('form.method.placeholder')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="m in METHODS" :key="m.value" :value="m.value">
|
<SelectItem v-for="m in methodItems" :key="m.value" :value="m.value">
|
||||||
{{ m.label }}
|
{{ m.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -160,7 +181,7 @@ function placeholderFor(method: string | undefined): string {
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="contactValue">
|
<FormField v-slot="{ componentField }" name="contactValue">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Where to send the reply</FormLabel>
|
<FormLabel>{{ t('form.value.label') }}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -169,7 +190,7 @@ function placeholderFor(method: string | undefined): string {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription class="text-xs">
|
<FormDescription class="text-xs">
|
||||||
Whatever is easiest for you — the studio will use exactly this.
|
{{ t('form.value.description') }}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -178,11 +199,11 @@ function placeholderFor(method: string | undefined): string {
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="message">
|
<FormField v-slot="{ componentField }" name="message">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Your message</FormLabel>
|
<FormLabel>{{ t('form.message.label') }}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows="6"
|
rows="6"
|
||||||
placeholder="Tell the studio a little about your space and what you are hoping for."
|
:placeholder="t('form.message.placeholder')"
|
||||||
v-bind="componentField"
|
v-bind="componentField"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
@ -192,7 +213,7 @@ function placeholderFor(method: string | undefined): string {
|
||||||
|
|
||||||
<div class="flex items-center justify-end pt-2">
|
<div class="flex items-center justify-end pt-2">
|
||||||
<Button type="submit" :disabled="isSubmitting || submitting" class="min-w-36">
|
<Button type="submit" :disabled="isSubmitting || submitting" class="min-w-36">
|
||||||
{{ isSubmitting || submitting ? 'Sending…' : 'Send inquiry' }}
|
{{ isSubmitting || submitting ? t('form.submit.sending') : t('form.submit.idle') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Lock } from '@lucide/vue'
|
import { Lock } from '@lucide/vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -8,13 +11,12 @@ import { Lock } from '@lucide/vue'
|
||||||
>
|
>
|
||||||
<div class="text-foreground mb-2 flex items-center gap-2 text-xs uppercase tracking-[0.18em]">
|
<div class="text-foreground mb-2 flex items-center gap-2 text-xs uppercase tracking-[0.18em]">
|
||||||
<Lock class="h-3.5 w-3.5" />
|
<Lock class="h-3.5 w-3.5" />
|
||||||
<span>How this works</span>
|
<span>{{ t('privacyBlurb.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<i18n-t keypath="privacyBlurb.body" tag="p">
|
||||||
Your message is encrypted in your browser and delivered through the
|
<template #nostr>
|
||||||
<span class="text-foreground">Nostr network</span> directly to the studio. No server
|
<span class="text-foreground">{{ t('privacyBlurb.nostrLabel') }}</span>
|
||||||
between us stores or sees it, and the studio replies through the contact method
|
</template>
|
||||||
you choose.
|
</i18n-t>
|
||||||
</p>
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
42
src/components/layout/LocaleSwitcher.vue
Normal file
42
src/components/layout/LocaleSwitcher.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { SUPPORTED_LOCALES, persistLocale, type LocaleCode } from '@/i18n'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
function set(code: LocaleCode) {
|
||||||
|
locale.value = code
|
||||||
|
persistLocale(code)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
class="text-foreground/70 hover:text-foreground inline-flex h-9 items-center px-2 text-xs uppercase tracking-[0.22em] transition-colors"
|
||||||
|
:aria-label="`Language: ${locale}`"
|
||||||
|
>
|
||||||
|
{{ locale.toUpperCase() }}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="min-w-40">
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="l in SUPPORTED_LOCALES"
|
||||||
|
:key="l.code"
|
||||||
|
class="flex items-center justify-between"
|
||||||
|
:class="locale === l.code ? 'bg-accent' : ''"
|
||||||
|
@select="set(l.code as LocaleCode)"
|
||||||
|
>
|
||||||
|
<span>{{ l.name }}</span>
|
||||||
|
<span class="text-muted-foreground text-xs uppercase tracking-[0.18em]">
|
||||||
|
{{ l.label }}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -10,17 +12,15 @@ const year = new Date().getFullYear()
|
||||||
class="mx-auto flex max-w-[1400px] flex-col items-start justify-between gap-6 px-6 py-12 md:flex-row md:items-center md:px-10"
|
class="mx-auto flex max-w-[1400px] flex-col items-start justify-between gap-6 px-6 py-12 md:flex-row md:items-center md:px-10"
|
||||||
>
|
>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="font-serif text-base tracking-[0.18em] uppercase">Earth Walker</p>
|
<p class="font-serif text-base tracking-[0.18em] uppercase">{{ t('brand') }}</p>
|
||||||
<p class="text-muted-foreground text-xs">
|
<p class="text-muted-foreground text-xs">{{ t('footer.tagline') }}</p>
|
||||||
Interior design — by appointment.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/contact"
|
to="/contact"
|
||||||
class="text-foreground/70 hover:text-foreground text-xs uppercase tracking-[0.22em] transition-colors"
|
class="text-foreground/70 hover:text-foreground text-xs uppercase tracking-[0.22em] transition-colors"
|
||||||
>
|
>
|
||||||
Inquire
|
{{ t('nav.inquire') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<p class="text-muted-foreground text-xs">© {{ year }}</p>
|
<p class="text-muted-foreground text-xs">© {{ year }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Menu } from '@lucide/vue'
|
import { Menu } from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
|
|
@ -10,11 +11,14 @@ import {
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import ThemeToggle from './ThemeToggle.vue'
|
import ThemeToggle from './ThemeToggle.vue'
|
||||||
|
import LocaleSwitcher from './LocaleSwitcher.vue'
|
||||||
|
|
||||||
const navItems = [
|
const { t } = useI18n()
|
||||||
{ to: '/portfolio', label: 'Portfolio' },
|
|
||||||
{ to: '/contact', label: 'Contact' },
|
const navItems = computed(() => [
|
||||||
]
|
{ to: '/portfolio', label: t('nav.portfolio') },
|
||||||
|
{ to: '/contact', label: t('nav.contact') },
|
||||||
|
])
|
||||||
|
|
||||||
const mobileOpen = ref(false)
|
const mobileOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -30,10 +34,10 @@ const mobileOpen = ref(false)
|
||||||
to="/"
|
to="/"
|
||||||
class="text-foreground font-serif text-lg tracking-[0.18em] uppercase"
|
class="text-foreground font-serif text-lg tracking-[0.18em] uppercase"
|
||||||
>
|
>
|
||||||
Earth Walker
|
{{ t('brand') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="hidden items-center gap-10 md:flex">
|
<nav class="hidden items-center gap-8 md:flex">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
|
|
@ -43,22 +47,24 @@ const mobileOpen = ref(false)
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<LocaleSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 md:hidden">
|
<div class="flex items-center gap-1 md:hidden">
|
||||||
|
<LocaleSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Sheet v-model:open="mobileOpen">
|
<Sheet v-model:open="mobileOpen">
|
||||||
<SheetTrigger
|
<SheetTrigger
|
||||||
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center"
|
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center"
|
||||||
aria-label="Open menu"
|
:aria-label="t('nav.openMenu')"
|
||||||
>
|
>
|
||||||
<Menu class="h-5 w-5" />
|
<Menu class="h-5 w-5" />
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" class="w-full sm:max-w-sm">
|
<SheetContent side="right" class="w-full sm:max-w-sm">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle class="font-serif tracking-[0.18em] uppercase">
|
<SheetTitle class="font-serif tracking-[0.18em] uppercase">
|
||||||
Earth Walker
|
{{ t('brand') }}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav class="mt-10 flex flex-col gap-6 px-4">
|
<nav class="mt-10 flex flex-col gap-6 px-4">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Moon, Sun } from '@lucide/vue'
|
import { Moon, Sun } from '@lucide/vue'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const { theme, toggle } = useTheme()
|
const { theme, toggle } = useTheme()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -9,7 +11,7 @@ const { theme, toggle } = useTheme()
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center transition-colors"
|
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center transition-colors"
|
||||||
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
:aria-label="theme === 'dark' ? t('themeToggle.toLight') : t('themeToggle.toDark')"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import ProjectImage from './ProjectImage.vue'
|
import ProjectImage from './ProjectImage.vue'
|
||||||
import type { Project } from '@/data/projects'
|
import type { Project } from '@/data/projects'
|
||||||
|
|
||||||
const props = defineProps<{ project: Project }>()
|
const props = defineProps<{ project: Project }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hero = computed(() => props.project.images.find((img) => img.feature === 'hero'))
|
const hero = computed(() => props.project.images.find((img) => img.feature === 'hero'))
|
||||||
const rest = computed(() => props.project.images.filter((img) => img.feature !== 'hero'))
|
const rest = computed(() => props.project.images.filter((img) => img.feature !== 'hero'))
|
||||||
|
const name = computed(() => t(`projects.${props.project.slug}.name`))
|
||||||
|
const eyebrow = computed(() => t(`projects.${props.project.slug}.eyebrow`))
|
||||||
|
const intro = computed(() => t(`projects.${props.project.slug}.intro`))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -26,9 +31,9 @@ const rest = computed(() => props.project.images.filter((img) => img.feature !==
|
||||||
<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"
|
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>
|
<p class="eyebrow text-white/80">{{ eyebrow }}</p>
|
||||||
<h1 class="mt-3 font-serif text-5xl font-light tracking-tight md:text-7xl">
|
<h1 class="mt-3 font-serif text-5xl font-light tracking-tight md:text-7xl">
|
||||||
{{ project.name }}
|
{{ name }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -38,7 +43,7 @@ const rest = computed(() => props.project.images.filter((img) => img.feature !==
|
||||||
<p
|
<p
|
||||||
class="mx-auto max-w-[60ch] text-center text-lg leading-relaxed text-foreground/80 md:text-xl"
|
class="mx-auto max-w-[60ch] text-center text-lg leading-relaxed text-foreground/80 md:text-xl"
|
||||||
>
|
>
|
||||||
{{ project.intro }}
|
{{ intro }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -46,15 +51,12 @@ const rest = computed(() => props.project.images.filter((img) => img.feature !==
|
||||||
<section class="space-y-16 px-6 pb-24 md:px-10 md:pb-32">
|
<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">
|
<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">
|
<template v-for="(img, i) in rest" :key="img.src + i">
|
||||||
<!-- wide: full-bleed within container -->
|
|
||||||
<ProjectImage v-if="img.feature === 'wide'" :image="img" />
|
<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">
|
<div v-else-if="img.feature === 'narrow'" class="mx-auto max-w-3xl">
|
||||||
<ProjectImage :image="img" />
|
<ProjectImage :image="img" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- paired: 2-up grid; only the first in a pair renders here -->
|
|
||||||
<div
|
<div
|
||||||
v-else-if="img.feature === 'paired' && (rest[i - 1]?.feature !== 'paired')"
|
v-else-if="img.feature === 'paired' && (rest[i - 1]?.feature !== 'paired')"
|
||||||
class="grid gap-6 md:grid-cols-2 md:gap-10"
|
class="grid gap-6 md:grid-cols-2 md:gap-10"
|
||||||
|
|
@ -63,7 +65,6 @@ const rest = computed(() => props.project.images.filter((img) => img.feature !==
|
||||||
<ProjectImage v-if="rest[i + 1]?.feature === 'paired'" :image="rest[i + 1]" />
|
<ProjectImage v-if="rest[i + 1]?.feature === 'paired'" :image="rest[i + 1]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- the second of a pair is rendered as a sibling above; skip -->
|
|
||||||
<template v-else-if="img.feature === 'paired'"></template>
|
<template v-else-if="img.feature === 'paired'"></template>
|
||||||
|
|
||||||
<ProjectImage v-else :image="img" />
|
<ProjectImage v-else :image="img" />
|
||||||
|
|
@ -75,12 +76,12 @@ const rest = computed(() => props.project.images.filter((img) => img.feature !==
|
||||||
<section
|
<section
|
||||||
class="border-border/60 border-t px-6 py-20 text-center md:py-28 md:px-10"
|
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>
|
<p class="eyebrow">{{ t('projectDetail.cta.eyebrow') }}</p>
|
||||||
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-4xl">
|
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-4xl">
|
||||||
Begin a conversation about your home.
|
{{ t('projectDetail.cta.headline') }}
|
||||||
</h2>
|
</h2>
|
||||||
<Button as-child class="mt-8">
|
<Button as-child class="mt-8">
|
||||||
<RouterLink to="/contact">Inquire</RouterLink>
|
<RouterLink to="/contact">{{ t('projectDetail.cta.action') }}</RouterLink>
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import type { ProjectImage } from '@/data/projects'
|
import type { ProjectImage } from '@/data/projects'
|
||||||
|
|
||||||
defineProps<{ image: ProjectImage }>()
|
const props = defineProps<{ image: ProjectImage }>()
|
||||||
|
const { t } = useI18n()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -14,7 +15,7 @@ const open = ref(false)
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative block w-full overflow-hidden rounded-sm"
|
class="group relative block w-full overflow-hidden rounded-sm"
|
||||||
:aria-label="`View larger: ${image.alt}`"
|
:aria-label="t('projectDetail.viewLarger', { alt: props.image.alt })"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="image.src"
|
:src="image.src"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,36 @@
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import en from './locales/en.json'
|
import en from './locales/en.json'
|
||||||
import es from './locales/es.json'
|
import es from './locales/es.json'
|
||||||
|
import fr from './locales/fr.json'
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = [
|
||||||
|
{ code: 'en', label: 'EN', name: 'English' },
|
||||||
|
{ code: 'fr', label: 'FR', name: 'Français' },
|
||||||
|
{ code: 'es', label: 'ES', name: 'Español' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type LocaleCode = (typeof SUPPORTED_LOCALES)[number]['code']
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ewd:locale'
|
||||||
|
|
||||||
|
function initialLocale(): LocaleCode {
|
||||||
|
if (typeof window === 'undefined') return 'en'
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY) as LocaleCode | null
|
||||||
|
if (stored && SUPPORTED_LOCALES.some((l) => l.code === stored)) return stored
|
||||||
|
const nav = (window.navigator.language || 'en').slice(0, 2).toLowerCase() as LocaleCode
|
||||||
|
if (SUPPORTED_LOCALES.some((l) => l.code === nav)) return nav
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
export default createI18n({
|
export default createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: initialLocale(),
|
||||||
fallbackLocale: 'en',
|
fallbackLocale: 'en',
|
||||||
messages: { en, es },
|
messages: { en, es, fr },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function persistLocale(code: LocaleCode) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,134 @@
|
||||||
{
|
{
|
||||||
"app": {
|
"brand": "Earth Walker",
|
||||||
"title": "Boilerplate Website"
|
"nav": {
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"contact": "Contact",
|
||||||
|
"inquire": "Inquire",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"viewAll": "View all"
|
||||||
|
},
|
||||||
|
"themeToggle": {
|
||||||
|
"toLight": "Switch to light theme",
|
||||||
|
"toDark": "Switch to dark theme"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "Interior design — by appointment."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"heading": "Welcome",
|
"hero": {
|
||||||
"intro": "Edit src/views/HomeView.vue to begin.",
|
"eyebrow": "Earth Walker Design",
|
||||||
"counter": "Count: {n}",
|
"headline": "Grounded, architectural,",
|
||||||
"increment": "Increment"
|
"headline2": "restorative interiors."
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"eyebrow": "Studio",
|
||||||
|
"lead": "A balance of minimalism and soul. Oversized windows and natural light. Warm woods, matte black accents, layered textures. Refined, but deeply human.",
|
||||||
|
"body": "The studio works closely with a small number of homes each year, slowly and intentionally — letting the architecture, the light, and the people who live there set the brief."
|
||||||
|
},
|
||||||
|
"selected": {
|
||||||
|
"eyebrow": "Selected work",
|
||||||
|
"headline": "Two homes, one sensibility."
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"eyebrow": "Inquiries",
|
||||||
|
"headline": "A small studio, available by appointment.",
|
||||||
|
"body": "Send a few notes about your home and the feeling you're hoping for.",
|
||||||
|
"action": "Begin a conversation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"portfolio": {
|
||||||
|
"eyebrow": "Portfolio",
|
||||||
|
"headline": "Two homes, one sensibility.",
|
||||||
|
"intro": "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."
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"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.",
|
||||||
|
"coverAlt": "Walnut kitchen with white embossed tile and warm pendant light"
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"coverAlt": "Living room with black vertical slat wall and picture window"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectDetail": {
|
||||||
|
"cta": {
|
||||||
|
"eyebrow": "A space of your own",
|
||||||
|
"headline": "Begin a conversation about your home.",
|
||||||
|
"action": "Inquire"
|
||||||
|
},
|
||||||
|
"viewLarger": "View larger: {alt}"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"eyebrow": "Contact",
|
||||||
|
"headline": "Begin a conversation.",
|
||||||
|
"intro": "The studio takes on a small number of new homes each year. Send a few notes about your space and we'll be in touch through your preferred channel."
|
||||||
|
},
|
||||||
|
"privacyBlurb": {
|
||||||
|
"title": "How this works",
|
||||||
|
"body": "Your message is encrypted in your browser and delivered through the {nostr} directly to the studio. No server between us stores or sees it, and the studio replies through the contact method you choose.",
|
||||||
|
"nostrLabel": "Nostr network"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": {
|
||||||
|
"label": "Your name",
|
||||||
|
"optional": "(optional)",
|
||||||
|
"placeholder": "First name, or however you'd like to be called"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"label": "Reach me via",
|
||||||
|
"placeholder": "Choose…"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"label": "Where to send the reply",
|
||||||
|
"description": "Whatever is easiest for you — the studio will use exactly this."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"label": "Your message",
|
||||||
|
"placeholder": "Tell the studio a little about your space and what you are hoping for."
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"idle": "Send inquiry",
|
||||||
|
"sending": "Sending…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameTooLong": "Keep it under 80 characters.",
|
||||||
|
"methodMissing": "Choose how the studio should reach you.",
|
||||||
|
"valueRequired": "Required.",
|
||||||
|
"valueTooLong": "Too long.",
|
||||||
|
"messageTooShort": "A sentence or two helps the studio respond well.",
|
||||||
|
"messageTooLong": "Keep it under 2000 characters.",
|
||||||
|
"badEmail": "That does not look like an email address.",
|
||||||
|
"badPhoneOrHandle": "Use a phone number or a handle.",
|
||||||
|
"badTelegram": "Use a Telegram handle, like @yourname.",
|
||||||
|
"badNpub": "That does not look like an npub."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"successTitle": "Inquiry sent",
|
||||||
|
"successFull": "The studio will be in touch.",
|
||||||
|
"successPartial": "Delivered to {accepted} of {attempted} relays — the studio will still receive it.",
|
||||||
|
"noRelaysTitle": "Could not reach any relay",
|
||||||
|
"noRelaysBody": "Check your network and try again.",
|
||||||
|
"failedTitle": "Inquiry failed",
|
||||||
|
"unknownError": "Unknown error."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"methods": {
|
||||||
|
"email": "Email",
|
||||||
|
"whatsapp": "WhatsApp",
|
||||||
|
"signal": "Signal",
|
||||||
|
"telegram": "Telegram",
|
||||||
|
"nostr": "Nostr"
|
||||||
|
},
|
||||||
|
"methodPlaceholders": {
|
||||||
|
"email": "you@example.com",
|
||||||
|
"whatsapp": "+1 555 123 4567",
|
||||||
|
"signal": "+1 555 123 4567 or @username",
|
||||||
|
"telegram": "@yourhandle",
|
||||||
|
"nostr": "npub1…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,134 @@
|
||||||
{
|
{
|
||||||
"app": {
|
"brand": "Earth Walker",
|
||||||
"title": "Plantilla de Sitio Web"
|
"nav": {
|
||||||
|
"portfolio": "Proyectos",
|
||||||
|
"contact": "Contacto",
|
||||||
|
"inquire": "Consultar",
|
||||||
|
"openMenu": "Abrir menú",
|
||||||
|
"viewAll": "Ver todo"
|
||||||
|
},
|
||||||
|
"themeToggle": {
|
||||||
|
"toLight": "Cambiar a tema claro",
|
||||||
|
"toDark": "Cambiar a tema oscuro"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "Diseño de interiores — con cita previa."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"heading": "Bienvenido",
|
"hero": {
|
||||||
"intro": "Edita src/views/HomeView.vue para empezar.",
|
"eyebrow": "Earth Walker Design",
|
||||||
"counter": "Cuenta: {n}",
|
"headline": "Interiores arraigados,",
|
||||||
"increment": "Incrementar"
|
"headline2": "arquitectónicos, restaurativos."
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"eyebrow": "Estudio",
|
||||||
|
"lead": "Un equilibrio entre minimalismo y alma. Ventanales y luz natural. Maderas cálidas, acentos negros mate, texturas en capas. Refinado, pero profundamente humano.",
|
||||||
|
"body": "El estudio trabaja de cerca con un pequeño número de hogares cada año, despacio y con intención — dejando que la arquitectura, la luz y las personas que viven allí marquen el rumbo."
|
||||||
|
},
|
||||||
|
"selected": {
|
||||||
|
"eyebrow": "Obra seleccionada",
|
||||||
|
"headline": "Dos hogares, una sensibilidad."
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"eyebrow": "Consultas",
|
||||||
|
"headline": "Un estudio pequeño, disponible con cita previa.",
|
||||||
|
"body": "Envíe unas notas sobre su hogar y el sentimiento que desea evocar.",
|
||||||
|
"action": "Iniciar una conversación"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"portfolio": {
|
||||||
|
"eyebrow": "Proyectos",
|
||||||
|
"headline": "Dos hogares, una sensibilidad.",
|
||||||
|
"intro": "Cada proyecto responde a su entorno — Boulder se inclina hacia la luz y las maderas cálidas; Asheville hacia el negro mate y la sombra arquitectónica. El hilo conductor es la contención y la honestidad de los materiales."
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"boulder": {
|
||||||
|
"name": "Boulder",
|
||||||
|
"eyebrow": "Orgánico lleno de luz",
|
||||||
|
"intro": "Un rancho de mediados de siglo reinventado en torno a maderas cálidas, paredes de madera de granero recuperada, encimeras con borde vivo y azulejos vidriados color esmeralda. La paleta dialoga con los árboles del exterior — la luz del sol hace casi todo el trabajo.",
|
||||||
|
"coverAlt": "Cocina de nogal con azulejos blancos en relieve y luz colgante cálida"
|
||||||
|
},
|
||||||
|
"asheville": {
|
||||||
|
"name": "Asheville",
|
||||||
|
"eyebrow": "Arquitectónico y sombrío",
|
||||||
|
"intro": "Un contrapunto a Boulder — listones verticales negros, electrodomésticos en negro mate y grandes ventanales equilibrados con suelos de madera cálida y textiles en capas. Un espacio contenido pero nunca frío.",
|
||||||
|
"coverAlt": "Salón con pared de listones verticales negros y ventanal panorámico"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectDetail": {
|
||||||
|
"cta": {
|
||||||
|
"eyebrow": "Un espacio propio",
|
||||||
|
"headline": "Iniciemos una conversación sobre su hogar.",
|
||||||
|
"action": "Consultar"
|
||||||
|
},
|
||||||
|
"viewLarger": "Ver más grande: {alt}"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"eyebrow": "Contacto",
|
||||||
|
"headline": "Iniciemos una conversación.",
|
||||||
|
"intro": "El estudio acepta un número reducido de nuevos hogares cada año. Envíe unas notas sobre su espacio y nos pondremos en contacto a través del canal que prefiera."
|
||||||
|
},
|
||||||
|
"privacyBlurb": {
|
||||||
|
"title": "Cómo funciona",
|
||||||
|
"body": "Su mensaje se cifra en su navegador y se entrega a través de la {nostr} directamente al estudio. Ningún servidor entre nosotros lo guarda ni lo ve, y el estudio responde por el método de contacto que usted elija.",
|
||||||
|
"nostrLabel": "red Nostr"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": {
|
||||||
|
"label": "Su nombre",
|
||||||
|
"optional": "(opcional)",
|
||||||
|
"placeholder": "Su nombre, o cómo prefiere que le llamen"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"label": "Contáctame por",
|
||||||
|
"placeholder": "Elija…"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"label": "Dónde enviar la respuesta",
|
||||||
|
"description": "Lo que le resulte más fácil — el estudio usará exactamente esto."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"label": "Su mensaje",
|
||||||
|
"placeholder": "Cuéntele al estudio un poco sobre su espacio y lo que espera."
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"idle": "Enviar consulta",
|
||||||
|
"sending": "Enviando…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameTooLong": "Manténgalo bajo 80 caracteres.",
|
||||||
|
"methodMissing": "Elija cómo debe contactarle el estudio.",
|
||||||
|
"valueRequired": "Requerido.",
|
||||||
|
"valueTooLong": "Demasiado largo.",
|
||||||
|
"messageTooShort": "Una o dos frases ayudan al estudio a responder mejor.",
|
||||||
|
"messageTooLong": "Manténgalo bajo 2000 caracteres.",
|
||||||
|
"badEmail": "Eso no parece una dirección de correo.",
|
||||||
|
"badPhoneOrHandle": "Use un número de teléfono o un usuario.",
|
||||||
|
"badTelegram": "Use un usuario de Telegram, como @sunombre.",
|
||||||
|
"badNpub": "Eso no parece un npub."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"successTitle": "Consulta enviada",
|
||||||
|
"successFull": "El estudio se pondrá en contacto.",
|
||||||
|
"successPartial": "Entregada a {accepted} de {attempted} relés — el estudio aún la recibirá.",
|
||||||
|
"noRelaysTitle": "No se pudo alcanzar ningún relé",
|
||||||
|
"noRelaysBody": "Compruebe su conexión e inténtelo de nuevo.",
|
||||||
|
"failedTitle": "Consulta fallida",
|
||||||
|
"unknownError": "Error desconocido."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"methods": {
|
||||||
|
"email": "Correo",
|
||||||
|
"whatsapp": "WhatsApp",
|
||||||
|
"signal": "Signal",
|
||||||
|
"telegram": "Telegram",
|
||||||
|
"nostr": "Nostr"
|
||||||
|
},
|
||||||
|
"methodPlaceholders": {
|
||||||
|
"email": "tu@ejemplo.com",
|
||||||
|
"whatsapp": "+34 600 123 456",
|
||||||
|
"signal": "+34 600 123 456 o @usuario",
|
||||||
|
"telegram": "@suusuario",
|
||||||
|
"nostr": "npub1…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
src/i18n/locales/fr.json
Normal file
134
src/i18n/locales/fr.json
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
{
|
||||||
|
"brand": "Earth Walker",
|
||||||
|
"nav": {
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"contact": "Contact",
|
||||||
|
"inquire": "Demander",
|
||||||
|
"openMenu": "Ouvrir le menu",
|
||||||
|
"viewAll": "Tout voir"
|
||||||
|
},
|
||||||
|
"themeToggle": {
|
||||||
|
"toLight": "Passer au thème clair",
|
||||||
|
"toDark": "Passer au thème sombre"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "Design d'intérieur — sur rendez-vous."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"hero": {
|
||||||
|
"eyebrow": "Earth Walker Design",
|
||||||
|
"headline": "Intérieurs ancrés,",
|
||||||
|
"headline2": "architecturaux, ressourçants."
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"eyebrow": "Studio",
|
||||||
|
"lead": "Un équilibre entre minimalisme et âme. De grandes fenêtres, de la lumière naturelle. Bois chauds, accents noir mat, textures superposées. Raffiné, mais profondément humain.",
|
||||||
|
"body": "Le studio accompagne chaque année quelques maisons, lentement et avec intention — en laissant l'architecture, la lumière, et les personnes qui y vivent guider la démarche."
|
||||||
|
},
|
||||||
|
"selected": {
|
||||||
|
"eyebrow": "Œuvre sélectionnée",
|
||||||
|
"headline": "Deux maisons, une même sensibilité."
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"eyebrow": "Demandes",
|
||||||
|
"headline": "Un petit studio, disponible sur rendez-vous.",
|
||||||
|
"body": "Écrivez quelques mots sur votre maison et l'émotion que vous recherchez.",
|
||||||
|
"action": "Entamer la conversation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"portfolio": {
|
||||||
|
"eyebrow": "Portfolio",
|
||||||
|
"headline": "Deux maisons, une même sensibilité.",
|
||||||
|
"intro": "Chaque projet répond à son lieu — Boulder s'incline vers la lumière et les bois chauds ; Asheville vers le noir mat et l'ombre architecturale. Le fil conducteur : la retenue et l'honnêteté des matériaux."
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"boulder": {
|
||||||
|
"name": "Boulder",
|
||||||
|
"eyebrow": "Organique, baigné de lumière",
|
||||||
|
"intro": "Un ranch des années 50 réinventé autour de bois chauds, de murs en planches de grange récupérées, de comptoirs à bord vif et de carreaux émaillés vert émeraude. La palette dialogue avec les arbres du dehors — la lumière du soleil fait presque tout.",
|
||||||
|
"coverAlt": "Cuisine en noyer avec carrelage blanc en relief et suspension chaude"
|
||||||
|
},
|
||||||
|
"asheville": {
|
||||||
|
"name": "Asheville",
|
||||||
|
"eyebrow": "Architectural et sombre",
|
||||||
|
"intro": "Un contrepoint à Boulder — lattes verticales noires, électroménager noir mat et grandes baies vitrées équilibrés par des sols en bois chaud et des textiles superposés. Un espace retenu, mais jamais froid.",
|
||||||
|
"coverAlt": "Salon avec mur de lattes verticales noires et baie vitrée"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectDetail": {
|
||||||
|
"cta": {
|
||||||
|
"eyebrow": "Un espace bien à vous",
|
||||||
|
"headline": "Entamons une conversation sur votre maison.",
|
||||||
|
"action": "Demander"
|
||||||
|
},
|
||||||
|
"viewLarger": "Voir en grand : {alt}"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"eyebrow": "Contact",
|
||||||
|
"headline": "Entamons la conversation.",
|
||||||
|
"intro": "Le studio accepte un petit nombre de nouvelles maisons chaque année. Écrivez quelques mots sur votre espace et nous reviendrons vers vous par le canal que vous préférez."
|
||||||
|
},
|
||||||
|
"privacyBlurb": {
|
||||||
|
"title": "Comment ça marche",
|
||||||
|
"body": "Votre message est chiffré dans votre navigateur et livré via le {nostr} directement au studio. Aucun serveur entre nous ne le stocke ni ne le lit, et le studio répond par le moyen de contact que vous avez choisi.",
|
||||||
|
"nostrLabel": "réseau Nostr"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": {
|
||||||
|
"label": "Votre nom",
|
||||||
|
"optional": "(facultatif)",
|
||||||
|
"placeholder": "Prénom, ou comme vous préférez être appelé·e"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"label": "Me joindre par",
|
||||||
|
"placeholder": "Choisir…"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"label": "Où envoyer la réponse",
|
||||||
|
"description": "Ce qui vous arrange — le studio utilisera exactement ceci."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"label": "Votre message",
|
||||||
|
"placeholder": "Dites-nous quelques mots sur votre espace et ce que vous espérez."
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"idle": "Envoyer la demande",
|
||||||
|
"sending": "Envoi…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameTooLong": "Moins de 80 caractères, s'il vous plaît.",
|
||||||
|
"methodMissing": "Choisissez comment le studio doit vous joindre.",
|
||||||
|
"valueRequired": "Requis.",
|
||||||
|
"valueTooLong": "Trop long.",
|
||||||
|
"messageTooShort": "Une ou deux phrases aident le studio à bien répondre.",
|
||||||
|
"messageTooLong": "Moins de 2000 caractères, s'il vous plaît.",
|
||||||
|
"badEmail": "Cela ne ressemble pas à une adresse email.",
|
||||||
|
"badPhoneOrHandle": "Utilisez un numéro de téléphone ou un pseudo.",
|
||||||
|
"badTelegram": "Utilisez un pseudo Telegram, comme @votrenom.",
|
||||||
|
"badNpub": "Cela ne ressemble pas à un npub."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"successTitle": "Demande envoyée",
|
||||||
|
"successFull": "Le studio reviendra vers vous.",
|
||||||
|
"successPartial": "Livrée à {accepted} des {attempted} relais — le studio la recevra quand même.",
|
||||||
|
"noRelaysTitle": "Aucun relais joignable",
|
||||||
|
"noRelaysBody": "Vérifiez votre connexion et réessayez.",
|
||||||
|
"failedTitle": "Échec de l'envoi",
|
||||||
|
"unknownError": "Erreur inconnue."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"methods": {
|
||||||
|
"email": "Email",
|
||||||
|
"whatsapp": "WhatsApp",
|
||||||
|
"signal": "Signal",
|
||||||
|
"telegram": "Telegram",
|
||||||
|
"nostr": "Nostr"
|
||||||
|
},
|
||||||
|
"methodPlaceholders": {
|
||||||
|
"email": "vous@exemple.com",
|
||||||
|
"whatsapp": "+33 6 12 34 56 78",
|
||||||
|
"signal": "+33 6 12 34 56 78 ou @pseudo",
|
||||||
|
"telegram": "@votrepseudo",
|
||||||
|
"nostr": "npub1…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import ContactForm from '@/components/contact/ContactForm.vue'
|
import ContactForm from '@/components/contact/ContactForm.vue'
|
||||||
import PrivacyBlurb from '@/components/contact/PrivacyBlurb.vue'
|
import PrivacyBlurb from '@/components/contact/PrivacyBlurb.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-2xl px-6 pt-20 pb-28 md:pt-28 md:pb-32">
|
<div class="mx-auto max-w-2xl px-6 pt-20 pb-28 md:pt-28 md:pb-32">
|
||||||
<p class="eyebrow">Contact</p>
|
<p class="eyebrow">{{ t('contact.eyebrow') }}</p>
|
||||||
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
|
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
|
||||||
Begin a conversation.
|
{{ t('contact.headline') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-foreground/70 mt-6 max-w-prose text-base md:text-lg">
|
<p class="text-foreground/70 mt-6 max-w-prose text-base md:text-lg">
|
||||||
The studio takes on a small number of new homes each year. Send a few notes about
|
{{ t('contact.intro') }}
|
||||||
your space and we'll be in touch through your preferred channel.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AspectRatio } from '@/components/ui/aspect-ratio'
|
import { AspectRatio } from '@/components/ui/aspect-ratio'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { projects } from '@/data/projects'
|
import { projects } from '@/data/projects'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const projectCards = computed(() =>
|
||||||
|
projects.map((p) => ({
|
||||||
|
slug: p.slug,
|
||||||
|
cover: p.cover,
|
||||||
|
coverAlt: t(`projects.${p.slug}.coverAlt`),
|
||||||
|
name: t(`projects.${p.slug}.name`),
|
||||||
|
eyebrow: t(`projects.${p.slug}.eyebrow`),
|
||||||
|
})),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -11,7 +25,7 @@ import { projects } from '@/data/projects'
|
||||||
<section class="relative -mt-16 overflow-hidden">
|
<section class="relative -mt-16 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="/images/boulder/08.jpg"
|
src="/images/boulder/08.jpg"
|
||||||
alt="Living room with mid-century sofa and emerald tile fireplace surround"
|
: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"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -21,13 +35,12 @@ import { projects } from '@/data/projects'
|
||||||
class="absolute right-0 bottom-14 left-0 mx-auto max-w-[1400px] px-6 text-white md:bottom-20 md:px-10"
|
class="absolute right-0 bottom-14 left-0 mx-auto max-w-[1400px] px-6 text-white md:bottom-20 md:px-10"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.22em] text-white/80">
|
<p class="text-xs uppercase tracking-[0.22em] text-white/80">
|
||||||
Earth Walker Design
|
{{ t('home.hero.eyebrow') }}
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
class="mt-4 max-w-3xl font-serif text-4xl font-light leading-[1.05] tracking-tight md:text-6xl lg:text-7xl"
|
class="mt-4 max-w-3xl font-serif text-4xl font-light leading-[1.05] tracking-tight md:text-6xl lg:text-7xl"
|
||||||
>
|
>
|
||||||
Grounded, architectural,<br />
|
{{ t('home.hero.headline') }}<br />{{ t('home.hero.headline2') }}
|
||||||
restorative interiors.
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -35,18 +48,14 @@ import { projects } from '@/data/projects'
|
||||||
<!-- Philosophy -->
|
<!-- Philosophy -->
|
||||||
<section class="px-6 py-24 md:py-32">
|
<section class="px-6 py-24 md:py-32">
|
||||||
<div class="mx-auto max-w-3xl text-center">
|
<div class="mx-auto max-w-3xl text-center">
|
||||||
<p class="eyebrow">Studio</p>
|
<p class="eyebrow">{{ t('home.studio.eyebrow') }}</p>
|
||||||
<p
|
<p
|
||||||
class="text-foreground/80 mt-6 font-serif text-2xl font-light leading-relaxed md:text-3xl"
|
class="text-foreground/80 mt-6 font-serif text-2xl font-light leading-relaxed md:text-3xl"
|
||||||
>
|
>
|
||||||
A balance of minimalism and soul. Oversized windows and natural light.
|
{{ t('home.studio.lead') }}
|
||||||
Warm woods, matte black accents, layered textures. Refined, but deeply
|
|
||||||
human.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground mt-8 text-base md:text-lg">
|
<p class="text-muted-foreground mt-8 text-base md:text-lg">
|
||||||
The studio works closely with a small number of homes each year, slowly
|
{{ t('home.studio.body') }}
|
||||||
and intentionally — letting the architecture, the light, and the people
|
|
||||||
who live there set the brief.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -56,24 +65,24 @@ import { projects } from '@/data/projects'
|
||||||
<div class="mx-auto max-w-[1400px]">
|
<div class="mx-auto max-w-[1400px]">
|
||||||
<div class="flex items-baseline justify-between">
|
<div class="flex items-baseline justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Selected work</p>
|
<p class="eyebrow">{{ t('home.selected.eyebrow') }}</p>
|
||||||
<h2
|
<h2
|
||||||
class="mt-3 font-serif text-3xl font-light tracking-tight md:text-5xl"
|
class="mt-3 font-serif text-3xl font-light tracking-tight md:text-5xl"
|
||||||
>
|
>
|
||||||
Two homes, one sensibility.
|
{{ t('home.selected.headline') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/portfolio"
|
to="/portfolio"
|
||||||
class="text-foreground/70 hover:text-foreground hidden text-xs uppercase tracking-[0.22em] transition-colors md:inline-flex"
|
class="text-foreground/70 hover:text-foreground hidden text-xs uppercase tracking-[0.22em] transition-colors md:inline-flex"
|
||||||
>
|
>
|
||||||
View all
|
{{ t('nav.viewAll') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-12 grid gap-10 md:grid-cols-2 md:gap-14">
|
<div class="mt-12 grid gap-10 md:grid-cols-2 md:gap-14">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="project in projects"
|
v-for="project in projectCards"
|
||||||
:key="project.slug"
|
:key="project.slug"
|
||||||
:to="`/portfolio/${project.slug}`"
|
:to="`/portfolio/${project.slug}`"
|
||||||
class="group block"
|
class="group block"
|
||||||
|
|
@ -100,15 +109,15 @@ import { projects } from '@/data/projects'
|
||||||
|
|
||||||
<!-- Closing CTA -->
|
<!-- Closing CTA -->
|
||||||
<section class="border-border/60 border-t px-6 py-24 text-center md:py-32 md:px-10">
|
<section class="border-border/60 border-t px-6 py-24 text-center md:py-32 md:px-10">
|
||||||
<p class="eyebrow">Inquiries</p>
|
<p class="eyebrow">{{ t('home.cta.eyebrow') }}</p>
|
||||||
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-5xl">
|
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-5xl">
|
||||||
A small studio, available by appointment.
|
{{ t('home.cta.headline') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-muted-foreground mx-auto mt-6 max-w-prose">
|
<p class="text-muted-foreground mx-auto mt-6 max-w-prose">
|
||||||
Send a few notes about your home and the feeling you're hoping for.
|
{{ t('home.cta.body') }}
|
||||||
</p>
|
</p>
|
||||||
<Button as-child class="mt-10">
|
<Button as-child class="mt-10">
|
||||||
<RouterLink to="/contact">Begin a conversation</RouterLink>
|
<RouterLink to="/contact">{{ t('home.cta.action') }}</RouterLink>
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,39 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AspectRatio } from '@/components/ui/aspect-ratio'
|
import { AspectRatio } from '@/components/ui/aspect-ratio'
|
||||||
import { projects } from '@/data/projects'
|
import { projects } from '@/data/projects'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const projectCards = computed(() =>
|
||||||
|
projects.map((p) => ({
|
||||||
|
slug: p.slug,
|
||||||
|
cover: p.cover,
|
||||||
|
coverAlt: t(`projects.${p.slug}.coverAlt`),
|
||||||
|
name: t(`projects.${p.slug}.name`),
|
||||||
|
eyebrow: t(`projects.${p.slug}.eyebrow`),
|
||||||
|
})),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-background">
|
<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">
|
<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>
|
<p class="eyebrow">{{ t('portfolio.eyebrow') }}</p>
|
||||||
<h1 class="mt-3 max-w-3xl font-serif text-4xl font-light tracking-tight md:text-6xl">
|
<h1 class="mt-3 max-w-3xl font-serif text-4xl font-light tracking-tight md:text-6xl">
|
||||||
Two homes, one sensibility.
|
{{ t('portfolio.headline') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-foreground/70 mt-6 max-w-2xl text-base md:text-lg">
|
<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;
|
{{ t('portfolio.intro') }}
|
||||||
Asheville into matte black and architectural shadow. The throughline is restraint
|
|
||||||
and material honesty.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mx-auto max-w-[1400px] px-6 pb-24 md:px-10 md:pb-32">
|
<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">
|
<div class="grid gap-10 md:grid-cols-2 md:gap-14">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="project in projects"
|
v-for="project in projectCards"
|
||||||
:key="project.slug"
|
:key="project.slug"
|
||||||
:to="`/portfolio/${project.slug}`"
|
:to="`/portfolio/${project.slug}`"
|
||||||
class="group block"
|
class="group block"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue