No files matched your search
@@ -0,0 +1 @@
|
||||
VITE_ADMIN_ACCESS_KEY=replace-with-strong-random-token
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
coverage
|
||||
node_modules
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 90,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.)_
|
||||
@@ -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/`).
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { GalleryAsset } from "@/types/gallery";
|
||||
import { staticGallerySource } from "@/data/staticGallerySource";
|
||||
|
||||
export async function loadGalleryAssets(): Promise<GalleryAsset[]> {
|
||||
return staticGallerySource.listAssets();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { GalleryAsset } from "@/types/gallery";
|
||||
|
||||
export interface GalleryDataSource {
|
||||
listAssets(): Promise<GalleryAsset[]>;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||