New initial commit
CI / quality (push) Has been cancelled

This commit is contained in:
matt committed 2026-04-22 16:28:11 -07:00
commit bc02d72576
64 files changed
+9366

No files matched your search

+1
View File
@@ -0,0 +1 @@
VITE_ADMIN_ACCESS_KEY=replace-with-strong-random-token
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Build
run: npm run build
+31
View File
@@ -0,0 +1,31 @@
node_modules
dist
coverage
*.local
.DS_Store
# Environment and secrets (templates stay tracked: *.example)
.env
.env.*
!.env.example
!.env.*.example
# --- Personal / local customization (templates are *.defaults.*, *.example.*) ---
# Optional gallery manifest override (defaults: src/data/gallery.defaults.json)
src/data/gallery.json
# Local-only documentation under docs/ (keep committed templates as *.example.md, etc.)
docs/IMPLEMENTATION_DECISIONS.md
docs/**/*.local.md
docs/_private/*
!docs/_private/.gitkeep
# Optional: put large or private originals here; manifest URLs may point to /gallery/user/...
public/gallery/user/*
!public/gallery/user/.gitkeep
# Product requirements (local only). Tracked: PRD.template.md, README.md
PRDs/*.md
!PRDs/PRD.template.md
!PRDs/README.md
+1
View File
@@ -0,0 +1 @@
22
+3
View File
@@ -0,0 +1,3 @@
dist
coverage
node_modules
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 90,
"trailingComma": "all"
}
+49
View File
@@ -0,0 +1,49 @@
# Post-alpha backlog (Seaside Sanctuary)
This document tracks work described in **your local PRDs** (under `PRDs/`, not committed) that is **out of scope** for the static alpha. It is not a commitment order; prioritize by risk and value.
## Phase B - Secure media ingestion
Source: typical PRD themes (secure ingestion pipeline).
- Server-side upload endpoint (e.g. Nitro/Node) with temporary buffer storage.
- Extract GPS, `DateTimeOriginal`, camera make/model using `sharp` / `exif-reader` (or equivalent).
- Persist rich metadata in a private database; store only **scrubbed** images in public object storage (Cloudflare Images / R2).
- Re-encode or strip metadata so public files have **no** EXIF/IPTC/XMP (validate with external tools).
- Admin “Privacy Summary” UI after upload; optional “keep metadata” override (default off).
- Async/worker processing for bulk uploads so the admin UI stays responsive.
- Automated collection tagging from GPS polygons (e.g. Encinitas / garden / beachhouse regions).
**Acceptance targets:** Public downloads show no location metadata; admin shows exact stored time/location from DB.
## Phase C - Pluggable AI vision and tagging
Source: typical PRD themes (AI vision layer).
- `VisionProvider` interface with `analyzeImage(buffer)` (or similar).
- Implementations: e.g. Google Gemini Vision, OpenAI multimodal, **Ollama** (local), optional niche classifiers (BirdNET, etc.).
- Config UI: provider selection, API keys / base URLs, tunable system prompt; persist settings (e.g. Pinia + server-side secrets for production).
- After scrubbing, run analysis on the clean buffer; map results to the shared taxonomy schema (`Common Name`, `Scientific Name`, confidence, suggested tags).
- Confirmation step with chips (Meyer Lemon Gold for high confidence, softer styling for low).
- Graceful fallback to manual tagging when a provider is unreachable.
- Optional batch queue for many files.
**Acceptance targets:** Hot-swap providers without restart; consistent DB schema; resilient errors.
## Phase D - Operations and routing
Source: typical PRD themes (routing / ops).
- **Public origin:** Cloudflare Pages or Vercel for the gallery (current alpha is static-ready).
- **Internal origin:** Home lab via **Cloudflare Tunnel** (Proxmox/Docker) for admin/automation.
- **Edge routing:** Worker or WAF rules using `CF-Connecting-IP` - e.g. home IP → `admin.*` or internal tunnel; everyone else → public gallery only.
- TLS hostnames, secrets, and rate limits for upload endpoints.
## Phase E - Asset pipeline at scale
- Cloudflare Images (or R2 + Workers) for variants: WebP/AVIF, responsive widths, blur placeholders generated at the edge or build time.
- Optional BlurHash generation in ingestion for smaller manifest payloads.
---
When Phase B exists, replace or generate the gallery manifest (`src/data/gallery.defaults.json`, or your gitignored `src/data/gallery.json` override) from the database or export job; the `GalleryAsset` shape in `src/types/gallery.ts` is the contract to preserve.
+45
View File
@@ -0,0 +1,45 @@
# Product requirements document
## Summary
_(One paragraph: what this product is and who it is for.)_
## Goals
- [ ] _(Goal 1)_
- [ ] _(Goal 2)_
## Non-goals
- _(Explicitly out of scope.)_
## Users and scenarios
| Persona | Need |
|--------|------|
| _(Name)_ | _(Need)_ |
## Functional requirements
1. _(Requirement)_
2. _(Requirement)_
## Non-functional requirements
- **Performance:** _(e.g. targets)_
- **Security / privacy:** _(e.g. data handling)_
- **Accessibility:** _(e.g. WCAG level)_
## Milestones
| Phase | Deliverable |
|-------|-------------|
| _(Phase)_ | _(Deliverable)_ |
## Open questions
- _(Question)_
## References
- _(Links or related docs.)_
+15
View File
@@ -0,0 +1,15 @@
# PRDs (product requirements)
Only the **blank template** and this README are committed.
1. Copy the template to a new file in this folder (any name ending in `.md` except `PRD.template.md` / `README.md`):
```bash
cp PRDs/PRD.template.md PRDs/PRD.md
```
Or run `npm run init:custom` from the repo root, which creates `PRDs/PRD.md` if missing.
2. Edit your copy locally. It stays **gitignored** so project-specific requirements are not pushed to the remote.
To share requirements in-repo, use a different process (e.g. wiki, issue tracker, or sanitized excerpts in `docs/`).
+77
View File
@@ -0,0 +1,77 @@
# beacho.us (Seaside Sanctuary)
Vue 3 + Vite gallery for coastal, garden, and wildlife photography. The current app is
an alpha focused on public gallery browsing, while private admin ingestion and AI
tagging are scaffolded for iterative hardening.
## Requirements
- Node `22` (see `.nvmrc`)
- npm `10+`
## Quick Start
```bash
nvm use
npm install
npm run dev
```
## Local customization (optional)
Some files are **gitignored** so you can personalize without committing secrets or your real gallery.
| You want | Do this |
|----------|---------|
| Your own gallery manifest | `npm run init:custom` (or copy `src/data/gallery.defaults.json` → `src/data/gallery.json`) |
| Private implementation notes | Same command creates `docs/IMPLEMENTATION_DECISIONS.md` from `docs/IMPLEMENTATION_DECISIONS.example.md` |
| Other private docs | `docs/_private/` (contents gitignored) or any `docs/**/*.local.md` |
| Product requirements | `PRDs/*.md` except `PRD.template.md` / `README.md` — copy template to e.g. `PRDs/PRD.md` |
| Large / private images | Put them under `public/gallery/user/` (ignored except `.gitkeep`) and reference `/gallery/user/...` in your manifest |
Tracked templates: `src/data/gallery.defaults.json`, `docs/IMPLEMENTATION_DECISIONS.example.md`, `.env.example`. See `docs/README.md` for gitignored doc patterns.
## Scripts
- `npm run dev` - start local dev server
- `npm run typecheck` - run Vue TypeScript checks
- `npm run lint` - run ESLint
- `npm run lint:fix` - auto-fix lint findings
- `npm run format` - format with Prettier
- `npm run format:check` - verify formatting
- `npm run test` - run unit tests
- `npm run test:watch` - watch mode tests
- `npm run test:coverage` - coverage report
- `npm run build` - typecheck and production build
- `npm run check` - full quality gate (typecheck, lint, test, build)
- `npm run init:custom` - create gitignored local files from templates (gallery, decisions doc, starter `PRDs/PRD.md`)
## Architecture
- Public gallery UI: `src/views/GalleryView.vue`, `src/components/*`
- Gallery state and lightbox behavior: `src/stores/gallery.ts`
- Data source adapter boundary: `src/data/source.ts`
- Default static data source: `src/data/staticGallerySource.ts`
- Admin workflow scaffold: `src/views/AdminWorkbenchView.vue`
- Admin domain logic:
- Privacy scrub and metadata extraction: `src/features/admin/ingestion.ts`
- Vision provider abstraction: `src/features/admin/vision.ts`
- Admin settings and upload queue: `src/stores/admin.ts`
## Current Scope
- Implemented:
- Gallery filters, lazy pagination, lightbox navigation
- Admin workbench scaffold with upload review queue and AI suggestion preview
- Pluggable vision provider interface with resilient fallback to manual tagging
- Not yet production-complete:
- Backend upload endpoint and persistent DB
- Cloudflare/Vercel deployment wiring
- Server-side secrets and auth/session enforcement
## Deployment Direction
`BACKLOG.md` still tracks high-level post-alpha themes. Detailed PRDs live only on your
machine: copy `PRDs/PRD.template.md` to a new file under `PRDs/` (see `PRDs/README.md`),
or use `npm run init:custom`. For local implementation notes, use
`docs/IMPLEMENTATION_DECISIONS.example.md` → `docs/IMPLEMENTATION_DECISIONS.md`.
+34
View File
@@ -0,0 +1,34 @@
# Implementation Decisions (Working Defaults)
These defaults unblock implementation. Replace them when final production decisions are
made.
## Platform and Routing
- Public site target: Cloudflare Pages.
- Internal/admin target: Cloudflare Tunnel to a private origin.
- Edge policy: default deny for `/admin` in public traffic; allow only trusted network.
## Media Storage and Privacy
- Public assets must be scrubbed before publication.
- Default metadata policy: remove EXIF/IPTC/XMP from public files.
- Admin-private metadata may include location/time fields for curation workflows.
## AI Vision Layer
- Provider contract is `VisionProvider` with a common result schema.
- Default provider in local development: mock provider (no external key required).
- Fallback behavior: if provider fails, ingestion continues with manual-tagging state.
## Taxonomy and Classification
- Shared tags remain free-form for now, but collection values are fixed in
`src/types/gallery.ts`.
- Suggested tags are reviewed by user confirmation before publishing.
## Tooling and CI
- Node LTS target: 22.
- Quality gate: typecheck + lint + test + build.
- CI provider: GitHub Actions.
+104
View File
@@ -0,0 +1,104 @@
# Manual launch checklist (customization and go-live)
Use this before you call the site “ready.” It covers replacing dummy content, branding, security, and infrastructure decisions only you can make.
---
## 1. Gallery content and media (replace all dummy data)
- [ ] **Create or edit your gallery manifest** — Run `npm run init:custom` once, then edit the gitignored `src/data/gallery.json` (copied from `src/data/gallery.defaults.json`). If you skip that, the app uses the committed defaults file only. Every entry must match `GalleryAsset` in `src/types/gallery.ts` (ids, `scrubbedUrl`, dimensions, `blurDataUrl`, `collection`, `tags`, copy fields, `alt`).
- [ ] **Swap SVG illustrations for real scrubbed images**
Default `scrubbedUrl` values point at `public/gallery/*.svg`. Replace with paths to your real files (e.g. `/gallery/my-photo.webp`, `/gallery/user/photo.webp`, or a CDN URL). You can delete unused SVGs from `public/gallery/` when nothing references them. Optional local-only assets: `public/gallery/user/` (gitignored contents; keep `.gitkeep`).
- [ ] **Set accurate `width` and `height`** for each asset (layout and aspect ratio depend on them).
- [ ] **Regenerate `blurDataUrl`** for each image (tiny LQIP/blur-up). The current values are simple inline SVG data URLs sized to match each dummy asset.
- [ ] **Optional: add `scrubbedSrcSet` and `sizes`** on entries that use responsive variants (e.g. Cloudflare Images). Omit if you use a single URL per asset.
- [ ] **Write honest `alt` text** for accessibility and SEO (not generic “photo”).
- [ ] **Align tags and dates** with how you want filtering and captions to read; tags drive the filter chips in the UI.
---
## 2. Collections and taxonomy (code + data must agree)
- [ ] **Decide final collection names**
Allowed values today are fixed in `src/types/gallery.ts` (`Collection` type) and mirrored in `src/components/GalleryFilterBar.vue`. If you rename or add collections, update **both** plus every `collection` field in `gallery.json`.
- [ ] **Document your tag vocabulary** (even informally) so future imports stay consistent with the chip UI.
---
## 3. Branding, copy, and site identity
- [ ] **`index.html`** — Update `<title>`, meta `description`, and `theme-color` if you change palette.
- [ ] **`src/components/AppHeader.vue`** — Site title in the header.
- [ ] **`src/views/GalleryView.vue`** — Main gallery heading and subtitle (“The Infinite Sanctuary” / Encinitas line).
- [ ] **`src/App.vue`** — Footer line (“Seaside Sanctuary - Encinitas coast” or equivalent).
- [ ] **`package.json`** — `name` field still says `seaside-sanctuary`; align with your npm/project identity if it matters to you.
- [ ] **`README.md`** — Project title and description for contributors or future you.
---
## 4. Visual design (optional customization)
- [ ] **`tailwind.config.js`** — `moonlight`, `meyer`, `succulent`, `seafoam` and font stacks (`Playfair Display`, `Source Sans 3`). Swap fonts in `index.html` Google Fonts link and here if you change typography.
- [ ] **`public/favicon.svg`** — Replace with your mark.
- [ ] **OG / social preview** — Not present today; add meta tags in `index.html` (or a small plugin) if you care about link previews.
---
## 5. Admin, AI, and security (before any public admin URL)
- [ ] **Production admin access** — Set `VITE_ADMIN_ACCESS_KEY` in your real env (see `.env.example`). In production builds, `/admin` redirects away unless `?key=` matches. Pick a long random value; never commit it.
- [ ] **Prefer edge/network controls** — Treat the query key as a **thin** gate only; typical secure-gallery PRDs assume Tunnel / IP / Worker rules for real protection. Decide and implement those outside this repo.
- [ ] **Implementation notes** — Template: `docs/IMPLEMENTATION_DECISIONS.example.md`. After `npm run init:custom`, edit gitignored `docs/IMPLEMENTATION_DECISIONS.md` with your real hosting, storage, privacy, and AI provider choices.
- [ ] **Replace mock vision** — `src/features/admin/vision.ts` and the admin store only expose a mock provider today. When you add Gemini/OpenAI/Ollama, wire keys server-side for production; do not ship real API keys in the client bundle.
- [ ] **Admin copy** — `src/views/AdminWorkbenchView.vue` intro text still describes a scaffold; align with your real workflow.
---
## 6. Ingestion and privacy (when you move past static JSON)
- [ ] **Real pipeline** — `src/features/admin/ingestion.ts` drafts are illustrative (object URLs, placeholder dimensions). Replace with server-side upload, EXIF handling, scrubbing, and storage per `BACKLOG.md`.
- [ ] **“Preserve metadata” toggle** — Decide policy; today it is UI state only until a backend enforces it.
---
## 7. Infrastructure and deployment
- [ ] **Host** — Cloudflare Pages, Vercel, or other; connect repo, set build command (`npm run build`), output directory `dist`, Node version (see `.nvmrc`).
- [ ] **Domain and HTTPS** — Point DNS; confirm TLS and redirects (www vs apex).
- [ ] **Environment variables** — Set `VITE_ADMIN_ACCESS_KEY` (and any future `VITE_*`) in the host’s dashboard for **production** builds only as appropriate.
- [ ] **CI** — `.github/workflows/ci.yml` runs on PRs; enable branch protection if you want required checks.
---
## 8. Legal, analytics, and polish
- [ ] **Privacy policy / terms** — Add pages or external links if you collect data or use third-party AI.
- [ ] **Analytics** — Add only if you want them; not in the app today.
- [ ] **Contact or about** — Add if visitors need a way to reach you.
---
## 9. After you change headings or behavior (automated tests)
- [ ] **E2E smoke test** — `tests/e2e/gallery-smoke.spec.ts` asserts the exact gallery heading text. If you rename it, update the test.
- [ ] **Run locally before deploy**
```bash
npm run check
npx playwright install chromium # once per machine
npm run test:e2e
```
---
## Quick reference: files that usually change together
| What you change | Primary files |
|-----------------|----------------|
| All photos and metadata | `src/data/gallery.json` (local override) or `src/data/gallery.defaults.json`; files under `public/` (e.g. `public/gallery/user/`) or your CDN |
| Collection list | `src/types/gallery.ts`, `src/components/GalleryFilterBar.vue`, every asset in your active manifest |
| Site name / hero copy | `index.html`, `AppHeader.vue`, `GalleryView.vue`, `App.vue` |
| Colors / fonts | `tailwind.config.js`, `index.html` (fonts), `src/assets/main.css` if used |
| Admin gate secret | `.env` (local), host env vars (prod), `.env.example` (placeholder name only) |
When every item you care about is checked off for your launch bar, you are in good shape to ship.
+11
View File
@@ -0,0 +1,11 @@
# Docs
**Tracked:** shared checklists and templates (e.g. `MANUAL_LAUNCH_CHECKLIST.md`, `*.example.md`).
**Gitignored (see repo root `.gitignore`):**
- `IMPLEMENTATION_DECISIONS.md` — your copy from `IMPLEMENTATION_DECISIONS.example.md` (`npm run init:custom`).
- Any `**/*.local.md` under `docs/` — scratch or personal notes.
- Everything in `_private/` except `.gitkeep` — drop arbitrary private notes or exports here.
Product requirements live under `PRDs/` (see `PRDs/README.md`); only `PRD.template.md` is committed there.
View File
Whitespace-only changes.
+22
View File
@@ -0,0 +1,22 @@
import pluginVue from "eslint-plugin-vue";
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";
import eslintConfigPrettier from "eslint-config-prettier";
export default defineConfigWithVueTs(
{
name: "app/files-to-lint",
files: ["**/*.{ts,mts,tsx,vue}"],
},
{
name: "app/files-to-ignore",
ignores: ["**/dist/**", "**/coverage/**"],
},
pluginVue.configs["flat/essential"],
vueTsConfigs.recommended,
{
rules: {
"vue/multi-word-component-names": "off",
},
},
eslintConfigPrettier,
);
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#F8FAFC" />
<meta name="description" content="Seaside Sanctuary - a botanical and coastal media archive." />
<title>Seaside Sanctuary</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=Source+Sans+3:ital,wght@0,400;0,600;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+7111
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "seaside-sanctuary",
"private": true,
"version": "0.1.0-alpha",
"type": "module",
"engines": {
"node": ">=20 <23"
},
"scripts": {
"init:custom": "node scripts/init-customizations.mjs",
"dev": "vite",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
},
"dependencies": {
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@testing-library/vue": "^8.1.0",
"@types/node": "^24.6.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitest/coverage-v8": "^3.2.4",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.5.1",
"jsdom": "^29.0.2",
"postcss": "^8.4.49",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.3",
"vite": "^6.0.3",
"vitest": "^3.2.4",
"vue-tsc": "^2.1.10"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
use: {
baseURL: "http://127.0.0.1:4173",
trace: "on-first-retry",
},
webServer: {
command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
port: 4173,
reuseExistingServer: true,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#0F172A"/>
<path d="M8 22c4-8 12-8 16 0" stroke="#10B981" stroke-width="2" stroke-linecap="round"/>
<circle cx="16" cy="12" r="3" fill="#FDE047"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 750" width="1000" height="750">
<rect width="1000" height="750" fill="#E2E8F0"/>
<path d="M200 450 L500 250 L800 450 V650 H200 Z" fill="#0F172A" opacity="0.85"/>
<rect x="420" y="520" width="160" height="130" fill="#FDE047" opacity="0.4"/>
<text x="500" y="710" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#0F172A">Beach house</text>
</svg>

After

Width:  |  Height:  |  Size: 433 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 900" width="700" height="900">
<rect width="700" height="900" fill="#F0F9FF"/>
<ellipse cx="350" cy="420" rx="90" ry="140" fill="#10B981" opacity="0.9"/>
<ellipse cx="380" cy="380" rx="25" ry="18" fill="#0F172A"/>
<path d="M320 360 Q350 300 420 340" stroke="#FDE047" stroke-width="8" fill="none" stroke-linecap="round"/>
<text x="350" y="820" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#0F172A">Hummingbird</text>
</svg>

After

Width:  |  Height:  |  Size: 515 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#E0F2FE"/>
<stop offset="100%" stop-color="#F8FAFC"/>
</linearGradient>
</defs>
<rect width="800" height="1000" fill="url(#sky)"/>
<ellipse cx="400" cy="620" rx="180" ry="200" fill="#FDE047" opacity="0.95"/>
<ellipse cx="340" cy="600" rx="40" ry="50" fill="#CA8A04" opacity="0.3"/>
<text x="400" y="920" text-anchor="middle" font-family="Georgia,serif" font-size="28" fill="#0F172A">Meyer Lemon</text>
</svg>

After

Width:  |  Height:  |  Size: 622 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 700" width="900" height="700">
<rect width="900" height="700" fill="#ECFDF5"/>
<circle cx="450" cy="320" r="120" fill="#10B981" opacity="0.85"/>
<circle cx="400" cy="300" r="30" fill="#FDE047"/>
<circle cx="500" cy="340" r="28" fill="#FDE047"/>
<text x="450" y="620" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#0F172A">Native Milkweed</text>
</svg>

After

Width:  |  Height:  |  Size: 443 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 650" width="900" height="650">
<rect width="900" height="650" fill="#BAE6FD"/>
<ellipse cx="450" cy="340" rx="200" ry="90" fill="#F8FAFC"/>
<path d="M280 360 Q450 280 620 360 L580 400 Q450 420 320 400 Z" fill="#0F172A"/>
<circle cx="520" cy="330" r="12" fill="#0F172A"/>
<text x="450" y="600" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#0F172A">Brown Pelican</text>
</svg>

After

Width:  |  Height:  |  Size: 467 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800">
<rect width="800" height="800" fill="#F8FAFC"/>
<ellipse cx="400" cy="420" rx="200" ry="180" fill="#10B981" opacity="0.35"/>
<ellipse cx="400" cy="400" rx="120" ry="100" fill="#10B981"/>
<ellipse cx="360" cy="380" rx="40" ry="55" fill="#34D399" transform="rotate(-20 360 380)"/>
<ellipse cx="440" cy="380" rx="40" ry="55" fill="#34D399" transform="rotate(20 440 380)"/>
<text x="400" y="720" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#0F172A">Succulent rosette</text>
</svg>

After

Width:  |  Height:  |  Size: 603 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 600" width="1200" height="600">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FDE047"/>
<stop offset="40%" stop-color="#FB923C"/>
<stop offset="100%" stop-color="#0F172A"/>
</linearGradient>
</defs>
<rect width="1200" height="600" fill="url(#g)"/>
<ellipse cx="600" cy="380" rx="80" ry="80" fill="#FDE047" opacity="0.9"/>
<text x="600" y="540" text-anchor="middle" font-family="Georgia,serif" font-size="28" fill="#F8FAFC">Coastal Sky</text>
</svg>

After

Width:  |  Height:  |  Size: 587 B

View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 500" width="1100" height="500">
<rect width="1100" height="500" fill="#0EA5E9" opacity="0.25"/>
<path d="M0 280 Q275 220 550 280 T1100 280 V500 H0 Z" fill="#0F172A" opacity="0.15"/>
<path d="M0 320 Q275 260 550 320 T1100 320 V500 H0 Z" fill="#10B981" opacity="0.2"/>
<text x="550" y="440" text-anchor="middle" font-family="Georgia,serif" font-size="28" fill="#0F172A">Pacific surf</text>
</svg>

After

Width:  |  Height:  |  Size: 461 B

+23
View File
@@ -0,0 +1,23 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const copies = [
["src/data/gallery.defaults.json", "src/data/gallery.json"],
["docs/IMPLEMENTATION_DECISIONS.example.md", "docs/IMPLEMENTATION_DECISIONS.md"],
["PRDs/PRD.template.md", "PRDs/PRD.md"],
];
mkdirSync(join(root, "public/gallery/user"), { recursive: true });
for (const [from, to] of copies) {
const dest = join(root, to);
if (existsSync(dest)) {
console.log(`skip (exists): ${to}`);
continue;
}
copyFileSync(join(root, from), dest);
console.log(`created: ${to}`);
}
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
</script>
<template>
<div class="min-h-screen flex flex-col">
<a
href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-[120] focus:bg-meyer focus:text-moonlight focus:px-3 focus:py-2 focus:rounded"
>
Skip to main content
</a>
<AppHeader />
<main id="main" class="flex-1 w-full max-w-[1600px] mx-auto px-4 sm:px-6 pb-16">
<RouterView />
</main>
<footer class="border-t border-moonlight/10 py-6 text-center text-sm text-moonlight/60">
<p>Seaside Sanctuary - Encinitas coast</p>
</footer>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply bg-seafoam text-moonlight font-sans antialiased;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { RouterLink } from "vue-router";
</script>
<template>
<header
class="sticky top-0 z-40 border-b border-moonlight/10 bg-seafoam/95 backdrop-blur-sm"
role="banner"
>
<div
class="max-w-[1600px] mx-auto px-4 sm:px-6 py-4 flex flex-wrap items-baseline justify-between gap-4"
>
<RouterLink
to="/"
class="font-display text-2xl sm:text-3xl text-moonlight hover:text-succulent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer focus-visible:ring-offset-2 rounded"
>
Seaside Sanctuary
</RouterLink>
<nav class="flex gap-6 text-sm font-medium" aria-label="Main">
<RouterLink
to="/"
end
class="text-moonlight/80 hover:text-meyer transition-colors focus:outline-none focus-visible:underline"
active-class="!text-succulent"
>
Gallery
</RouterLink>
<RouterLink
to="/admin"
class="text-moonlight/50 hover:text-moonlight/80 transition-colors focus:outline-none focus-visible:underline"
>
Admin
</RouterLink>
</nav>
</div>
</header>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { useGalleryStore } from "@/stores/gallery";
import type { Collection } from "@/types/gallery";
const store = useGalleryStore();
const collections: { value: Collection | "all"; label: string }[] = [
{ value: "all", label: "All" },
{ value: "Garden", label: "Garden" },
{ value: "Beachhouse", label: "Beachhouse" },
{ value: "Birds", label: "Birds" },
{ value: "Coastal Sky", label: "Coastal Sky" },
];
</script>
<template>
<div class="space-y-4 mb-8">
<div>
<h2 class="font-display text-lg text-moonlight/80 mb-2">Collection</h2>
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="Filter by collection"
>
<button
v-for="c in collections"
:key="c.value"
type="button"
class="rounded-full px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer focus-visible:ring-offset-2"
:class="
store.activeCollection === c.value
? 'bg-moonlight text-seafoam'
: 'bg-white/80 text-moonlight border border-moonlight/15 hover:border-succulent/50'
"
:aria-pressed="store.activeCollection === c.value"
@click="store.setCollection(c.value)"
>
{{ c.label }}
</button>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2 mb-2">
<h2 class="font-display text-lg text-moonlight/80">Tags</h2>
<button
v-if="store.selectedTags.size > 0"
type="button"
class="text-xs text-succulent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer rounded"
@click="store.clearTags()"
>
Clear tags
</button>
</div>
<div
class="sticky top-[4.5rem] z-30 -mx-4 px-4 py-2 bg-seafoam/90 backdrop-blur-sm border-y border-moonlight/5 sm:border sm:rounded-xl sm:mx-0"
>
<div
class="flex gap-2 overflow-x-auto pb-1"
role="group"
aria-label="Filter by tags"
>
<button
v-for="tag in store.allTags"
:key="tag"
type="button"
class="shrink-0 rounded-full px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer focus-visible:ring-offset-2"
:class="
store.selectedTags.has(tag)
? 'bg-meyer text-moonlight shadow-sm'
: 'bg-white/70 text-moonlight/90 border border-moonlight/10 hover:border-succulent/40'
"
:aria-pressed="store.selectedTags.has(tag)"
@click="store.toggleTag(tag)"
>
{{ tag }}
</button>
</div>
</div>
</div>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from "vue";
import type { GalleryAsset } from "@/types/gallery";
const props = defineProps<{
asset: GalleryAsset;
}>();
const emit = defineEmits<{
open: [id: string];
}>();
const loaded = ref(false);
function onImgLoad() {
loaded.value = true;
}
</script>
<template>
<button
type="button"
class="group relative w-full text-left rounded-xl overflow-hidden bg-white shadow-sm border border-moonlight/10 hover:border-succulent/40 hover:shadow-md transition-shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer focus-visible:ring-offset-2"
:style="{ aspectRatio: `${props.asset.width} / ${props.asset.height}` }"
@click="emit('open', props.asset.id)"
>
<span class="absolute inset-0 z-0 overflow-hidden">
<img
:src="props.asset.blurDataUrl"
alt=""
class="absolute inset-0 w-full h-full object-cover scale-110 blur-xl opacity-80"
aria-hidden="true"
/>
<img
:src="props.asset.scrubbedUrl"
:srcset="props.asset.scrubbedSrcSet"
:sizes="props.asset.sizes ?? '(max-width: 640px) 100vw, (max-width: 1200px) 50vw, 33vw'"
:alt="props.asset.alt"
loading="lazy"
decoding="async"
class="relative z-[1] w-full h-full object-cover transition-opacity duration-500"
:class="loaded ? 'opacity-100' : 'opacity-0'"
:width="props.asset.width"
:height="props.asset.height"
@load="onImgLoad"
/>
</span>
<span
class="absolute inset-x-0 bottom-0 z-[2] bg-gradient-to-t from-moonlight/85 to-transparent p-3 pt-10 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
>
<span class="font-display text-seafoam text-sm">{{
props.asset.speciesOrSubject
}}</span>
</span>
</button>
</template>
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useGalleryStore } from "@/stores/gallery";
const store = useGalleryStore();
const panelRef = ref<HTMLElement | null>(null);
const closeBtnRef = ref<HTMLButtonElement | null>(null);
const prevFocused = ref<HTMLElement | null>(null);
function onKeydown(e: KeyboardEvent) {
if (!store.lightboxAsset) {
return;
}
if (e.key === "Tab") {
trapFocus(e);
return;
}
if (e.key === "Escape") {
e.preventDefault();
store.closeLightbox();
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
store.stepLightbox(1);
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
store.stepLightbox(-1);
}
}
function focusableEls(): HTMLElement[] {
const panel = panelRef.value;
if (!panel) {
return [];
}
return Array.from(
panel.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute("disabled") && el.tabIndex !== -1);
}
function trapFocus(e: KeyboardEvent) {
const els = focusableEls();
if (els.length === 0) {
e.preventDefault();
return;
}
const first = els[0];
const last = els[els.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
return;
}
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
const lightboxNeighbors = computed(() => {
const i = store.lightboxIndex;
const list = store.filteredAssets;
if (i < 0 || list.length < 2) {
return [] as string[];
}
const prev = list[(i - 1 + list.length) % list.length];
const next = list[(i + 1) % list.length];
return [prev.scrubbedUrl, next.scrubbedUrl];
});
watch(
() => store.lightboxAssetId,
async (id) => {
if (id) {
prevFocused.value = document.activeElement as HTMLElement | null;
document.body.style.overflow = "hidden";
await nextTick();
closeBtnRef.value?.focus();
} else {
document.body.style.overflow = "";
prevFocused.value?.focus();
prevFocused.value = null;
}
},
);
watch(lightboxNeighbors, (urls) => {
for (const url of urls) {
const img = new Image();
img.src = url;
}
});
onMounted(() => {
window.addEventListener("keydown", onKeydown);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeydown);
document.body.style.overflow = "";
});
function onBackdropClick(e: MouseEvent) {
if (e.target === panelRef.value) {
store.closeLightbox();
}
}
</script>
<template>
<Teleport to="body">
<div
v-if="store.lightboxAsset"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-8 bg-moonlight/92 backdrop-blur-sm"
role="dialog"
aria-modal="true"
:aria-label="store.lightboxAsset.speciesOrSubject"
>
<div
ref="panelRef"
class="relative max-w-[min(96vw,1200px)] max-h-[90vh] w-full flex flex-col items-center"
@click="onBackdropClick"
>
<button
ref="closeBtnRef"
type="button"
class="absolute -top-2 right-0 sm:-right-2 z-[110] rounded-full bg-seafoam text-moonlight px-4 py-2 text-sm font-semibold shadow-lg hover:bg-meyer focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer"
@click="store.closeLightbox()"
>
Close
</button>
<div
class="relative mt-10 max-h-[min(78vh,900px)] w-full flex items-center justify-center"
@click.stop
>
<img
:src="store.lightboxAsset.scrubbedUrl"
:srcset="store.lightboxAsset.scrubbedSrcSet"
:sizes="store.lightboxAsset.sizes ?? '96vw'"
:alt="store.lightboxAsset.alt"
class="max-h-[min(78vh,900px)] w-auto max-w-full object-contain rounded-lg shadow-2xl"
:width="store.lightboxAsset.width"
:height="store.lightboxAsset.height"
fetchpriority="high"
/>
</div>
<div
class="mt-6 w-full max-w-2xl rounded-xl bg-seafoam/95 border border-moonlight/10 px-6 py-4 text-moonlight shadow-inner"
@click.stop
>
<p class="font-display text-2xl text-moonlight">
{{ store.lightboxAsset.speciesOrSubject }}
</p>
<p
v-if="store.lightboxAsset.scientificName"
class="mt-1 text-sm italic text-moonlight/70"
>
{{ store.lightboxAsset.scientificName }}
</p>
<dl class="mt-4 grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt class="text-moonlight/50 font-medium uppercase tracking-wide text-xs">
Date
</dt>
<dd>{{ store.lightboxAsset.dateLabel }}</dd>
</div>
<div>
<dt class="text-moonlight/50 font-medium uppercase tracking-wide text-xs">
Collection
</dt>
<dd>{{ store.lightboxAsset.collection }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-moonlight/50 font-medium uppercase tracking-wide text-xs">
Tags
</dt>
<dd class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="t in store.lightboxAsset.tags"
:key="t"
class="inline-flex rounded-full bg-meyer/30 px-2 py-0.5 text-xs text-moonlight"
>{{ t }}</span
>
</dd>
</div>
</dl>
<p class="mt-4 text-xs text-moonlight/45">
{{ store.lightboxIndex + 1 }} / {{ store.filteredAssets.length }}
</p>
</div>
</div>
</div>
</Teleport>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import GalleryImageCard from "@/components/GalleryImageCard.vue";
import { useGalleryStore } from "@/stores/gallery";
const store = useGalleryStore();
function onOpen(id: string) {
store.openLightbox(id);
}
</script>
<template>
<TransitionGroup
name="gallery-tile"
tag="div"
class="columns-1 sm:columns-2 lg:columns-3 gap-4 space-y-4"
enter-active-class="transition duration-300 ease-out"
leave-active-class="transition duration-200 ease-in"
move-class="transition duration-300 ease-out"
>
<div
v-for="asset in store.visibleAssets"
:key="asset.id"
class="break-inside-avoid mb-4"
>
<GalleryImageCard :asset="asset" @open="onOpen" />
</div>
</TransitionGroup>
<p
v-if="store.filteredAssets.length === 0"
class="text-center py-16 text-moonlight/60 font-medium"
>
No pieces match these filters.
</p>
</template>
<style scoped>
.gallery-tile-enter-from,
.gallery-tile-leave-to {
opacity: 0;
transform: scale(0.98);
}
</style>
+10
View File
@@ -0,0 +1,10 @@
# Gallery data
- **`gallery.defaults.json`** — Committed sample manifest. Used when you have no local override.
- **`gallery.json`** — Optional, **gitignored**. Copy the defaults to customize without forking the whole repo:
```bash
cp src/data/gallery.defaults.json src/data/gallery.json
```
Then edit `gallery.json` (paths under `public/`, CDN URLs, tags, etc.). The app loads `gallery.json` when present at build time; otherwise it uses `gallery.defaults.json`.
+294
View File
@@ -0,0 +1,294 @@
[
{
"id": "g-001",
"scrubbedUrl": "/gallery/lemons.svg",
"width": 800,
"height": 1000,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 10'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.2'/%3E%3C/filter%3E%3Crect width='8' height='10' fill='%23fde047' filter='url(%23b)' opacity='.5'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Meyer Lemon", "Citrus", "Spring"],
"dateLabel": "April 12, 2026",
"speciesOrSubject": "Meyer lemon",
"scientificName": "Citrus × meyeri",
"alt": "Stylized illustration of Meyer lemons on the tree"
},
{
"id": "g-002",
"scrubbedUrl": "/gallery/milkweed.svg",
"width": 900,
"height": 700,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 7'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='9' height='7' fill='%2310b981' filter='url(%23b)' opacity='.45'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Native Milkweed", "Pollinators", "Spring"],
"dateLabel": "April 8, 2026",
"speciesOrSubject": "Native milkweed",
"scientificName": "Asclepias fascicularis",
"alt": "Abstract milkweed bloom in garden palette"
},
{
"id": "g-003",
"scrubbedUrl": "/gallery/succulent.svg",
"width": 800,
"height": 800,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='%2334d399'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Succulents", "Texture"],
"dateLabel": "March 22, 2026",
"speciesOrSubject": "Succulent rosette",
"alt": "Graphic succulent rosette in green tones"
},
{
"id": "b-001",
"scrubbedUrl": "/gallery/hummingbird.svg",
"width": 700,
"height": 900,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7 9'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3C/filter%3E%3Crect width='7' height='9' fill='%2310b981' filter='url(%23b)' opacity='.5'/%3E%3C/svg%3E",
"collection": "Birds",
"tags": ["Hummingbirds", "Flight"],
"dateLabel": "April 15, 2026",
"speciesOrSubject": "Anna's hummingbird",
"alt": "Stylized hummingbird hovering near blooms"
},
{
"id": "b-002",
"scrubbedUrl": "/gallery/pelican.svg",
"width": 900,
"height": 650,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 6.5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='9' height='6.5' fill='%23bae6fd' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Birds",
"tags": ["Brown Pelican", "Coastal"],
"dateLabel": "April 18, 2026",
"speciesOrSubject": "Brown pelican",
"scientificName": "Pelecanus occidentalis",
"alt": "Minimal illustration of a brown pelican over water"
},
{
"id": "c-001",
"scrubbedUrl": "/gallery/sunset.svg",
"width": 1200,
"height": 600,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 6'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='2'/%3E%3C/filter%3E%3Crect width='12' height='6' fill='%23fb923c' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Coastal Sky",
"tags": ["Sunset", "Golden hour"],
"dateLabel": "April 19, 2026",
"speciesOrSubject": "Coastal sunset",
"alt": "Gradient sky with warm sunset disk over the horizon"
},
{
"id": "h-001",
"scrubbedUrl": "/gallery/beach-house.svg",
"width": 1000,
"height": 750,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 7.5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='10' height='7.5' fill='%23e2e8f0' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Beachhouse",
"tags": ["Architecture", "Evening"],
"dateLabel": "April 5, 2026",
"speciesOrSubject": "Beach house gable",
"alt": "Simple graphic of a coastal roofline at dusk"
},
{
"id": "c-002",
"scrubbedUrl": "/gallery/waves.svg",
"width": 1100,
"height": 500,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.2'/%3E%3C/filter%3E%3Crect width='11' height='5' fill='%230ea5e9' filter='url(%23b)' opacity='.35'/%3E%3C/svg%3E",
"collection": "Coastal Sky",
"tags": ["Pacific", "Surf"],
"dateLabel": "April 17, 2026",
"speciesOrSubject": "Pacific surf",
"alt": "Layered wave forms along the shoreline"
},
{
"id": "g-004",
"scrubbedUrl": "/gallery/lemons.svg",
"width": 800,
"height": 1000,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 10'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.2'/%3E%3C/filter%3E%3Crect width='8' height='10' fill='%23fde047' filter='url(%23b)' opacity='.5'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Meyer Lemon", "Harvest"],
"dateLabel": "April 1, 2026",
"speciesOrSubject": "Citrus harvest",
"scientificName": "Citrus × meyeri",
"alt": "Second view of Meyer lemons in the garden"
},
{
"id": "g-005",
"scrubbedUrl": "/gallery/milkweed.svg",
"width": 900,
"height": 700,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 7'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='9' height='7' fill='%2310b981' filter='url(%23b)' opacity='.45'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Native Milkweed", "Monarchs"],
"dateLabel": "April 3, 2026",
"speciesOrSubject": "Milkweed patch",
"alt": "Milkweed study in soft greens and gold"
},
{
"id": "b-003",
"scrubbedUrl": "/gallery/hummingbird.svg",
"width": 700,
"height": 900,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7 9'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3C/filter%3E%3Crect width='7' height='9' fill='%2310b981' filter='url(%23b)' opacity='.5'/%3E%3C/svg%3E",
"collection": "Birds",
"tags": ["Hummingbirds", "Garden"],
"dateLabel": "April 11, 2026",
"speciesOrSubject": "Garden visitor",
"alt": "Hummingbird near coastal garden"
},
{
"id": "c-003",
"scrubbedUrl": "/gallery/sunset.svg",
"width": 1200,
"height": 600,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 6'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='2'/%3E%3C/filter%3E%3Crect width='12' height='6' fill='%23fb923c' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Coastal Sky",
"tags": ["Sunset", "Spring"],
"dateLabel": "April 14, 2026",
"speciesOrSubject": "Twilight band",
"alt": "Wide coastal sky at dusk"
},
{
"id": "h-002",
"scrubbedUrl": "/gallery/beach-house.svg",
"width": 1000,
"height": 750,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 7.5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='10' height='7.5' fill='%23e2e8f0' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Beachhouse",
"tags": ["Architecture", "Sea foam"],
"dateLabel": "March 30, 2026",
"speciesOrSubject": "Front elevation",
"alt": "Beach house silhouette study"
},
{
"id": "g-006",
"scrubbedUrl": "/gallery/succulent.svg",
"width": 800,
"height": 800,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='%2334d399'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Succulents", "Coastal"],
"dateLabel": "March 18, 2026",
"speciesOrSubject": "Coastal succulent",
"alt": "Succulent texture close-up"
},
{
"id": "b-004",
"scrubbedUrl": "/gallery/pelican.svg",
"width": 900,
"height": 650,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 6.5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='9' height='6.5' fill='%23bae6fd' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Birds",
"tags": ["Brown Pelican", "Flight"],
"dateLabel": "April 9, 2026",
"speciesOrSubject": "Gliding pelican",
"alt": "Pelican in glide over the Pacific"
},
{
"id": "c-004",
"scrubbedUrl": "/gallery/waves.svg",
"width": 1100,
"height": 500,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.2'/%3E%3C/filter%3E%3Crect width='11' height='5' fill='%230ea5e9' filter='url(%23b)' opacity='.35'/%3E%3C/svg%3E",
"collection": "Coastal Sky",
"tags": ["Pacific", "Morning"],
"dateLabel": "April 6, 2026",
"speciesOrSubject": "Morning tide",
"alt": "Cool morning surf layers"
},
{
"id": "g-007",
"scrubbedUrl": "/gallery/lemons.svg",
"width": 800,
"height": 1000,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 10'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.2'/%3E%3C/filter%3E%3Crect width='8' height='10' fill='%23fde047' filter='url(%23b)' opacity='.5'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Citrus", "Yellow"],
"dateLabel": "March 28, 2026",
"speciesOrSubject": "Lemon canopy",
"alt": "Yellow citrus canopy abstract"
},
{
"id": "h-003",
"scrubbedUrl": "/gallery/beach-house.svg",
"width": 1000,
"height": 750,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 7.5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='10' height='7.5' fill='%23e2e8f0' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Beachhouse",
"tags": ["Beachhouse", "Quiet"],
"dateLabel": "April 16, 2026",
"speciesOrSubject": "Quiet facade",
"alt": "Quiet evening at the beach house"
},
{
"id": "b-005",
"scrubbedUrl": "/gallery/hummingbird.svg",
"width": 700,
"height": 900,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7 9'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3C/filter%3E%3Crect width='7' height='9' fill='%2310b981' filter='url(%23b)' opacity='.5'/%3E%3C/svg%3E",
"collection": "Birds",
"tags": ["Hummingbirds", "Spring"],
"dateLabel": "April 4, 2026",
"speciesOrSubject": "Spring migrant",
"alt": "Hummingbird in spring light"
},
{
"id": "c-005",
"scrubbedUrl": "/gallery/sunset.svg",
"width": 1200,
"height": 600,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 6'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='2'/%3E%3C/filter%3E%3Crect width='12' height='6' fill='%23fb923c' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Coastal Sky",
"tags": ["Sunset", "Horizon"],
"dateLabel": "April 2, 2026",
"speciesOrSubject": "Horizon glow",
"alt": "Low horizon glow after sunset"
},
{
"id": "g-008",
"scrubbedUrl": "/gallery/milkweed.svg",
"width": 900,
"height": 700,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 7'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='9' height='7' fill='%2310b981' filter='url(%23b)' opacity='.45'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Pollinators", "Native"],
"dateLabel": "March 25, 2026",
"speciesOrSubject": "Milkweed stand",
"alt": "Native milkweed stand"
},
{
"id": "c-006",
"scrubbedUrl": "/gallery/waves.svg",
"width": 1100,
"height": 500,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1.2'/%3E%3C/filter%3E%3Crect width='11' height='5' fill='%230ea5e9' filter='url(%23b)' opacity='.35'/%3E%3C/svg%3E",
"collection": "Coastal Sky",
"tags": ["Coastal Sky", "Blue"],
"dateLabel": "March 31, 2026",
"speciesOrSubject": "Afternoon sea",
"alt": "Afternoon sea state"
},
{
"id": "g-009",
"scrubbedUrl": "/gallery/succulent.svg",
"width": 800,
"height": 800,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='%2334d399'/%3E%3C/svg%3E",
"collection": "Garden",
"tags": ["Succulents", "Spring"],
"dateLabel": "April 20, 2026",
"speciesOrSubject": "Spring growth",
"alt": "Succulent spring growth"
},
{
"id": "h-004",
"scrubbedUrl": "/gallery/beach-house.svg",
"width": 1000,
"height": 750,
"blurDataUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 7.5'%3E%3Cfilter id='b'%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3C/filter%3E%3Crect width='10' height='7.5' fill='%23e2e8f0' filter='url(%23b)'/%3E%3C/svg%3E",
"collection": "Beachhouse",
"tags": ["Beachhouse", "Garden"],
"dateLabel": "April 7, 2026",
"speciesOrSubject": "Garden side",
"alt": "Garden side of the beach house"
}
]
+6
View File
@@ -0,0 +1,6 @@
import type { GalleryAsset } from "@/types/gallery";
import { staticGallerySource } from "@/data/staticGallerySource";
export async function loadGalleryAssets(): Promise<GalleryAsset[]> {
return staticGallerySource.listAssets();
}
+5
View File
@@ -0,0 +1,5 @@
import type { GalleryAsset } from "@/types/gallery";
export interface GalleryDataSource {
listAssets(): Promise<GalleryAsset[]>;
}
+16
View File
@@ -0,0 +1,16 @@
import defaults from "./gallery.defaults.json";
import type { GalleryDataSource } from "@/data/source";
import type { GalleryAsset } from "@/types/gallery";
const overrides = import.meta.glob<{ default: GalleryAsset[] }>("./gallery.json", {
eager: true,
});
const userModule = overrides["./gallery.json"];
const staticAssets = (userModule?.default ?? defaults) as GalleryAsset[];
export const staticGallerySource: GalleryDataSource = {
async listAssets() {
return staticAssets;
},
};
+65
View File
@@ -0,0 +1,65 @@
import type { VisionSuggestion } from "@/features/admin/vision";
export interface PublicAssetDraft {
id: string;
fileName: string;
scrubbedUrl: string;
width: number;
height: number;
blurDataUrl: string;
dateLabel: string;
metadataRemoved: string[];
}
export interface PrivateMetadataRecord {
originalFileName: string;
capturedAt?: string;
gps?: { lat: number; lng: number };
camera?: string;
}
export interface IngestionResult {
publicDraft: PublicAssetDraft;
privateMetadata: PrivateMetadataRecord;
aiSuggestions: VisionSuggestion[];
}
function randomId() {
return `draft_${Math.random().toString(36).slice(2, 10)}`;
}
function toDateLabel(now = new Date()) {
return now.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export async function buildIngestionDraft(
file: File,
suggestions: VisionSuggestion[],
): Promise<IngestionResult> {
const objectUrl = URL.createObjectURL(file);
const metadataRemoved = ["EXIF", "IPTC", "XMP"];
return {
publicDraft: {
id: randomId(),
fileName: file.name,
scrubbedUrl: objectUrl,
width: 1200,
height: 800,
blurDataUrl:
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMyIgaGVpZ2h0PSIyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IGZpbGw9IiNlNGYxZWQiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiLz48L3N2Zz4=",
dateLabel: toDateLabel(),
metadataRemoved,
},
privateMetadata: {
originalFileName: file.name,
capturedAt: new Date().toISOString(),
camera: "Unknown",
},
aiSuggestions: suggestions,
};
}
+41
View File
@@ -0,0 +1,41 @@
export interface VisionSuggestion {
commonName: string;
scientificName?: string;
confidence: number;
suggestedTags: string[];
}
export interface VisionProvider {
name: string;
analyzeImage(input: { fileName: string; mimeType: string }): Promise<VisionSuggestion[]>;
}
export class MockVisionProvider implements VisionProvider {
name = "mock";
async analyzeImage(input: {
fileName: string;
mimeType: string;
}): Promise<VisionSuggestion[]> {
const baseTag = input.mimeType.startsWith("image/") ? "photo" : "asset";
return [
{
commonName: "Coastal Subject",
confidence: 0.78,
suggestedTags: [baseTag, "coastal", "review"],
},
];
}
}
export async function safeAnalyzeImage(
provider: VisionProvider,
input: { fileName: string; mimeType: string },
): Promise<VisionSuggestion[]> {
try {
return await provider.analyzeImage(input);
} catch (error) {
console.error("Vision provider failed; falling back to manual review.", error);
return [];
}
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/main.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
+15
View File
@@ -0,0 +1,15 @@
import type { RouteLocationNormalized } from "vue-router";
export function canAccessAdmin(to: RouteLocationNormalized): boolean {
if (import.meta.env.DEV) {
return true;
}
const keyFromQuery = typeof to.query.key === "string" ? to.query.key : "";
const expected = import.meta.env.VITE_ADMIN_ACCESS_KEY;
if (!expected) {
return false;
}
return keyFromQuery.length > 0 && keyFromQuery === expected;
}
+24
View File
@@ -0,0 +1,24 @@
import { createRouter, createWebHistory } from "vue-router";
import GalleryView from "@/views/GalleryView.vue";
import AdminWorkbenchView from "@/views/AdminWorkbenchView.vue";
import { canAccessAdmin } from "@/router/adminAccess";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: "/", name: "gallery", component: GalleryView },
{ path: "/admin", name: "admin", component: AdminWorkbenchView },
],
scrollBehavior() {
return { top: 0 };
},
});
router.beforeEach((to) => {
if (to.name === "admin" && !canAccessAdmin(to)) {
return { name: "gallery" };
}
return true;
});
export default router;
+87
View File
@@ -0,0 +1,87 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import {
buildIngestionDraft,
type IngestionResult,
} from "@/features/admin/ingestion";
import {
MockVisionProvider,
safeAnalyzeImage,
type VisionProvider,
} from "@/features/admin/vision";
type ProviderKind = "mock";
const STORAGE_KEY = "beacho.admin.settings.v1";
interface AdminSettings {
provider: ProviderKind;
preserveMetadata: boolean;
}
export const useAdminStore = defineStore("admin", () => {
const settings = ref<AdminSettings>({
provider: "mock",
preserveMetadata: false,
});
const queue = ref<IngestionResult[]>([]);
const isProcessing = ref(false);
const error = ref<string | null>(null);
function loadPersistedSettings() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
try {
const parsed = JSON.parse(raw) as Partial<AdminSettings>;
settings.value = {
provider: parsed.provider === "mock" ? parsed.provider : "mock",
preserveMetadata: Boolean(parsed.preserveMetadata),
};
} catch (cause) {
console.error("Failed to parse admin settings.", cause);
}
}
watch(
settings,
(value) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
},
{ deep: true },
);
const activeProvider = computed<VisionProvider>(() => new MockVisionProvider());
async function ingestFiles(files: File[]) {
if (files.length === 0) {
return;
}
isProcessing.value = true;
error.value = null;
try {
for (const file of files) {
const suggestions = await safeAnalyzeImage(activeProvider.value, {
fileName: file.name,
mimeType: file.type,
});
const draft = await buildIngestionDraft(file, suggestions);
queue.value.unshift(draft);
}
} catch (cause) {
error.value = "Upload processing failed. Please retry.";
console.error(cause);
} finally {
isProcessing.value = false;
}
}
return {
settings,
queue,
isProcessing,
error,
loadPersistedSettings,
ingestFiles,
};
});
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useGalleryStore, BATCH } from "@/stores/gallery";
describe("gallery store", () => {
beforeEach(async () => {
setActivePinia(createPinia());
const store = useGalleryStore();
await store.init();
});
it("loads static assets and paginates", () => {
const store = useGalleryStore();
expect(store.allAssets.length).toBeGreaterThan(0);
expect(store.visibleAssets.length).toBe(BATCH);
store.loadMore();
expect(store.visibleAssets.length).toBe(BATCH * 2);
});
it("filters by collection and tags", () => {
const store = useGalleryStore();
store.setCollection("Birds");
expect(store.filteredAssets.every((asset) => asset.collection === "Birds")).toBe(true);
const firstTag = store.filteredAssets[0]?.tags[0];
expect(firstTag).toBeTruthy();
if (!firstTag) {
return;
}
store.toggleTag(firstTag);
expect(store.filteredAssets.every((asset) => asset.tags.includes(firstTag))).toBe(true);
});
it("cycles lightbox navigation", () => {
const store = useGalleryStore();
const firstId = store.filteredAssets[0]?.id;
expect(firstId).toBeTruthy();
if (!firstId) {
return;
}
store.openLightbox(firstId);
const originalIndex = store.lightboxIndex;
store.stepLightbox(1);
expect(store.lightboxIndex).not.toBe(originalIndex);
store.stepLightbox(-1);
expect(store.lightboxAsset?.id).toBe(firstId);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { loadGalleryAssets } from "@/data";
import type { Collection, GalleryAsset } from "@/types/gallery";
export const BATCH = 12;
function assetMatchesFilters(
asset: GalleryAsset,
collection: Collection | "all",
selectedTags: Set<string>,
): boolean {
if (collection !== "all" && asset.collection !== collection) {
return false;
}
if (selectedTags.size === 0) {
return true;
}
return [...selectedTags].some((t) => asset.tags.includes(t));
}
export const useGalleryStore = defineStore("gallery", () => {
const allAssets = ref<GalleryAsset[]>([]);
const isLoading = ref(false);
const loadError = ref<string | null>(null);
const activeCollection = ref<Collection | "all">("all");
const selectedTags = ref<Set<string>>(new Set());
const visibleCount = ref(BATCH);
const lightboxAssetId = ref<string | null>(null);
const filteredAssets = computed(() =>
allAssets.value.filter((a) =>
assetMatchesFilters(a, activeCollection.value, selectedTags.value),
),
);
const visibleAssets = computed(() =>
filteredAssets.value.slice(0, visibleCount.value),
);
const hasMore = computed(
() => visibleCount.value < filteredAssets.value.length,
);
const allTags = computed(() => {
const s = new Set<string>();
for (const a of allAssets.value) {
for (const t of a.tags) {
s.add(t);
}
}
return [...s].sort((a, b) => a.localeCompare(b));
});
function setCollection(c: Collection | "all") {
activeCollection.value = c;
}
async function init() {
if (isLoading.value) {
return;
}
isLoading.value = true;
loadError.value = null;
try {
allAssets.value = await loadGalleryAssets();
resetPagination();
} catch (error) {
loadError.value = "Unable to load gallery assets.";
allAssets.value = [];
resetPagination();
console.error(error);
} finally {
isLoading.value = false;
}
}
function toggleTag(tag: string) {
const next = new Set(selectedTags.value);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
selectedTags.value = next;
}
function clearTags() {
selectedTags.value = new Set();
}
function loadMore() {
if (!hasMore.value) {
return;
}
visibleCount.value = Math.min(
visibleCount.value + BATCH,
filteredAssets.value.length,
);
}
function resetPagination() {
visibleCount.value = Math.min(BATCH, filteredAssets.value.length);
}
watch([activeCollection, selectedTags], () => {
resetPagination();
});
function openLightbox(assetId: string) {
lightboxAssetId.value = assetId;
}
function closeLightbox() {
lightboxAssetId.value = null;
}
const lightboxAsset = computed(() => {
const id = lightboxAssetId.value;
if (!id) {
return null;
}
return filteredAssets.value.find((a) => a.id === id) ?? null;
});
const lightboxIndex = computed(() => {
const id = lightboxAssetId.value;
if (!id) {
return -1;
}
return filteredAssets.value.findIndex((a) => a.id === id);
});
function stepLightbox(delta: number) {
const list = filteredAssets.value;
if (list.length === 0) {
return;
}
let i = lightboxIndex.value;
if (i < 0) {
i = 0;
}
const next = (i + delta + list.length) % list.length;
lightboxAssetId.value = list[next]!.id;
}
return {
allAssets,
isLoading,
loadError,
activeCollection,
selectedTags,
visibleAssets,
filteredAssets,
hasMore,
allTags,
setCollection,
init,
toggleTag,
clearTags,
loadMore,
resetPagination,
lightboxAssetId,
lightboxAsset,
lightboxIndex,
openLightbox,
closeLightbox,
stepLightbox,
};
});
+5
View File
@@ -0,0 +1,5 @@
import { afterEach } from "vitest";
afterEach(() => {
localStorage.clear();
});
+21
View File
@@ -0,0 +1,21 @@
/** Public-safe fields only; no GPS or raw EXIF in the gallery manifest. */
export type Collection = "Garden" | "Beachhouse" | "Birds" | "Coastal Sky";
export interface GalleryAsset {
id: string;
/** Public CDN or static path to scrubbed image (no sensitive metadata in file). */
scrubbedUrl: string;
/** Optional responsive descriptors, e.g. Cloudflare Images variants. */
scrubbedSrcSet?: string;
sizes?: string;
width: number;
height: number;
/** Tiny placeholder for blur-up (LQIP). */
blurDataUrl: string;
collection: Collection;
tags: string[];
dateLabel: string;
speciesOrSubject: string;
scientificName?: string;
alt: string;
}
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { RouterLink } from "vue-router";
</script>
<template>
<div class="max-w-2xl mx-auto pt-10 pb-8">
<h1 class="font-display text-3xl sm:text-4xl text-moonlight mb-4">
Internal control plane
</h1>
<p class="text-moonlight/80 leading-relaxed mb-6">
In production, this surface is intended as a <strong>private</strong> layer
for home automation and garden asset management. It would be reachable only
from trusted networks - for example via
<span class="text-succulent font-medium">Cloudflare Tunnel</span> to your
home lab and/or restricted by
<span class="text-succulent font-medium">Cloudflare Worker / WAF</span>
rules on <code class="text-sm bg-moonlight/10 px-1 rounded">CF-Connecting-IP</code>
(e.g. redirecting your home IP to an <code class="text-sm bg-moonlight/10 px-1 rounded">admin.</code>
hostname while the public sees only the gallery).
</p>
<div
class="rounded-2xl border border-moonlight/15 bg-white/60 p-6 text-sm text-moonlight/85 space-y-3"
>
<p class="font-semibold text-moonlight">Alpha note</p>
<p>
Upload, EXIF extraction, metadata stripping, and AI-assisted tagging are
<strong>not</strong> implemented in this build. The public gallery uses a
static manifest of scrubbed assets only.
</p>
<p>
See the project backlog for secure ingestion, pluggable vision providers,
and routing hardening.
</p>
</div>
<p class="mt-8">
<RouterLink
to="/"
class="text-succulent font-medium hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer rounded"
>
Back to gallery
</RouterLink>
</p>
</div>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useAdminStore } from "@/stores/admin";
const store = useAdminStore();
const fileInput = ref<HTMLInputElement | null>(null);
const hasDrafts = computed(() => store.queue.length > 0);
onMounted(() => {
store.loadPersistedSettings();
});
async function onFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files) {
return;
}
await store.ingestFiles(Array.from(input.files));
input.value = "";
}
function openFilePicker() {
fileInput.value?.click();
}
</script>
<template>
<section class="max-w-5xl mx-auto py-10 space-y-8">
<header class="space-y-3">
<h1 class="font-display text-3xl sm:text-4xl text-moonlight">
Admin workbench
</h1>
<p class="text-moonlight/80 max-w-3xl">
This scaffold models a private ingestion flow: upload files, strip metadata for
public use, then review AI-assisted suggestions before publishing.
</p>
</header>
<div class="rounded-2xl border border-moonlight/15 bg-white/70 p-6 space-y-4">
<h2 class="font-display text-xl text-moonlight">Pipeline settings</h2>
<div class="flex flex-wrap gap-4 text-sm">
<label class="flex items-center gap-2">
<span class="text-moonlight/80">Vision provider</span>
<select
v-model="store.settings.provider"
class="rounded border border-moonlight/20 bg-white px-2 py-1"
>
<option value="mock">Mock provider</option>
</select>
</label>
<label class="flex items-center gap-2">
<input v-model="store.settings.preserveMetadata" type="checkbox" />
<span class="text-moonlight/80">Allow metadata override (default off)</span>
</label>
</div>
</div>
<div class="rounded-2xl border border-moonlight/15 bg-white/70 p-6 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="font-display text-xl text-moonlight">Upload intake</h2>
<button
type="button"
class="rounded-full bg-moonlight px-4 py-2 text-sm text-seafoam hover:bg-succulent transition-colors"
@click="openFilePicker"
>
Add files
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
multiple
@change="onFilesSelected"
/>
<p v-if="store.isProcessing" class="text-sm text-moonlight/80">
Processing uploads...
</p>
<p v-if="store.error" class="text-sm text-red-800">{{ store.error }}</p>
<p v-if="!hasDrafts" class="text-sm text-moonlight/65">
No pending drafts yet. Add images to start metadata scrubbing and review.
</p>
</div>
<div v-if="hasDrafts" class="space-y-4">
<h2 class="font-display text-xl text-moonlight">Draft review queue</h2>
<article
v-for="draft in store.queue"
:key="draft.publicDraft.id"
class="grid gap-4 rounded-2xl border border-moonlight/15 bg-white/80 p-4 sm:grid-cols-[180px_1fr]"
>
<img
:src="draft.publicDraft.scrubbedUrl"
:alt="draft.publicDraft.fileName"
class="w-full h-36 sm:h-full object-cover rounded-lg border border-moonlight/10"
/>
<div class="space-y-3">
<div>
<h3 class="font-medium text-moonlight">{{ draft.publicDraft.fileName }}</h3>
<p class="text-xs text-moonlight/70">
Public metadata removed: {{ draft.publicDraft.metadataRemoved.join(", ") }}
</p>
</div>
<div>
<p class="text-sm font-medium text-moonlight/85 mb-1">AI suggestions</p>
<p v-if="draft.aiSuggestions.length === 0" class="text-sm text-moonlight/65">
Provider unavailable or no confident matches. Manual tagging required.
</p>
<ul v-else class="flex flex-wrap gap-2">
<li
v-for="item in draft.aiSuggestions"
:key="`${draft.publicDraft.id}-${item.commonName}`"
class="rounded-full bg-meyer/40 px-3 py-1 text-xs text-moonlight"
>
{{ item.commonName }} ({{ Math.round(item.confidence * 100) }}%)
</li>
</ul>
</div>
</div>
</article>
</div>
</section>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import GalleryFilterBar from "@/components/GalleryFilterBar.vue";
import GalleryLightbox from "@/components/GalleryLightbox.vue";
import GalleryMasonry from "@/components/GalleryMasonry.vue";
import { useGalleryStore } from "@/stores/gallery";
const store = useGalleryStore();
let ticking = false;
function checkScroll() {
const el = document.documentElement;
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
const threshold = el.scrollHeight * 0.2;
if (remaining <= threshold) {
store.loadMore();
}
}
function onScroll() {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(() => {
ticking = false;
checkScroll();
});
}
onMounted(() => {
void store.init();
window.addEventListener("scroll", onScroll, { passive: true });
checkScroll();
});
onUnmounted(() => {
window.removeEventListener("scroll", onScroll);
});
</script>
<template>
<div>
<section aria-labelledby="gallery-heading" class="pt-6 sm:pt-10">
<h1
id="gallery-heading"
class="font-display text-4xl sm:text-5xl text-moonlight mb-2"
>
The Infinite Sanctuary
</h1>
<p class="max-w-2xl text-moonlight/75 text-lg mb-8">
Flora, fauna, and light along the Encinitas coast - a living archive in
the Botanical Coast palette.
</p>
<GalleryFilterBar v-if="!store.isLoading && !store.loadError" />
<p v-if="store.isLoading" class="rounded-lg bg-white/50 p-4 text-moonlight/80">
Loading gallery...
</p>
<p
v-else-if="store.loadError"
class="rounded-lg border border-red-300/40 bg-red-100/40 p-4 text-red-950"
>
{{ store.loadError }}
</p>
<GalleryMasonry v-else />
<div
v-if="store.hasMore"
class="flex justify-center mt-10"
>
<button
type="button"
class="rounded-full border border-moonlight/20 bg-white px-6 py-2 text-sm font-medium text-moonlight hover:border-succulent/50 hover:text-succulent focus:outline-none focus-visible:ring-2 focus-visible:ring-meyer"
@click="store.loadMore()"
>
Load more
</button>
</div>
</section>
<GalleryLightbox />
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
+19
View File
@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
moonlight: "#0F172A",
meyer: "#FDE047",
succulent: "#10B981",
seafoam: "#F8FAFC",
},
fontFamily: {
display: ['"Playfair Display"', "Georgia", "serif"],
sans: ['"Source Sans 3"', "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+7
View File
@@ -0,0 +1,7 @@
import { expect, test } from "@playwright/test";
test("loads gallery heading and cards", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "The Infinite Sanctuary" })).toBeVisible();
await expect(page.getByRole("img").first()).toBeVisible();
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
+23
View File
@@ -0,0 +1,23 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
include: ["src/**/*.test.ts"],
exclude: ["tests/**", "node_modules/**", "dist/**"],
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/stores/**/*.ts", "src/features/**/*.ts"],
},
},
});