Compare commits
No commits in common. "c65ee029dd3f50142408d2e6727e9ab70c2266df" and "b1dae9d7e8881acd46c5f1f03e3affab6153dcea" have entirely different histories.
c65ee029dd
...
b1dae9d7e8
15
.env.example
|
|
@ -1,15 +0,0 @@
|
|||
# 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=
|
||||
146
README.md
|
|
@ -1,122 +1,74 @@
|
|||
# Earth Walker Design
|
||||
# boilerplate-website
|
||||
|
||||
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.
|
||||
Opinionated Vue 3 starter — the base every new aiolabs website forks from.
|
||||
|
||||
## Routes
|
||||
## Stack
|
||||
|
||||
| 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) |
|
||||
- **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**
|
||||
|
||||
## Local dev
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
cp .env.example .env # fill in VITE_OWNER_NPUB
|
||||
pnpm dev # http://localhost:5173
|
||||
pnpm build # type-check + production build to dist/
|
||||
pnpm dev # vite dev server
|
||||
pnpm build # type-check + production build
|
||||
pnpm preview # serve dist/
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
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.
|
||||
## Layout
|
||||
|
||||
## Env vars
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
| 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. |
|
||||
## Optional features
|
||||
|
||||
Both are inlined at build time by Vite. Rotating either requires a
|
||||
rebuild + redeploy.
|
||||
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.
|
||||
|
||||
## How the contact form delivers inquiries
|
||||
- **`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
|
||||
|
||||
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.
|
||||
## Versioning strategy
|
||||
|
||||
No server in between stores the message. There is no inbox to leak.
|
||||
The boilerplate's `main` branch is **the "tools wired, no content" baseline**. New site = clone (or fork on Forgejo) → start adding content immediately.
|
||||
|
||||
## 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, h1–h3, 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'
|
||||
```
|
||||
- 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.
|
||||
|
||||
## Stack
|
||||
|
||||
Tracking the upstream boilerplate (`aiolabs/boilerplate-website`).
|
||||
To pull dependency refreshes:
|
||||
To pull dep refreshes from the boilerplate into an existing site:
|
||||
|
||||
```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
|
|
@ -1,12 +1 @@
|
|||
/// <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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"@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
|
|
@ -23,9 +23,6 @@ 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))
|
||||
|
|
@ -260,18 +257,6 @@ 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'}
|
||||
|
|
@ -389,15 +374,6 @@ 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==}
|
||||
|
||||
|
|
@ -1150,17 +1126,6 @@ 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==}
|
||||
|
||||
|
|
@ -1702,14 +1667,6 @@ 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
|
||||
|
|
@ -1777,19 +1734,6 @@ 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
|
||||
|
|
@ -2533,20 +2477,6 @@ 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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 892 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 704 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 547 KiB |
|
Before Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 725 KiB |
|
Before Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 616 KiB |
|
Before Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 712 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
|
@ -1,220 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -3,9 +3,6 @@ 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>
|
||||
|
|
@ -16,6 +13,5 @@ const isDev = import.meta.env.DEV
|
|||
</main>
|
||||
<SiteFooter />
|
||||
<Toaster position="bottom-right" />
|
||||
<PaletteSwitcher v-if="isDev" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -12,15 +10,17 @@ 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">{{ t('brand') }}</p>
|
||||
<p class="text-muted-foreground text-xs">{{ t('footer.tagline') }}</p>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
{{ t('nav.inquire') }}
|
||||
Inquire
|
||||
</RouterLink>
|
||||
<p class="text-muted-foreground text-xs">© {{ year }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Menu } from '@lucide/vue'
|
||||
import {
|
||||
Sheet,
|
||||
|
|
@ -11,14 +10,11 @@ import {
|
|||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import LocaleSwitcher from './LocaleSwitcher.vue'
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ to: '/portfolio', label: t('nav.portfolio') },
|
||||
{ to: '/contact', label: t('nav.contact') },
|
||||
])
|
||||
const navItems = [
|
||||
{ to: '/portfolio', label: 'Portfolio' },
|
||||
{ to: '/contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
</script>
|
||||
|
|
@ -34,10 +30,10 @@ const mobileOpen = ref(false)
|
|||
to="/"
|
||||
class="text-foreground font-serif text-lg tracking-[0.18em] uppercase"
|
||||
>
|
||||
{{ t('brand') }}
|
||||
Earth Walker
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center gap-8 md:flex">
|
||||
<nav class="hidden items-center gap-10 md:flex">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
|
|
@ -47,24 +43,22 @@ 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="t('nav.openMenu')"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<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">
|
||||
{{ t('brand') }}
|
||||
Earth Walker
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav class="mt-10 flex flex-col gap-6 px-4">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -11,7 +9,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' ? t('themeToggle.toLight') : t('themeToggle.toDark')"
|
||||
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
@click="toggle"
|
||||
>
|
||||
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -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"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { ChevronUp } from "lucide-vue-next"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
|
||||
import { Toaster as Sonner } from "vue-sonner"
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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 }
|
||||
})
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,10 @@
|
|||
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: initialLocale(),
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, es, fr },
|
||||
messages: { en, es },
|
||||
})
|
||||
|
||||
export function persistLocale(code: LocaleCode) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +1,11 @@
|
|||
{
|
||||
"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."
|
||||
"app": {
|
||||
"title": "Boilerplate Website"
|
||||
},
|
||||
"home": {
|
||||
"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…"
|
||||
"heading": "Welcome",
|
||||
"intro": "Edit src/views/HomeView.vue to begin.",
|
||||
"counter": "Count: {n}",
|
||||
"increment": "Increment"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +1,11 @@
|
|||
{
|
||||
"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."
|
||||
"app": {
|
||||
"title": "Plantilla de Sitio Web"
|
||||
},
|
||||
"home": {
|
||||
"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…"
|
||||
"heading": "Bienvenido",
|
||||
"intro": "Edita src/views/HomeView.vue para empezar.",
|
||||
"counter": "Cuenta: {n}",
|
||||
"increment": "Incrementar"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
{
|
||||
"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…"
|
||||
}
|
||||
}
|
||||
|
|
@ -6,39 +6,11 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,53 @@
|
|||
@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);
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
/* 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);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/* 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);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/* 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);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/* 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);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/* 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);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,124 +1,8 @@
|
|||
<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>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
|
||||
import { asheville } from '@/data/projects'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProjectDetail :project="asheville" />
|
||||
</template>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
|
||||
import { boulder } from '@/data/projects'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProjectDetail :project="boulder" />
|
||||
</template>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||