Compare commits

..

10 commits

Author SHA1 Message Date
c65ee029dd fix: escape @ in i18n messages, set useScope:'global' everywhere
Vue-i18n treats a bare '@' as the start of a linked-message reference,
which made every placeholder containing one — you{'@'}example.com,
the Telegram '{'@'}yourname' hint, the bad-Telegram error — crash the
compiler with "Invalid linked format". Escaping each '@' as the
literal {'@'} in en/es/fr fixes the compile and renders as a plain
'@' to the visitor.

Separately, every useI18n() call now passes { useScope: 'global' }.
Without it, components mounted inside <Form> / <Field> contexts
couldn't find a parent i18n scope and vue-i18n logged "Not found
parent scope. use the global scope." on every render. Explicit
global scope silences the warning and matches what the app
actually intends — there are no per-component message bundles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:39:19 +02:00
00e77ecf05 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>
2026-05-27 11:33:57 +02:00
f0980d7fb5 feat: four OKLCH palette options + dev-only switcher
Extracts each palette into its own module under src/themes/, scoped
to :root[data-palette='<slug>'] so multiple can coexist and a JS
attribute swap is enough to flip the whole site:

- stone-warm — bone + matte-black, gallery-quiet (current default)
- desert-clay — warm cream with terracotta undertone, evening light
- forest-ash — sage-tinted neutral with mossy accent, nature-leaning
- charcoal-cream — high-contrast monochrome, Asheville moody by default

usePalette() reads ?palette=<slug>, falls back to localStorage, then
to stone-warm. Active palette is written to <html data-palette>.
PaletteSwitcher (the round Palette icon at bottom-right) only renders
when import.meta.env.DEV is true — Nicholette can use it to compare
the four side-by-side, but it ships out of the production bundle.

To make a different palette the production default, edit the bare
:root + .dark blocks at the bottom of themes/index.css. That keeps
the runtime swap working as a preview channel and lets us ship a
single hard-coded chrome once she chooses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:29:48 +02:00
d9f52c6e7b docs: rewrite README for the Earth Walker site
Replaces the boilerplate's generic stack-and-versioning notes with
project-specific deployment guidance: routes, env vars, the
inquiry-delivery flow (so future maintainers understand the Nostr
plumbing isn't optional), image discipline (the anonymity rule plus
the imagemagick incantation that normalizes new assets), and the
add-a-project recipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:26:18 +02:00
533e94352f feat: real home page — hero, studio voice, project teasers, CTA
Five-section composition that lets the visitor judge the studio in
under one screen plus one scroll: full-bleed living-room hero with
the brand line in Fraunces overlay, a centered Studio paragraph
that picks the brief's own words ("oversized windows … warm woods …
matte black accents … refined but deeply human"), a two-up Selected
work grid linking to Boulder and Asheville, and a closing CTA.

The hero leans on Boulder 08 (mid-century living room with emerald
tile) — it reads as restorative and inviting, the right note for an
above-the-fold image; the Asheville moodiness comes through on the
project teaser card immediately below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:25:28 +02:00
def88eacad feat: contact form — method selector, zod validation, Nostr delivery
ContactForm wires vee-validate + zod against the inquiry payload:
optional name, required method (Email/WhatsApp/Signal/Telegram/Nostr),
contact value validated per method (email regex, phone-or-handle,
@handle, npub1 prefix), and a 10-2000 char message. On submit the
form calls submitInquiry() from the Nostr feature and toasts the
result — partial relay acceptance still counts as success and is
surfaced to the visitor.

PrivacyBlurb sits above the form explaining the model in plain
language: encrypted in the browser, delivered through Nostr, no
server in between. Lock icon plus terse copy — the goal is to put a
non-Nostr-native visitor at ease without a wall of jargon.

.env.example documents the two build-time vars (VITE_OWNER_NPUB,
VITE_NOSTR_RELAYS) the form depends on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:24:32 +02:00
dd4c87b548 feat: nostr-tools + ephemeral inquiry submission helper
The contact form's submissions land in the client's Nostr inbox
without ever touching a server we control. submitInquiry generates a
throwaway secp256k1 keypair per submission, wraps the payload as a
NIP-17 / NIP-59 gift-wrapped kind:1059 event (NIP-44 v2 encryption
inside), publishes through SimplePool to the configured relays, and
discards the ephemeral key. The visitor leaves no persistent identity.

VITE_OWNER_NPUB (the recipient) and VITE_NOSTR_RELAYS (optional
override of the default damus/nos.lol/relay.nostr.band set) are
read at build time. env.d.ts grows typed declarations so the rest
of the app catches typos at compile time.

The store is the boilerplate feature README's prescribed shape —
SimplePool + relays ref + Promise.allSettled-wrapped publish —
extended only with env-driven relay parsing. submitInquiry returns
{ok, acceptedBy, attempted} so the form can surface partial-relay
failures honestly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:22:47 +02:00
a79f4a32c7 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>
2026-05-27 11:20:30 +02:00
52cd156e43 fix: align shadcn-vue components with @lucide/vue + drop redundant baseUrl
The shadcn-vue CLI still emits 'lucide-vue-next' imports even though
the package is deprecated upstream; this boilerplate uses @lucide/vue
(the new home). Replace the imports across every generated component
that pulls icons (dialog, dropdown-menu, select, sheet, sonner).

tsconfig.app.json no longer needs the explicit baseUrl='.' I added
earlier — @vue/tsconfig/tsconfig.dom.json already provides the right
default, and TS6 was warning about it. The root tsconfig.json keeps
baseUrl + ignoreDeprecations because the shadcn-vue CLI reads the
root config and needs it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:20:07 +02:00
5fa3e55392 feat: add Boulder project images; strip metadata from all photos
Twelve Boulder originals pulled from the Wix CDN, resized to a 2400px
long-edge cap with mozjpeg-style 4:2:0 sampling at quality 82 — 120MB
of originals collapses to ~6.5MB, well within reason for a static-site
repo. Filenames are sequence-numbered (01.jpg…12.jpg); narrative order
gets curated in projects.ts.

Re-runs the same -strip pass over the five Asheville Signal images.
The original Signal files carried roughly 2x their stripped weight in
embedded metadata; ImageMagick strips EXIF/GPS/timestamp/maker fields
without touching the visible content. Net: anonymity-first defaults
hold for every image the site serves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:16:24 +02:00
64 changed files with 1927 additions and 146 deletions

15
.env.example Normal file
View file

@ -0,0 +1,15 @@
# Earth Walker Design — site env vars
#
# Both are inlined at build time by Vite. To rotate either, edit
# values here, then rebuild + redeploy.
# The Nostr public key that receives encrypted inquiry submissions.
# Bech32 npub1... form. Generate via `nak key generate` (fiatjaf/nak)
# or any Nostr client.
VITE_OWNER_NPUB=
# Optional. Comma-separated wss:// relay URLs the inquiry form
# publishes to. If unset, defaults to:
# wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
# The submission succeeds if at least one relay accepts the event.
VITE_NOSTR_RELAYS=

150
README.md
View file

@ -1,74 +1,122 @@
# boilerplate-website
# Earth Walker Design
Opinionated Vue 3 starter — the base every new aiolabs website forks from.
Studio site for an interior designer. Vue 3 + Vite + shadcn-vue +
Tailwind 4. Anonymous-by-default contact: inquiry submissions are
encrypted in the visitor's browser and delivered to the designer's
Nostr inbox.
## Stack
## Routes
- **Vue 3.5** + **Vite 8** + **TypeScript 6**
- **shadcn-vue** (via `reka-ui` + `class-variance-authority` + `tailwind-merge`) — components.json pre-wired, run `pnpm dlx shadcn-vue@latest add <name>` to copy components in
- **Tailwind CSS 4** (via `@tailwindcss/vite` — no `tailwind.config.js`)
- **Pinia 3** — sample `useCounterStore` in `src/stores/`
- **Vue Router 5** — file in `src/router/index.ts`, lazy-loaded views in `src/views/`
- **Vue I18n 11**`src/i18n/locales/{en,es}.json`
- **vee-validate 4** + **zod 3** — form validation primitives (zod pinned to ^3 until `@vee-validate/zod` ships a v4-compatible resolver)
- **@lucide/vue** — icon set (`lucide-vue-next` is deprecated upstream)
- **ESLint 10** (flat config) + **Prettier 3**
| Path | Purpose |
|---|---|
| `/` | Hero, studio voice, two project teasers, inquiry CTA |
| `/portfolio` | Two-up project grid (Boulder, Asheville) |
| `/portfolio/boulder` | Boulder project detail — editorial image scroll + lightbox |
| `/portfolio/asheville` | Asheville project detail |
| `/contact` | Inquiry form (Nostr-delivered) |
## Quick start
## Local dev
```sh
pnpm install
pnpm dev # vite dev server
pnpm build # type-check + production build
cp .env.example .env # fill in VITE_OWNER_NPUB
pnpm dev # http://localhost:5173
pnpm build # type-check + production build to dist/
pnpm preview # serve dist/
pnpm lint
pnpm format
```
## Layout
The contact form requires `VITE_OWNER_NPUB` to be set — without it,
`submitInquiry` throws so visitors see an explicit error rather
than silently dropping submissions. Generate one with
[`nak key generate`](https://github.com/fiatjaf/nak) or any Nostr client.
## Env vars
| Var | Required | Default | Notes |
|---|---|---|---|
| `VITE_OWNER_NPUB` | yes | — | The designer's Nostr public key (`npub1…`). Inquiries are NIP-17 gift-wrapped to this key. |
| `VITE_NOSTR_RELAYS` | no | `wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band` | Comma-separated wss:// list. Submission succeeds if any one relay accepts. |
Both are inlined at build time by Vite. Rotating either requires a
rebuild + redeploy.
## How the contact form delivers inquiries
1. Visitor fills the form (name optional, contact method + value,
message). Validation runs client-side via `vee-validate` + `zod`.
2. `submitInquiry()` (`src/features/nostr/submitInquiry.ts`):
- generates a fresh ephemeral secp256k1 keypair
- decodes `VITE_OWNER_NPUB` to hex
- calls `nip17.wrapEvent()` to produce a kind:1059 gift-wrap with
NIP-44 v2 encryption inside (the visitor's identity is the
throwaway key, not anything they entered)
- publishes via `SimplePool` to the configured relays in parallel
3. The designer receives the inquiry as a DM in any NIP-17 capable
Nostr client (Damus, Amethyst, 0xchat, etc.) signed in with the
matching nsec.
No server in between stores the message. There is no inbox to leak.
## Theming
Light + dark stone-warm palettes live in `src/style.css` as OKLCH
CSS variables. Toggle persists to `localStorage` under
`ewd:theme` (see `src/composables/useTheme.ts`). Typography pairs
Fraunces (display, h1h3, brand wordmark) with Inter (UI/body) via
Bunny Fonts, declared in `index.html` and bound through Tailwind 4
`@theme` directives.
To recolor: edit the OKLCH triples in `src/style.css` under `:root`
and `.dark`. Keep all shadcn-vue token names intact.
## Image discipline
All images live under `public/images/<project>/`. The build pipeline
expects:
- Filenames are sequence-numbered (`01.jpg`, `02.jpg`, …) — never
leak addresses, neighborhood names, or owner identities in the
filename.
- EXIF / GPS / timestamp / maker metadata is stripped before commit.
Re-run on new assets:
```sh
nix-shell -p imagemagick --run \
'for f in *.jpg; do magick "$f" -auto-orient -strip -resize "2400x2400>" \
-interlace Plane -sampling-factor 4:2:0 -quality 82 "${f}.tmp" \
&& mv "${f}.tmp" "$f"; done'
```
src/
├─ App.vue # router-view shell
├─ main.ts # plugin wiring
├─ style.css # tailwind + shadcn-vue CSS variables
├─ lib/utils.ts # cn() — shadcn-vue's class merger
├─ router/index.ts
├─ stores/counter.ts # example pinia store
├─ i18n/
│ ├─ index.ts
│ └─ locales/{en,es}.json
├─ views/HomeView.vue # proof-of-wiring page (i18n + pinia + tailwind)
└─ features/
├─ nostr/README.md # opt-in: nostr-tools wiring (see file)
└─ lnbits/README.md # opt-in: LNbits payments wiring (see file)
- Alt text in `src/data/projects.ts` describes the room/feature, not
the location.
## Adding a project
1. Drop sequence-numbered images into `public/images/<slug>/`.
2. Add a new `Project` entry in `src/data/projects.ts` (typed) with
`slug`, `name`, `eyebrow`, `intro`, `cover`, `coverAlt`, and an
`images[]` array. Each image takes a `feature` tag
(`hero` | `wide` | `narrow` | `paired`) that drives the layout
slot in `ProjectDetail.vue`'s editorial scroll.
3. Add a 4-line view at `src/views/projects/<Slug>View.vue`:
```vue
<script setup lang="ts">
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
import { mySlug } from '@/data/projects'
</script>
<template><ProjectDetail :project="mySlug" /></template>
```
4. Register the route in `src/router/index.ts`.
5. The `PortfolioView` already iterates `projects[]`, so the new
project's card appears automatically.
## Optional features
## Stack
Both nostr and LNbits live as **documentation-only** folders. The deps
aren't installed by default — bundle stays small for sites that don't
need them. Each folder's README walks you through enabling.
- **`src/features/nostr/`** — connect to relays, sign/publish events, contact forms that DM the site owner's npub
- **`src/features/lnbits/`** — create invoices, accept Lightning payments via an LNbits instance
## Versioning strategy
The boilerplate's `main` branch is **the "tools wired, no content" baseline**. New site = clone (or fork on Forgejo) → start adding content immediately.
To pull dep refreshes from the boilerplate into an existing site:
Tracking the upstream boilerplate (`aiolabs/boilerplate-website`).
To pull dependency refreshes:
```sh
git remote add boilerplate forgejo@git.atitlan.io:aiolabs/boilerplate-website.git
git fetch boilerplate
git merge boilerplate/main
```
When a site diverges enough to no longer benefit from these merges, drop the remote — it becomes its own thing.
## Keeping deps current
- The boilerplate gets dep bumps via PRs (Renovate / Dependabot configurable; otherwise periodic `pnpm update --latest` + manual review).
- The Vue ecosystem (vue, pinia, router, i18n, vee-validate) versions in lockstep — when one majors, the others usually follow within weeks.
- Tailwind 4 + shadcn-vue + reka-ui is the current modern combo. shadcn-vue uses tw-animate-css (not the deprecated tailwindcss-animate plugin).

11
env.d.ts vendored
View file

@ -1 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Hex- or bech32-encoded npub of the inquiry recipient. */
readonly VITE_OWNER_NPUB?: string
/** Comma-separated wss:// relay list; falls back to a default set if unset. */
readonly VITE_NOSTR_RELAYS?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View file

@ -17,6 +17,7 @@
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"nostr-tools": "^2.23.5",
"pinia": "^3.0.4",
"reka-ui": "^2.9.8",
"tailwind-merge": "^3.6.0",

70
pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
nostr-tools:
specifier: ^2.23.5
version: 2.23.5(typescript@6.0.3)
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
@ -257,6 +260,18 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
'@noble/curves@2.0.1':
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -374,6 +389,15 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@scure/base@2.0.0':
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
'@scure/bip32@2.0.1':
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
'@scure/bip39@2.0.1':
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@ -1126,6 +1150,17 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nostr-tools@2.23.5:
resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
nostr-wasm@0.1.0:
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@ -1667,6 +1702,14 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@noble/ciphers@2.1.1': {}
'@noble/curves@2.0.1':
dependencies:
'@noble/hashes': 2.0.1
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -1734,6 +1777,19 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@scure/base@2.0.0': {}
'@scure/bip32@2.0.1':
dependencies:
'@noble/curves': 2.0.1
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@scure/bip39@2.0.1':
dependencies:
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@ -2477,6 +2533,20 @@ snapshots:
natural-compare@1.4.0: {}
nostr-tools@2.23.5(typescript@6.0.3):
dependencies:
'@noble/ciphers': 2.1.1
'@noble/curves': 2.0.1
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@scure/bip32': 2.0.1
'@scure/bip39': 2.0.1
nostr-wasm: 0.1.0
optionalDependencies:
typescript: 6.0.3
nostr-wasm@0.1.0: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 526 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 704 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View file

@ -0,0 +1,220 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { submitInquiry, type ContactMethod } from '@/features/nostr/submitInquiry'
const { t } = useI18n({ useScope: 'global' })
const METHODS: ContactMethod[] = ['email', 'whatsapp', 'signal', 'telegram', 'nostr']
const methodItems = computed(() =>
METHODS.map((m) => ({
value: m,
label: t(`methods.${m}`),
placeholder: t(`methodPlaceholders.${m}`),
})),
)
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PHONE_RE = /^\+?[\d\s().-]{7,}$/
const HANDLE_RE = /^@?[A-Za-z0-9_.]{3,32}$/
const Schema = computed(() =>
z
.object({
name: z
.string()
.trim()
.max(80, t('form.errors.nameTooLong'))
.optional()
.or(z.literal('')),
contactMethod: z.enum(['email', 'whatsapp', 'signal', 'telegram', 'nostr'], {
message: t('form.errors.methodMissing'),
}),
contactValue: z
.string()
.trim()
.min(2, t('form.errors.valueRequired'))
.max(200, t('form.errors.valueTooLong')),
message: z
.string()
.trim()
.min(10, t('form.errors.messageTooShort'))
.max(2000, t('form.errors.messageTooLong')),
})
.superRefine((data, ctx) => {
const v = data.contactValue
const fail = (msg: string) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contactValue'], message: msg })
switch (data.contactMethod) {
case 'email':
if (!EMAIL_RE.test(v)) fail(t('form.errors.badEmail'))
break
case 'whatsapp':
case 'signal':
if (!PHONE_RE.test(v) && !HANDLE_RE.test(v)) fail(t('form.errors.badPhoneOrHandle'))
break
case 'telegram':
if (!HANDLE_RE.test(v)) fail(t('form.errors.badTelegram'))
break
case 'nostr':
if (!/^npub1[0-9a-z]{30,}$/.test(v)) fail(t('form.errors.badNpub'))
break
}
}),
)
type FormValues = {
name?: string
contactMethod: ContactMethod
contactValue: string
message: string
}
const { handleSubmit, isSubmitting, resetForm, values } = useForm<FormValues>({
validationSchema: computed(() => toTypedSchema(Schema.value)),
initialValues: { name: '', contactMethod: 'email', contactValue: '', message: '' },
})
const submitting = ref(false)
const onSubmit = handleSubmit(async (vals) => {
submitting.value = true
try {
const result = await submitInquiry({
name: vals.name?.trim() || undefined,
contactMethod: vals.contactMethod,
contactValue: vals.contactValue.trim(),
message: vals.message.trim(),
})
if (result.ok) {
toast.success(t('form.toast.successTitle'), {
description:
result.acceptedBy === result.attempted
? t('form.toast.successFull')
: t('form.toast.successPartial', {
accepted: result.acceptedBy,
attempted: result.attempted,
}),
})
resetForm()
} else {
toast.error(t('form.toast.noRelaysTitle'), {
description: t('form.toast.noRelaysBody'),
})
}
} catch (err) {
toast.error(t('form.toast.failedTitle'), {
description: err instanceof Error ? err.message : t('form.toast.unknownError'),
})
} finally {
submitting.value = false
}
})
function placeholderFor(method: string | undefined): string {
return methodItems.value.find((m) => m.value === method)?.placeholder ?? ''
}
</script>
<template>
<form class="space-y-7" novalidate @submit="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>
{{ t('form.name.label') }}
<span class="text-muted-foreground">{{ t('form.name.optional') }}</span>
</FormLabel>
<FormControl>
<Input
type="text"
autocomplete="name"
:placeholder="t('form.name.placeholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="grid gap-7 sm:grid-cols-[180px_1fr]">
<FormField v-slot="{ componentField }" name="contactMethod">
<FormItem>
<FormLabel>{{ t('form.method.label') }}</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue :placeholder="t('form.method.placeholder')" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="m in methodItems" :key="m.value" :value="m.value">
{{ m.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="contactValue">
<FormItem>
<FormLabel>{{ t('form.value.label') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="placeholderFor(values.contactMethod)"
v-bind="componentField"
/>
</FormControl>
<FormDescription class="text-xs">
{{ t('form.value.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>{{ t('form.message.label') }}</FormLabel>
<FormControl>
<Textarea
rows="6"
:placeholder="t('form.message.placeholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="flex items-center justify-end pt-2">
<Button type="submit" :disabled="isSubmitting || submitting" class="min-w-36">
{{ isSubmitting || submitting ? t('form.submit.sending') : t('form.submit.idle') }}
</Button>
</div>
</form>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import { Lock } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n({ useScope: 'global' })
</script>
<template>
<aside
class="border-border/60 text-muted-foreground bg-muted/30 rounded-sm border p-5 text-sm leading-relaxed"
>
<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" />
<span>{{ t('privacyBlurb.title') }}</span>
</div>
<i18n-t keypath="privacyBlurb.body" tag="p">
<template #nostr>
<span class="text-foreground">{{ t('privacyBlurb.nostrLabel') }}</span>
</template>
</i18n-t>
</aside>
</template>

View file

@ -3,6 +3,9 @@ import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import SiteHeader from './SiteHeader.vue'
import SiteFooter from './SiteFooter.vue'
import PaletteSwitcher from './PaletteSwitcher.vue'
const isDev = import.meta.env.DEV
</script>
<template>
@ -13,5 +16,6 @@ import SiteFooter from './SiteFooter.vue'
</main>
<SiteFooter />
<Toaster position="bottom-right" />
<PaletteSwitcher v-if="isDev" />
</div>
</template>

View 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({ useScope: 'global' })
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>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { Palette } from '@lucide/vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PALETTES, usePalette, type PaletteSlug } from '@/composables/usePalette'
const { palette, set } = usePalette()
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger
class="border-border bg-background/85 hover:bg-background fixed right-4 bottom-4 z-50 inline-flex h-10 w-10 items-center justify-center rounded-full border shadow-lg backdrop-blur transition-colors"
aria-label="Switch color palette"
>
<Palette class="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" class="w-72">
<DropdownMenuLabel class="text-xs uppercase tracking-[0.18em]">
Palette (dev only)
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="p in PALETTES"
:key="p.slug"
class="flex flex-col items-start gap-0.5 py-2.5"
:class="palette === p.slug ? 'bg-accent' : ''"
@select="set(p.slug as PaletteSlug)"
>
<span class="font-serif text-sm">{{ p.label }}</span>
<span class="text-muted-foreground text-xs">{{ p.description }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n({ useScope: 'global' })
const year = new Date().getFullYear()
</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"
>
<div class="space-y-1">
<p class="font-serif text-base tracking-[0.18em] uppercase">Earth Walker</p>
<p class="text-muted-foreground text-xs">
Interior design by appointment.
</p>
<p class="font-serif text-base tracking-[0.18em] uppercase">{{ t('brand') }}</p>
<p class="text-muted-foreground text-xs">{{ t('footer.tagline') }}</p>
</div>
<div class="flex items-center gap-8">
<RouterLink
to="/contact"
class="text-foreground/70 hover:text-foreground text-xs uppercase tracking-[0.22em] transition-colors"
>
Inquire
{{ t('nav.inquire') }}
</RouterLink>
<p class="text-muted-foreground text-xs">© {{ year }}</p>
</div>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Menu } from '@lucide/vue'
import {
Sheet,
@ -10,11 +11,14 @@ import {
SheetTrigger,
} from '@/components/ui/sheet'
import ThemeToggle from './ThemeToggle.vue'
import LocaleSwitcher from './LocaleSwitcher.vue'
const navItems = [
{ to: '/portfolio', label: 'Portfolio' },
{ to: '/contact', label: 'Contact' },
]
const { t } = useI18n({ useScope: 'global' })
const navItems = computed(() => [
{ to: '/portfolio', label: t('nav.portfolio') },
{ to: '/contact', label: t('nav.contact') },
])
const mobileOpen = ref(false)
</script>
@ -30,10 +34,10 @@ const mobileOpen = ref(false)
to="/"
class="text-foreground font-serif text-lg tracking-[0.18em] uppercase"
>
Earth Walker
{{ t('brand') }}
</RouterLink>
<nav class="hidden items-center gap-10 md:flex">
<nav class="hidden items-center gap-8 md:flex">
<RouterLink
v-for="item in navItems"
:key="item.to"
@ -43,22 +47,24 @@ const mobileOpen = ref(false)
>
{{ item.label }}
</RouterLink>
<LocaleSwitcher />
<ThemeToggle />
</nav>
<div class="flex items-center gap-1 md:hidden">
<LocaleSwitcher />
<ThemeToggle />
<Sheet v-model:open="mobileOpen">
<SheetTrigger
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" />
</SheetTrigger>
<SheetContent side="right" class="w-full sm:max-w-sm">
<SheetHeader>
<SheetTitle class="font-serif tracking-[0.18em] uppercase">
Earth Walker
{{ t('brand') }}
</SheetTitle>
</SheetHeader>
<nav class="mt-10 flex flex-col gap-6 px-4">

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Moon, Sun } from '@lucide/vue'
import { useTheme } from '@/composables/useTheme'
const { t } = useI18n({ useScope: 'global' })
const { theme, toggle } = useTheme()
</script>
@ -9,7 +11,7 @@ const { theme, toggle } = useTheme()
<button
type="button"
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"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import ProjectImage from './ProjectImage.vue'
import type { Project } from '@/data/projects'
const props = defineProps<{ project: Project }>()
const { t } = useI18n({ useScope: 'global' })
const hero = computed(() => props.project.images.find((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>
<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">{{ eyebrow }}</p>
<h1 class="mt-3 font-serif text-5xl font-light tracking-tight md:text-7xl">
{{ 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"
>
{{ 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">
<ProjectImage v-if="img.feature === 'wide'" :image="img" />
<div v-else-if="img.feature === 'narrow'" class="mx-auto max-w-3xl">
<ProjectImage :image="img" />
</div>
<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>
<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">{{ t('projectDetail.cta.eyebrow') }}</p>
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-4xl">
{{ t('projectDetail.cta.headline') }}
</h2>
<Button as-child class="mt-8">
<RouterLink to="/contact">{{ t('projectDetail.cta.action') }}</RouterLink>
</Button>
</section>
</article>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import type { ProjectImage } from '@/data/projects'
const props = defineProps<{ image: ProjectImage }>()
const { t } = useI18n({ useScope: 'global' })
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="t('projectDetail.viewLarger', { alt: props.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>

View file

@ -2,7 +2,7 @@
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import { X } from "@lucide/vue"
import {
DialogClose,
DialogContent,

View file

@ -2,7 +2,7 @@
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import { X } from "@lucide/vue"
import {
DialogClose,
DialogContent,

View file

@ -2,7 +2,7 @@
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { Check } from "@lucide/vue"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,

View file

@ -2,7 +2,7 @@
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import { Circle } from "@lucide/vue"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,

View file

@ -2,7 +2,7 @@
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { ChevronRight } from "@lucide/vue"
import {
DropdownMenuSubTrigger,
useForwardProps,

View file

@ -2,7 +2,7 @@
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { Check } from "@lucide/vue"
import {
SelectItem,
SelectItemIndicator,

View file

@ -2,7 +2,7 @@
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { ChevronDown } from "@lucide/vue"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"

View file

@ -2,7 +2,7 @@
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUp } from "lucide-vue-next"
import { ChevronUp } from "@lucide/vue"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"

View file

@ -2,7 +2,7 @@
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { ChevronDown } from "@lucide/vue"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"

View file

@ -3,7 +3,7 @@ import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { SheetVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import { X } from "@lucide/vue"
import {
DialogClose,
DialogContent,

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { reactiveOmit } from "@vueuse/core"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "@lucide/vue"
import { Toaster as Sonner } from "vue-sonner"
const props = defineProps<ToasterProps>()

View file

@ -0,0 +1,54 @@
import { ref, watchEffect } from 'vue'
export const PALETTES = [
{
slug: 'stone-warm',
label: 'Stone Warm',
description: 'Bone background, matte-black ink. Gallery-quiet.',
},
{
slug: 'desert-clay',
label: 'Desert Clay',
description: 'Warm cream with terracotta undertone. Evening light.',
},
{
slug: 'forest-ash',
label: 'Forest & Ash',
description: 'Sage-tinted neutral, mossy accent. Nature integrated.',
},
{
slug: 'charcoal-cream',
label: 'Charcoal & Cream',
description: 'High-contrast monochrome. Moody architectural.',
},
] as const
export type PaletteSlug = (typeof PALETTES)[number]['slug']
const STORAGE_KEY = 'ewd:palette'
const DEFAULT: PaletteSlug = 'stone-warm'
function initial(): PaletteSlug {
if (typeof window === 'undefined') return DEFAULT
const url = new URL(window.location.href)
const fromQuery = url.searchParams.get('palette') as PaletteSlug | null
if (fromQuery && PALETTES.some((p) => p.slug === fromQuery)) return fromQuery
const stored = window.localStorage.getItem(STORAGE_KEY) as PaletteSlug | null
if (stored && PALETTES.some((p) => p.slug === stored)) return stored
return DEFAULT
}
const palette = ref<PaletteSlug>(initial())
watchEffect(() => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-palette', palette.value)
window.localStorage.setItem(STORAGE_KEY, palette.value)
})
export function usePalette() {
function set(slug: PaletteSlug) {
palette.value = slug
}
return { palette, set }
}

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

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

View file

@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { SimplePool, type Event } from 'nostr-tools'
const DEFAULT_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
]
function parseRelays(): string[] {
const env = import.meta.env.VITE_NOSTR_RELAYS
if (!env) return DEFAULT_RELAYS
const list = env
.split(',')
.map((r: string) => r.trim())
.filter((r: string) => r.startsWith('wss://') || r.startsWith('ws://'))
return list.length ? list : DEFAULT_RELAYS
}
export const useNostrStore = defineStore('nostr', () => {
const pool = new SimplePool()
const relays = ref<string[]>(parseRelays())
async function publish(event: Event): Promise<PromiseSettledResult<string>[]> {
return Promise.allSettled(pool.publish(relays.value, event))
}
return { relays, publish }
})

View file

@ -0,0 +1,69 @@
import { nip17, nip19 } from 'nostr-tools'
import { generateSecretKey } from 'nostr-tools/pure'
import { useNostrStore } from './store'
export type ContactMethod = 'email' | 'whatsapp' | 'signal' | 'telegram' | 'nostr'
export interface InquiryPayload {
name?: string
contactMethod: ContactMethod
contactValue: string
message: string
}
export interface SubmitResult {
ok: boolean
acceptedBy: number
attempted: number
}
const METHOD_LABEL: Record<ContactMethod, string> = {
email: 'Email',
whatsapp: 'WhatsApp',
signal: 'Signal',
telegram: 'Telegram',
nostr: 'Nostr',
}
function ownerHex(): string {
const npub = import.meta.env.VITE_OWNER_NPUB
if (!npub) {
throw new Error(
'VITE_OWNER_NPUB is not set — the inquiry form has no destination.',
)
}
const decoded = nip19.decode(npub.trim() as `npub1${string}`)
if (decoded.type !== 'npub') {
throw new Error(
`VITE_OWNER_NPUB must be an npub (got ${(decoded as { type: string }).type}).`,
)
}
return decoded.data
}
function formatMessage(p: InquiryPayload): string {
const lines: string[] = []
lines.push('New inquiry — Earth Walker Design')
lines.push('')
if (p.name) lines.push(`From: ${p.name}`)
lines.push(`Preferred contact: ${METHOD_LABEL[p.contactMethod]}${p.contactValue}`)
lines.push('')
lines.push(p.message)
return lines.join('\n')
}
export async function submitInquiry(payload: InquiryPayload): Promise<SubmitResult> {
const recipient = ownerHex()
const senderSk = generateSecretKey()
const wrapped = nip17.wrapEvent(senderSk, { publicKey: recipient }, formatMessage(payload))
const nostr = useNostrStore()
const results = await nostr.publish(wrapped)
const accepted = results.filter((r) => r.status === 'fulfilled').length
return {
ok: accepted > 0,
acceptedBy: accepted,
attempted: results.length,
}
}

View file

@ -1,10 +1,36 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en.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({
legacy: false,
locale: 'en',
locale: initialLocale(),
fallbackLocale: 'en',
messages: { en, es },
messages: { en, es, fr },
})
export function persistLocale(code: LocaleCode) {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, code)
}
}

View file

@ -1,11 +1,134 @@
{
"app": {
"title": "Boilerplate Website"
"brand": "Earth Walker",
"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": {
"heading": "Welcome",
"intro": "Edit src/views/HomeView.vue to begin.",
"counter": "Count: {n}",
"increment": "Increment"
"hero": {
"eyebrow": "Earth Walker Design",
"headline": "Grounded, architectural,",
"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…"
}
}

View file

@ -1,11 +1,134 @@
{
"app": {
"title": "Plantilla de Sitio Web"
"brand": "Earth Walker",
"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": {
"heading": "Bienvenido",
"intro": "Edita src/views/HomeView.vue para empezar.",
"counter": "Cuenta: {n}",
"increment": "Incrementar"
"hero": {
"eyebrow": "Earth Walker Design",
"headline": "Interiores arraigados,",
"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
View 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…"
}
}

View file

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

View file

@ -1,53 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './themes/index.css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
--radius: 0.25rem;
}
.dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

View file

@ -0,0 +1,50 @@
/* Palette: Charcoal & Cream
* High-contrast monochrome. Warm cream background, near-black warm
* charcoal ink, deep-bronze accent for the active states. The
* Asheville mood by default moody, architectural, restrained.
* In dark mode it goes deeper still.
*/
:root[data-palette='charcoal-cream'] {
--background: oklch(0.95 0.013 75);
--foreground: oklch(0.16 0.005 60);
--card: oklch(0.95 0.013 75);
--card-foreground: oklch(0.16 0.005 60);
--popover: oklch(0.95 0.013 75);
--popover-foreground: oklch(0.16 0.005 60);
--primary: oklch(0.16 0.005 60);
--primary-foreground: oklch(0.95 0.013 75);
--secondary: oklch(0.9 0.014 75);
--secondary-foreground: oklch(0.16 0.005 60);
--muted: oklch(0.9 0.014 75);
--muted-foreground: oklch(0.45 0.012 60);
--accent: oklch(0.86 0.025 70);
--accent-foreground: oklch(0.16 0.005 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.95 0.013 75);
--border: oklch(0.82 0.014 70);
--input: oklch(0.82 0.014 70);
--ring: oklch(0.16 0.005 60);
}
:root[data-palette='charcoal-cream'].dark {
--background: oklch(0.12 0.004 60);
--foreground: oklch(0.94 0.013 75);
--card: oklch(0.14 0.004 60);
--card-foreground: oklch(0.94 0.013 75);
--popover: oklch(0.14 0.004 60);
--popover-foreground: oklch(0.94 0.013 75);
--primary: oklch(0.94 0.013 75);
--primary-foreground: oklch(0.14 0.004 60);
--secondary: oklch(0.2 0.006 60);
--secondary-foreground: oklch(0.94 0.013 75);
--muted: oklch(0.2 0.006 60);
--muted-foreground: oklch(0.68 0.012 70);
--accent: oklch(0.26 0.015 65);
--accent-foreground: oklch(0.94 0.013 75);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.013 75);
--border: oklch(0.24 0.008 60);
--input: oklch(0.24 0.008 60);
--ring: oklch(0.85 0.014 75);
}

View file

@ -0,0 +1,49 @@
/* Palette: Desert Clay
* Warm creamy background with a terracotta undertone, espresso ink,
* burnt-clay accent. Matches Boulder's reclaimed wood / warm pendant
* mood. Reads as evening light, not gallery white.
*/
:root[data-palette='desert-clay'] {
--background: oklch(0.97 0.012 65);
--foreground: oklch(0.22 0.018 45);
--card: oklch(0.97 0.012 65);
--card-foreground: oklch(0.22 0.018 45);
--popover: oklch(0.97 0.012 65);
--popover-foreground: oklch(0.22 0.018 45);
--primary: oklch(0.4 0.07 35);
--primary-foreground: oklch(0.97 0.012 65);
--secondary: oklch(0.92 0.018 60);
--secondary-foreground: oklch(0.22 0.018 45);
--muted: oklch(0.93 0.014 60);
--muted-foreground: oklch(0.5 0.025 45);
--accent: oklch(0.86 0.04 45);
--accent-foreground: oklch(0.22 0.018 45);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.012 65);
--border: oklch(0.86 0.018 55);
--input: oklch(0.86 0.018 55);
--ring: oklch(0.4 0.07 35);
}
:root[data-palette='desert-clay'].dark {
--background: oklch(0.17 0.014 40);
--foreground: oklch(0.94 0.014 70);
--card: oklch(0.2 0.014 40);
--card-foreground: oklch(0.94 0.014 70);
--popover: oklch(0.2 0.014 40);
--popover-foreground: oklch(0.94 0.014 70);
--primary: oklch(0.85 0.05 50);
--primary-foreground: oklch(0.2 0.014 40);
--secondary: oklch(0.27 0.018 45);
--secondary-foreground: oklch(0.94 0.014 70);
--muted: oklch(0.27 0.018 45);
--muted-foreground: oklch(0.7 0.02 55);
--accent: oklch(0.34 0.045 45);
--accent-foreground: oklch(0.94 0.014 70);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.014 70);
--border: oklch(0.3 0.018 45);
--input: oklch(0.3 0.018 45);
--ring: oklch(0.8 0.04 50);
}

49
src/themes/forest-ash.css Normal file
View file

@ -0,0 +1,49 @@
/* Palette: Forest & Ash
* Cool sage-tinted background, charcoal ink, mossy accent. The
* "nature integrated" word from the brief made literal picks up
* the Boulder emerald tile and the Asheville pine-window framing.
*/
:root[data-palette='forest-ash'] {
--background: oklch(0.97 0.008 150);
--foreground: oklch(0.2 0.012 150);
--card: oklch(0.97 0.008 150);
--card-foreground: oklch(0.2 0.012 150);
--popover: oklch(0.97 0.008 150);
--popover-foreground: oklch(0.2 0.012 150);
--primary: oklch(0.32 0.04 150);
--primary-foreground: oklch(0.97 0.008 150);
--secondary: oklch(0.93 0.014 145);
--secondary-foreground: oklch(0.2 0.012 150);
--muted: oklch(0.93 0.012 145);
--muted-foreground: oklch(0.5 0.018 145);
--accent: oklch(0.86 0.03 150);
--accent-foreground: oklch(0.2 0.012 150);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 150);
--border: oklch(0.87 0.014 150);
--input: oklch(0.87 0.014 150);
--ring: oklch(0.32 0.04 150);
}
:root[data-palette='forest-ash'].dark {
--background: oklch(0.16 0.012 150);
--foreground: oklch(0.94 0.01 150);
--card: oklch(0.19 0.012 150);
--card-foreground: oklch(0.94 0.01 150);
--popover: oklch(0.19 0.012 150);
--popover-foreground: oklch(0.94 0.01 150);
--primary: oklch(0.86 0.04 150);
--primary-foreground: oklch(0.19 0.012 150);
--secondary: oklch(0.26 0.016 150);
--secondary-foreground: oklch(0.94 0.01 150);
--muted: oklch(0.26 0.016 150);
--muted-foreground: oklch(0.7 0.014 145);
--accent: oklch(0.33 0.03 150);
--accent-foreground: oklch(0.94 0.01 150);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.01 150);
--border: oklch(0.29 0.016 150);
--input: oklch(0.29 0.016 150);
--ring: oklch(0.8 0.04 150);
}

60
src/themes/index.css Normal file
View file

@ -0,0 +1,60 @@
/* Palette registry each module declares its tokens scoped to
* :root[data-palette='<slug>']. usePalette() sets the attribute.
* A bare :root fallback below copies the stone-warm tokens so the
* page renders correctly before JS attaches the attribute and for
* users with JS disabled.
*/
@import './stone-warm.css';
@import './desert-clay.css';
@import './forest-ash.css';
@import './charcoal-cream.css';
/* Pre-hydration fallback: same as stone-warm. Kept here (not in
* stone-warm.css under :root) so swapping which palette is the
* default is one edit point this block at the values from a
* different theme module. */
:root {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
--radius: 0.25rem;
}
.dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}

48
src/themes/stone-warm.css Normal file
View file

@ -0,0 +1,48 @@
/* Palette: Stone Warm
* Bone background, matte-black ink, taupe muted. Gallery-quiet, the
* most conservative of the four. Closest to Studio McGee's chrome.
*/
:root[data-palette='stone-warm'] {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
}
:root[data-palette='stone-warm'].dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}

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

@ -0,0 +1,27 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import ContactForm from '@/components/contact/ContactForm.vue'
import PrivacyBlurb from '@/components/contact/PrivacyBlurb.vue'
const { t } = useI18n({ useScope: 'global' })
</script>
<template>
<div class="mx-auto max-w-2xl px-6 pt-20 pb-28 md:pt-28 md:pb-32">
<p class="eyebrow">{{ t('contact.eyebrow') }}</p>
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
{{ t('contact.headline') }}
</h1>
<p class="text-foreground/70 mt-6 max-w-prose text-base md:text-lg">
{{ t('contact.intro') }}
</p>
<div class="mt-10">
<PrivacyBlurb />
</div>
<div class="mt-10">
<ContactForm />
</div>
</div>
</template>

View file

@ -1,8 +1,124 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Button } from '@/components/ui/button'
import { projects } from '@/data/projects'
const { t } = useI18n({ useScope: 'global' })
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>
<template>
<main class="mx-auto max-w-2xl space-y-6 p-8">
<h1 class="text-3xl font-bold">Earth Walker Design</h1>
<p class="text-muted-foreground">Home view content arriving in a later commit.</p>
</main>
<div class="bg-background">
<!-- Hero -->
<section class="relative -mt-16 overflow-hidden">
<img
src="/images/boulder/08.jpg"
:alt="t('projects.boulder.coverAlt')"
class="h-screen min-h-[640px] w-full object-cover"
/>
<div
class="absolute inset-0 bg-gradient-to-b from-black/15 via-transparent to-black/45"
></div>
<div
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">
{{ t('home.hero.eyebrow') }}
</p>
<h1
class="mt-4 max-w-3xl font-serif text-4xl font-light leading-[1.05] tracking-tight md:text-6xl lg:text-7xl"
>
{{ t('home.hero.headline') }}<br />{{ t('home.hero.headline2') }}
</h1>
</div>
</section>
<!-- Philosophy -->
<section class="px-6 py-24 md:py-32">
<div class="mx-auto max-w-3xl text-center">
<p class="eyebrow">{{ t('home.studio.eyebrow') }}</p>
<p
class="text-foreground/80 mt-6 font-serif text-2xl font-light leading-relaxed md:text-3xl"
>
{{ t('home.studio.lead') }}
</p>
<p class="text-muted-foreground mt-8 text-base md:text-lg">
{{ t('home.studio.body') }}
</p>
</div>
</section>
<!-- Projects teaser -->
<section class="border-border/60 border-t px-6 py-20 md:px-10 md:py-28">
<div class="mx-auto max-w-[1400px]">
<div class="flex items-baseline justify-between">
<div>
<p class="eyebrow">{{ t('home.selected.eyebrow') }}</p>
<h2
class="mt-3 font-serif text-3xl font-light tracking-tight md:text-5xl"
>
{{ t('home.selected.headline') }}
</h2>
</div>
<RouterLink
to="/portfolio"
class="text-foreground/70 hover:text-foreground hidden text-xs uppercase tracking-[0.22em] transition-colors md:inline-flex"
>
{{ t('nav.viewAll') }}
</RouterLink>
</div>
<div class="mt-12 grid gap-10 md:grid-cols-2 md:gap-14">
<RouterLink
v-for="project in projectCards"
: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-5 flex items-baseline justify-between">
<h3 class="font-serif text-2xl font-light tracking-tight md:text-3xl">
{{ project.name }}
</h3>
<p class="eyebrow">{{ project.eyebrow }}</p>
</div>
</RouterLink>
</div>
</div>
</section>
<!-- Closing CTA -->
<section class="border-border/60 border-t px-6 py-24 text-center md:py-32 md:px-10">
<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">
{{ t('home.cta.headline') }}
</h2>
<p class="text-muted-foreground mx-auto mt-6 max-w-prose">
{{ t('home.cta.body') }}
</p>
<Button as-child class="mt-10">
<RouterLink to="/contact">{{ t('home.cta.action') }}</RouterLink>
</Button>
</section>
</div>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { projects } from '@/data/projects'
const { t } = useI18n({ useScope: 'global' })
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>
<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">{{ t('portfolio.eyebrow') }}</p>
<h1 class="mt-3 max-w-3xl font-serif text-4xl font-light tracking-tight md:text-6xl">
{{ t('portfolio.headline') }}
</h1>
<p class="text-foreground/70 mt-6 max-w-2xl text-base md:text-lg">
{{ t('portfolio.intro') }}
</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 projectCards"
:key="project.slug"
:to="`/portfolio/${project.slug}`"
class="group block"
>
<AspectRatio :ratio="4 / 5" class="overflow-hidden bg-muted">
<img
:src="project.cover"
:alt="project.coverAlt"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.03]"
/>
</AspectRatio>
<div class="mt-6 flex items-baseline justify-between">
<h2 class="font-serif text-2xl font-light tracking-tight md:text-3xl">
{{ project.name }}
</h2>
<p class="eyebrow">{{ project.eyebrow }}</p>
</div>
</RouterLink>
</div>
</section>
</div>
</template>

View file

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

View file

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

View file

@ -4,7 +4,6 @@
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}