diff --git a/.dockerignore b/.dockerignore index 0d05712..3054fec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,14 @@ -.git -.github +.git +.github +.playwright-cli/ +**/.playwright-cli/ +**/playwright-report/ +**/test-results/ node_modules site/node_modules site/.next +visual-audit/node_modules +visual-audit/dist .output .sst .tanstack @@ -12,16 +18,37 @@ site/data/*.db site/data/*.db-* site/data/*.sqlite site/data/*.sqlite-* +**/*.sqlite +**/*.sqlite-* +**/*.sqlite3 +**/*.db +**/*.db-* +**/*.wal +**/*.shm +**/*.bak +**/*.backup site/data/backups/ site/data/media-ai-cache/ .env .env.local -.env.*.local +.env.*.local +**/.env +**/.env.local +**/.env.*.local +.visual-audit-lab.env #recycle @eaDir **/@eaDir # Legacy local media placeholders, image cache, and releases pics/ -cache/ -releases/ +cache/ +releases/ +backups/ +restores/ +archive/ +design/ +output/ +visual-audits/ +visual-audit-lab/ +secrets/ wsl_build_nas_deploy_commands.md diff --git a/.env.example b/.env.example index 8192189..614304b 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,23 @@ DATA_ROOT=/app/site/data STUDIO_PASSWORD=change-this-before-public SESSION_SECRET=replace-with-a-long-random-secret -# Payment infrastructure +# Authenticated visual-audit inventory access. +# Generate independently from STUDIO_PASSWORD and SESSION_SECRET. +VISUAL_AUDIT_TOKEN= +VISUAL_AUDIT_MAX_RECORDS=5000 +# Production must report the mounted library as production-live. Snapshot-lab +# and local-smoke runners override this with their isolated provenance. +WOODSMITH_MEDIA_PROVENANCE=production-live +# Strict execution policy: auto, cpu, or cuda. auto uses CUDA only after a +# representative benchmark has enabled a deterministic stage in this build. +VISUAL_AUDIT_ACCELERATOR=auto +# "auto" uses available CPU parallelism with conservative caps: capture 2, +# validation 6, and report 6. Explicit ranges are capture 1-6 and validation/report 1-8. +VISUAL_AUDIT_CAPTURE_WORKERS=auto +VISUAL_AUDIT_VALIDATION_WORKERS=auto +VISUAL_AUDIT_REPORT_WORKERS=auto + +# Payment infrastructure STRIPE_SECRET_KEY= STRIPE_PUBLISHABLE_KEY= @@ -66,6 +82,14 @@ MEDIA_AI_AMBIGUITY_DELTA=0.08 MEDIA_AI_MEDIA_ROOT= MEDIA_AI_CACHE= MEDIA_AI_SIDECAR_TOKEN= +MEDIA_AI_HOST=127.0.0.1 +MEDIA_AI_PORT=8765 +MEDIA_AI_ACCELERATOR=auto +MEDIA_AI_CUDA_DEVICE=0 +MEDIA_AI_GPU_MEMORY_LIMIT_MB=4096 +MEDIA_AI_GPU_LEASE_FILE= +MEDIA_AI_EMBED_BATCH_SIZE=16 +MEDIA_AI_MODEL_LOCAL_ONLY=false MEDIA_AI_USE_OLLAMA=false MEDIA_AI_CLUSTER_SIMILARITY=0.84 MEDIA_AI_DUPLICATE_HASH_DISTANCE=8 diff --git a/.gitignore b/.gitignore index 44f23f8..43d01a2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,9 +38,14 @@ site/data/*.sqlite-shm site/data/*.sqlite-wal # Legacy local media placeholders, image cache, and releases -pics/ -cache/ -releases/ +pics/ +cache/ +releases/ +backups/ +restores/ +site/data/backups/ +site/data.restore-*/ +site/data.rollback-*/ # Secrets .env @@ -52,3 +57,13 @@ releases/ cloudflared/*.json cloudflared/config.yml wsl_build_nas_deploy_commands.md + +# Visual audit +visual-audit/node_modules/ +visual-audit/dist/ +visual-audit/.auth/ +visual-audit/tmp/ +visual-audits/ +visual-audit-lab/ +.visual-audit-lab.env +secrets/ diff --git a/Dockerfile b/Dockerfile index 609e95d..188d082 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,33 @@ FROM node:22-bookworm-slim AS builder + +ARG WOODSMITH_BUILD_SHA=unknown + WORKDIR /app/site + ENV NEXT_TELEMETRY_DISABLED=1 +ENV WOODSMITH_BUILD_SHA=${WOODSMITH_BUILD_SHA} COPY site/package.json ./package.json COPY site/package-lock.json ./package-lock.json + RUN npm ci COPY site ./ + RUN npm run build + FROM node:22-bookworm-slim AS runner + +ARG WOODSMITH_BUILD_SHA=unknown + WORKDIR /app/site + ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3002 ENV HOSTNAME=0.0.0.0 +ENV WOODSMITH_BUILD_SHA=${WOODSMITH_BUILD_SHA} RUN groupadd --system --gid 1001 nextjs RUN useradd --system --uid 1001 --gid 1001 nextjs @@ -22,10 +35,15 @@ RUN useradd --system --uid 1001 --gid 1001 nextjs COPY --from=builder --chown=nextjs:nextjs /app/site/.next/standalone ./ COPY --from=builder --chown=nextjs:nextjs /app/site/.next/static ./.next/static COPY --from=builder --chown=nextjs:nextjs /app/site/public ./public +COPY --from=builder --chown=nextjs:nextjs /app/site/scripts/runtime-state.mjs ./ops/runtime-state.mjs +COPY --from=builder --chown=nextjs:nextjs /app/site/scripts/runtime-state-lib.mjs ./ops/runtime-state-lib.mjs + RUN mkdir -p /app/site/data \ && chown nextjs:nextjs /app/site/data \ && chmod -R a+rX /app/site/public /app/site/.next/static USER nextjs + EXPOSE 3002 + CMD ["node", "--experimental-sqlite", "server.js"] diff --git a/PLANS.md b/PLANS.md index 08a2da6..0174a25 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1,5 +1,28 @@ # PLANS.md +## 2026-07-11 Sitewide UX, Data, Commission, And Visual Archive Overhaul + +- Status: IN PROGRESS ON FEATURE BRANCH; NOT DEPLOYED +- Branch: `codex/sitewide-studio-ux-commission-overhaul-20260711` + +| Slice | Status | Evidence | +|------|--------|----------| +| Additive data model and compatibility | DONE / COMMITTED | `d35eb35` adds the migration ledger, normalized piece media, typed commerce policies, edit/rename history, compatibility synchronization, transactional reference rewriting, and disposable-database tests. | +| Audit and category design | DONE / COMMITTED | `30c87f6` records the evidence audit; `959a2ca` adds managed visual category icons and tests. | +| Structured Studio and public content | DONE / COMMITTED | `8b2a39b` adds structured footer/home services, typed public pricing/inquiry/review behavior, visual media selection, compact Page/Piece/Process editing, media roles, and public build records. | +| Conceptual proportional 3D preview | DONE / COMMITTED | `9e143da` adds route-local R3F templates, view/camera controls, exact dimensions, deterministic SVG/text fallbacks, demand rendering, and estimator tests. | +| Deterministic visual archive | IMPLEMENTED / EXACT TIER 1 AND NAS RECOVERY VALIDATED; TIER 2/3 PENDING | The QA slice provides protected bounded inventory, strict live-readonly and isolated snapshot-lab modes, deterministic tiled high-resolution capture, deep UI states, searchable HTML/PDF reports, checksums, diagnostics, diffs, locks, retention, and bounded NAS-safe workers. Exact commit `b7f5b46` passed Tier 1 live-readonly with 312 captures across 27 routes, zero successful unsafe requests, both media gates green, and zero unexpected diagnostics. Its isolated snapshot lab passed 386 captures across 39 routes, exactly one draft save plus cleanup delete, zero residual drafts, SQLite `quick_check`, unchanged source data/media, and complete container/volume cleanup. A restricted paired NAS backup and staging restore also pass. The scripts now require prebuilt exact `linux/amd64` candidate images rather than silently rebuilding. Exact Tier 2 production-clone, candidate deployment, Tier 3 live-production, and rollback evidence remain required. | +| Three-tier media and placeholder evidence | IMPLEMENTED / EXACT TIER 1 VALIDATED; TIER 2/3 PENDING | Schema-v5 archives identify Tier 1 synthetic, Tier 2 production clone, or Tier 3 live production. Protected inventory returns only public-media counts and SHA-256 fingerprints; browser evidence hashes direct and Next-optimized mounted sources. Deterministic reports reject provenance mismatch, missing mounted files, synthetic production markers, absent anonymous mounted-media observations, load failures, and unapproved visible placeholders. All 76 archive tests and the focused app aggregate tests pass, and both exact Tier 1 modes passed their media and placeholder gates. Tier 2 production-clone and NAS Tier 3 evidence remain required. | +| Verified accelerator policy | DONE / LOCAL GPU BENCHMARK VALIDATED | Added strict `auto|cpu|cuda` selection, bounded `nvidia-smi` capability detection, CDP renderer provenance, per-stage backend decisions, accelerator-aware archive validation, and reproducible browser/phase benchmarks. The available RTX 3070 Ti passed Docker CUDA-driver access, but Chromium CUDA/Vulkan and CUDA/GL candidates remained SwiftShader, explicit GPU-enabled SwiftShader changed canonical pixels and was slower, and nvImageCodec 0.9.0.20 exposed no GPU/hybrid PNG decoder. All 299 representative PNGs matched Sharp exactly only through the CPU `I_UNCHANGED` path. `auto` therefore selects the portable canonical pipeline; forced `cuda` fails closed until a stage passes the deterministic material-benefit gate. | +| Sidecar queue, supervision, and GPU arbitration | IMPLEMENTED / LOCAL WINDOWS CUDA VALIDATED; PRODUCTION RECHECK PENDING | Added strict CPU/CUDA selection, explicit SentenceTransformer device binding, bounded batch/allocator memory, single-flight work, cross-process lease ownership, indexed-cache queue status, crash supervision, authenticated health/wrong-token probing, and resumable cache semantics. A twelve-image representative benchmark measured CPU median 0.488176s versus CUDA 0.129774s (about 3.76x), identical eight-label rankings, maximum score drift 0.000105856, and 670 MiB peak reserved VRAM. A disposable authenticated CUDA service indexed four copied images with zero errors, released its lease, passed SQLite `quick_check`, rejected the wrong token, and left source copies unchanged. No sidecar process was left running; current NAS reachability remains a read-only preflight/deployment gate. The visual archive remains CPU/SwiftShader, preventing current GPU contention. | +| Typed atomic inline editing | DONE | Added a typed field registry spanning scalar, list, link, relation, media, status, and structural settings fields; trusted-origin enforcement; complete-batch validation; one SQLite transaction; optimistic expected-value conflicts; admin audit records; reset, rollback, keyboard, focus, and one-step Undo behavior. Thirty-two application tests, typecheck, lint, production build, and a disposable authenticated Chromium save/undo/reset/conflict/origin smoke pass. Final-candidate archive evidence remains a release gate. | +| Secure resumable custom requests | DONE / LOCAL DOCKER VALIDATED | Added the ten-step autosaved workflow, verified-account server drafts, idempotent server-authoritative submission, hashed submission/render quotas, private staged attachments with rollback, owner-bound generated previews, opaque project-access cookies, POST lookup without URL email, and exact dimension/category continuity into R3F. Additive schema v5, seed v6, 36 application tests, production build, image safety inspection, and disposable browser/container submission evidence pass. | +| Safe standalone and image build | DONE / LOCAL DOCKER VALIDATED | `safe-build` uses disposable build roots, excludes runtime data and test sources from Next tracing, rejects databases/backups/test files, and proves cleanup for child-failure, forbidden-output, and success paths. Docker Desktop Engine 29.6.1 and BuildKit 0.31.1 built/loaded exact commit `9940969` as app image `sha256:5a1eca29219bade97bfd622eea0b80495ae852b11c77b194946103649e1e88ae` and visual-audit image `sha256:17d6fc85167812d89960b8389368f79a9234368f7fd3048c2f3f3793508215be`, both `linux/amd64`. Direct filesystem checks found no runtime database, backup, environment file, private evidence, audit output, production media, or secret material, and the app image starts with an empty runtime data directory. The ignored 100,703,519-byte `linux/amd64` app-image archive passes gzip integrity and Docker reload with SHA-256 `B0B79A93E004430F5303AA1C16815F29C447E7F6462A717C5301E67C8D0B3375`. | +| Paired runtime recovery | DONE / NAS BACKUP AND STAGING RESTORE VALIDATED | The fail-closed CLI uses SQLite `VACUUM INTO`, hashes database/media/environment state, rejects source drift, symlinks, traversal, tampering, extras, and overwrites, verifies `quick_check`, and compensates failed promotions. Five disposable tests and the local rollback-container proof pass. NAS run `20260718T093133Z-b7f5b46` independently created, verified, and staged a restricted three-surface restore from manifest SHA-256 `4d23117297e10636fbd1d3bc1efdb317a3a16f0f20122341145336706ee01e7b`, with `quickCheck=ok`, 3,164 media files, 1,971,306,256 media bytes, and protected environment inclusion. | +| Transactional media organization | DONE / LOCAL BROWSER VALIDATED | Additive schema v6 records batch/item snapshots; selected media can be moved, deterministically renamed, tagged, rated, assigned, and given normalized role/stage/visibility metadata in one compensated operation. Optimistic rollback refuses later edits, filesystem failures reverse earlier moves, legacy and normalized references stay synchronized, and optional cleanup writes unpublished source-linked derivatives only. Forty application tests plus disposable desktop/mobile browser apply/rollback and SQLite `quick_check` evidence pass. | +| Accessibility, responsive UI, and public media performance | DONE / LOCAL BROWSER VALIDATED | Added skip navigation, route-aware current navigation, high-contrast focus treatment, focus-safe auto-hide header behavior, modal focus containment/restoration, keyboard/touch bounded lightbox pan and zoom, announced carousel position, 24px target protection, and hydration-safe persistent themes. Public portfolio/shop/carousel thumbnails use responsive Next image requests while the full-screen viewer retains the source file; raw media supports ETag/Last-Modified revalidation. Commit `9940969` also restores a valid Portfolio/Process heading hierarchy. Exact-image browser QA passed 376 combinations across 47 discovered routes at 1440, 430, 390, and 320px in both themes with zero horizontal overflow, unnamed/unlabelled controls, duplicate IDs, heading skips, missing alt text, 5xx responses, or page errors. Base/muted contrast measured 15.61/6.38 in light mode and 18.43/12.04 in dark mode. | +| Remaining product work | PENDING | Exact Tier 2 production-clone evidence, final documentation review, candidate deployment, Tier 3 live archive, production rollback/persistence checks, current production sidecar reachability, and post-deployment verification remain active. | + ## 2026-07-05 Local-First Media AI, Header, and Persistence Pass - Status: LIVE DEPLOYED AND VERIFIED ON SYNOLOGY @@ -76,7 +99,7 @@ Verification for this pass: `npm run typecheck`, `npm run lint`, and `npm run bu |----|--------|------------------| | 1. Public navigation and Process/Shop separation | DONE | Removed Process from seeded and rendered primary navigation, removed the Process/behind-the-scenes block from Shop, preserved the dedicated `/process` archive and legacy Journal redirects, and added seed v5 exact-string cleanup for persisted legacy copy. | | 2. Buyer email verification | DONE / CONFIGURATION REQUIRED | New accounts receive expiring verification tokens, unverified customer login is rejected, resend accepts an email without an authenticated session, SMTP acceptance is checked for the primary recipient, and UI errors distinguish configuration/authentication/sender/connection failures. Live dispatch still requires valid `SMTP_*` credentials and sender values. | -| 3. Direct visual editing | DONE | Admin pencil controls intercept in capture phase and edit mapped public text/URLs in place without navigation or full-page refresh. Route changes clear stale editor state; structural edits use the explicit full-editor handoff. No raw JSON control is exposed. | +| 3. Direct visual editing | DONE | Admin pencil controls intercept in capture phase and edit mapped public fields in place without navigation or full-page refresh. The typed registry drives the server allowlist; saves are atomic, audited, conflict-aware, origin-protected, and reversible. Route changes clear stale editor state; structural edits use the explicit visual full-editor handoff. No raw JSON control is exposed. | | 4. Compact media assignment desk | DONE | Replaced the long media editor stack with a bounded three-pane workspace: utility tools, paged responsive thumbnail browser, and one active inspector. Added whole-library search, assignment/type filters, collapsed upload/automation/crop sections, and in-place action state. | | 5. Media assignment integrity | DONE | Reviewed assignments now remove stale old-piece gallery membership and add new-piece membership. Unreviewed assignments remain private. Refresh/cluster tools run locally without AI credentials; the AI route requires admin authentication. | | 6. Keyboard media workflow | DONE | J/K navigate visible media, F focuses whole-library search, P focuses piece assignment, U clears it, R toggles review state, S saves, Shift+S saves and advances, A approves and advances, and roving tab stops keep one active thumbnail keyboard-focusable. | diff --git a/README.md b/README.md index f3ad5c2..528f3cf 100644 --- a/README.md +++ b/README.md @@ -16,26 +16,30 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company - Portfolio category filtering managed through editable labels, matching terms, and icon styles in the Woodshop dashboard - Shop pages with asking-price language, cart totals, coupon handling, tax estimate, pickup/delivery/shipping labels, and Stripe checkout plumbing - Process writing under the dedicated `/process` archive, with markdown content and optional source-credit links for outside references -- Contact-first custom work requests with attachments, lead-time context, material preferences, project tracking, and an optional live procedural 3D scale preview +- Contact-first custom work requests with attachments, lead-time context, material preferences, project tracking, and an optional route-local React Three Fiber conceptual scale preview - Optional server-side OpenAI image-model previews for custom work when `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured; generated previews are stored back into `/app/pics` - Buyer account pages for signup, login, password reset, profile editing, profile images, and account-linked projects - Private Woodshop dashboard with focused workspace tabs for editing settings, pages, pieces, custom work types, users, media, process notes, projects, orders, reviews, and notifications through structured browser forms -- Admin-only pencil controls that edit mapped public text and links in place, with an explicit full-editor link for structural work -- A compact browser media desk with whole-library and AI-state filters, in-place paging and edits, explicit candidate review, guided local training actions, training-weighted visual ranking, upload/rename/delete safety, persistent assignment, crop/focal controls, and source credit against the writable NAS photo library +- Admin-only pencil controls backed by a typed field registry, atomic audited saves, conflict detection, URL/origin validation, reset/undo controls, and an explicit visual full-editor link for structural work +- A compact browser media desk with whole-library and AI-state filters, in-place paging and edits, explicit candidate review, guided local training actions, transactional selected-item folder/name/assignment/role/stage changes with rollback, upload/rename/delete safety, crop/focal controls, and source credit against the writable NAS photo library - Email notification queueing, Stripe invoice creation, and EasyPost shipping-label requests when the related environment variables are configured -- Full-size image lightbox support with zoom, pan, arrow navigation, plus `Esc` and close-button exit behavior +- Responsive piece carousels with announced position and full-size lightboxes that trap focus, restore the opener, support bounded keyboard/touch pan and zoom, and close through `Esc`, backdrop click, or the visible X control - Keyword/metadata search plus visual search across public content and, for admins, private media, visual labels, clusters, unpublished content, and project records. The optional local sidecar supplies shared image/text CLIP vectors; Gemini or OpenAI embeddings remain opt-in alternatives. -- Persistent light/day and black OLED night themes using the local ITC New Rennie Mackintosh font assets +- Persistent light/day and black OLED night themes using the local ITC New Rennie Mackintosh font assets; the cookie-backed initial theme and client store are synchronized without hydration overwriting the saved choice +- WCAG-oriented skip navigation, current-page navigation state, visible high-contrast focus, reduced-motion handling, compact 24px-or-larger targets, and focus clearance for the auto-hiding header +- Responsive optimized thumbnail requests on portfolio, shop, cart, and carousel surfaces; the raw source remains available in the full-screen viewer and direct media responses use ETag/Last-Modified revalidation - Programmatic Beaman Woodworks favicon and brand mark - Safe profile administration for renaming accounts, replacing legacy developer emails, and deleting non-current users from the dashboard - Compact auto-hide navigation that keeps the public and dashboard workspaces usable on narrow and desktop viewports +- A pinned two-mode Playwright visual archive with protected source/database/link inventory, evidence-based network diagnostics, keyboard skip-link states, read-only production capture, clone-only bounded mutation states, viewport-correct high-resolution tiles, complete restricted searchable HTML, deterministic representative bookmarked PDF/shareable atlases, checksums, baseline comparisons, and strict benchmark-gated CPU/CUDA provenance +- A fail-closed paired recovery tool that creates and verifies an online-consistent SQLite backup, a hashed copy of the mounted media tree, and an optional protected environment-file copy, then restores only to new staging destinations ## 📃 Production Notes - Persistence uses `node:sqlite`, which emits Node's experimental warning during build and runtime. - Studio overview reports the active `DATA_ROOT`, SQLite `quick_check`, journal mode, and seed version so rebuild-safe persistence can be verified from the browser. Seed upgrades are non-destructive for existing Studio-edited records. - `/journal` and `/journal/[slug]` now redirect to Process. New public writing should be published as Process notes. -- The public custom work flow is contact-first and includes a credential-free procedural 3D scale preview. The older SVG renderer remains for stored visualization snapshots. Optional photorealistic preview generation is available only when explicitly configured with a server-side OpenAI key and feature flag. +- The public custom work flow is contact-first and includes a credential-free, dynamically loaded React Three Fiber conceptual proportional preview. A deterministic SVG drawing remains available for fallback, printing, and submitted snapshots. Optional photorealistic preview generation is available only when explicitly configured with a server-side OpenAI key and feature flag. - The public site exposes admin-only edit controls when an admin is signed in. Mapped text and link fields save in place without a page reload; structural changes remain available through the linked `/studio` editor. - Scientist Desk remains published without photos until the correct black phenolic resin top, birds-eye maple rails, and white maple legs media are verified. - New piece records can be created without guessed photos. Media should be assigned only after review in the Woodshop dashboard. @@ -50,6 +54,11 @@ Woodsmith is a self-hosted Next.js application for the Beaman Woodworks company - `design/Beaman_Woodworks_V2_Google_Stitch_Beta/`: Beaman Woodworks 2.0 prototypes audited for layout, theme, and dashboard direction - `ITC_New_Rennie_Mackintosh_Complete_Family_Pack/`: source font assets for the site typography - `docker-compose.synology.yml`: Synology runtime model +- `visual-audit/`: pinned Playwright capture, report, comparison, validation, and NAS automation package +- `visual-audit/scripts/run-local-disposable-smoke.ps1`: exact-image Windows live-readonly or clone-only snapshot-lab smoke using fake credentials, synthetic media, non-root containers, and automatically removed volumes +- `visual-audit/scripts/docker-command.sh`: fail-closed Docker resolution and exact `linux/amd64` image/build-identity checks for Synology archive runs +- `site/scripts/runtime-state.mjs`: backup, verify, and staging-only restore CLI included in the production image at `/app/site/ops/runtime-state.mjs` +- `docs/visual-archive.md`: private live-readonly and snapshot-lab operating guide - `synology-nas-deploy.md`: deployment and NAS operations guide - `admin.md`: private Woodshop dashboard manual - `woodsmith_DeepWiki_Merged_03222026.md`: codebase architecture reference @@ -82,6 +91,12 @@ Required for a secure deployment: - `NEXT_PUBLIC_SITE_URL` - `DATA_ROOT` (production: `/app/site/data`) +Required only when the private visual archive is enabled: + +- `VISUAL_AUDIT_TOKEN` and optional `VISUAL_AUDIT_MAX_RECORDS` +- `WOODSMITH_MEDIA_PROVENANCE=production-live` for the production app; isolated runners set stricter provenance themselves +- private ignored secret files prepared by `visual-audit/scripts/prepare-live-secrets.sh` + Local-first media AI configuration: - `AI_PROVIDER`, `AI_ANALYSIS_PROVIDER`, `AI_EMBEDDING_PROVIDER`, `AI_FALLBACK_PROVIDER` @@ -89,10 +104,10 @@ Local-first media AI configuration: - `OLLAMA_BASE_URL` and `OLLAMA_VISION_MODEL` - `ENABLE_AI_MEDIA_ANALYSIS`, `ENABLE_EMBEDDING_SEARCH`, and `ENABLE_LOCAL_IMAGE_EMBEDDINGS` - `MEDIA_AI_MAX_BATCH`, `MEDIA_AI_CONFIDENCE_HIGH`, `MEDIA_AI_CONFIDENCE_MIN`, and `MEDIA_AI_AMBIGUITY_DELTA` -- sidecar-only `MEDIA_AI_MEDIA_ROOT`, `MEDIA_AI_CACHE`, `MEDIA_AI_SIDECAR_TOKEN`, `MEDIA_AI_USE_OLLAMA`, `MEDIA_AI_CLUSTER_SIMILARITY`, and `MEDIA_AI_DUPLICATE_HASH_DISTANCE` +- sidecar-only `MEDIA_AI_MEDIA_ROOT`, `MEDIA_AI_CACHE`, `MEDIA_AI_SIDECAR_TOKEN`, `MEDIA_AI_ACCELERATOR`, `MEDIA_AI_CUDA_DEVICE`, `MEDIA_AI_GPU_MEMORY_LIMIT_MB`, `MEDIA_AI_GPU_LEASE_FILE`, `MEDIA_AI_EMBED_BATCH_SIZE`, `MEDIA_AI_MODEL_LOCAL_ONLY`, `MEDIA_AI_USE_OLLAMA`, `MEDIA_AI_CLUSTER_SIMILARITY`, and `MEDIA_AI_DUPLICATE_HASH_DISTANCE` - optional `GEMINI_API_KEY`, `GEMINI_VISION_MODEL`, `GEMINI_EMBEDDING_MODEL`, and `ENABLE_GEMINI_FALLBACK` -The sidecar processes bounded resumable batches through the Studio trainer: **Train selected**, **Improve page**, and **Continue library** run scan/analyze/embed/cluster/rank steps in the correct order without requiring the woodshop user to manage those internals. Manual accepted assignments and rejected suggestions are persisted as training labels and influence later rankings; raw Scan/Analyze/Embed/Cluster actions remain available inside the Advanced section for diagnostics. The sidecar writes SHA-256/perceptual hashes, generated 768px review thumbnails, embeddings, analyses, and cluster state only to its configured cache directory outside the mounted photo tree. +The sidecar processes bounded resumable batches through the Studio trainer: **Train selected**, **Improve page**, and **Continue library** run scan/analyze/embed/cluster/rank steps in the correct order without requiring the woodshop user to manage those internals. Manual accepted assignments and rejected suggestions are persisted as training labels and influence later rankings; raw Scan/Analyze/Embed/Cluster actions remain available inside the Advanced section for diagnostics. The sidecar writes SHA-256/perceptual hashes, generated 768px review thumbnails, embeddings, analyses, and cluster state only to its configured cache directory outside the mounted photo tree. Its authenticated health response reports actual CPU/CUDA selection, bounded allocator memory, active work and lease ownership, and indexed-cache queue counts. CUDA-heavy work is single-flight and protected by a cross-process lease; automatic CUDA runtime failure retries the failed inference batch on CPU, while forced CUDA fails rather than claiming a fallback. Required for optional live services: @@ -135,6 +150,8 @@ Public: - `/process/[slug]` - `/commissions` - `/commissions/status` +- `/contact` +- `/care-and-warranty` - `/about` - `/search` @@ -143,7 +160,7 @@ Legacy redirects: - `/journal` - `/journal/[slug]` -Buyer account and request access: +Buyer account and request access: - `/account/signup` - `/account/login` @@ -151,7 +168,9 @@ Buyer account and request access: - `/account/reset` - `/account/profile` - `/account/projects` -- `/requests/[reference]` +- `/requests/[reference]` + +Custom work uses a ten-step, locally autosaved request flow. Verified accounts also receive resumable server drafts. Submission is idempotent, rate-limited by a hashed owner key, recalculates the estimate and lead time on the server, stages allowlisted private image references safely, and redirects without putting buyer email in the URL. Existing projects are opened through the POST lookup at `/commissions/status`, which issues an expiring `HttpOnly` capability cookie. Private Woodshop: @@ -161,6 +180,10 @@ Private Woodshop: ## 🚀 Deployment The supported deployment target is Synology NAS with Docker Compose and reverse proxy termination. The compose file mounts `/volume1/homes/Cooper/Photos/Dad_Woodworking_09262025` directly to `/app/pics:rw`; do not remount or bind the repo-local `pics/` folder under `docker_ssd`. `MEDIA_ROOT` must be an absolute container path. + +`npm run build` uses disposable build-time data and media roots and fails if Next standalone output contains SQLite, WAL/SHM, or backup files. Production state must enter the container only through the writable runtime mounts. + +Before deployment or a large media operation, use the paired runtime-state procedure in `synology-nas-deploy.md`. A completed backup is accepted only after its manifest hashes, exact file inventory, and SQLite `quick_check` pass; restore never overwrites the live data, media, or environment paths. After deploying a build from this branch, the startup migration updates legacy `lowestprime@proton.me` developer references in persisted settings and seeded profile data to `cooperbeaman@proton.me`. @@ -169,3 +192,4 @@ Use these docs together: - `synology-nas-deploy.md` - `admin.md` - `woodsmith_DeepWiki_Merged_03222026.md` +- `docs/visual-archive.md` diff --git a/admin.md b/admin.md index 02727c2..0210a72 100644 --- a/admin.md +++ b/admin.md @@ -11,7 +11,7 @@ This guide covers the private Woodshop dashboard at `/studio`. ## Dashboard areas -The dashboard opens on an overview workspace and lets you move between focused panels instead of loading every editor at once. Public pages also show admin-only pencil controls while you are signed in. Mapped text and links edit in place; **Full editor** opens the matching dashboard workspace for structural changes. The site header is intentionally compact and hides while scrolling down; scroll up, focus a header control, or move the pointer over the header area to reveal it again. +The dashboard opens on an overview workspace and lets you move between focused panels instead of loading every editor at once. Public pages also show admin-only pencil controls while you are signed in. Mapped text and links edit in place; use `Ctrl+S` to save, `Esc` to exit, **Reset unsaved** to restore the value shown when editing opened, or **Undo last save** to reverse the most recent inline batch. **Full editor** opens the matching visual dashboard workspace for structural changes. Inline batches are validated from a typed field registry, saved in one SQLite transaction, checked for concurrent changes, and recorded in the admin edit audit. The site header is intentionally compact and hides while scrolling down; scroll up, focus a header control, or move the pointer over the header area to reveal it again. When focus returns to page content, the header reveals without covering the focused control. The inline URL editor and media browser keep keyboard focus inside their modal surfaces and restore it when closed. ### Settings @@ -25,7 +25,7 @@ Changes save into the mounted SQLite data store, revalidate the matching public ### Portfolio and shop pieces -The Pieces section can add drafts, update titles and descriptions, set category tabs, revise materials and tags, assign media paths, control publication status, manage inventory count, set asking-price data for shop items, and mark whether media has been verified. +The Pieces section can add drafts, update titles and descriptions, set category tabs, revise materials and tags, select and order media visually by role, control publication status, manage inventory count, set asking-price data for shop items, and mark whether media has been verified. Raw path entry is not the normal workflow. The Categories section manages the public portfolio filters. Each category has a stable key, public label, matching terms, and icon style. Categories can be renamed safely; deletion requires that assigned pieces are either absent or reassigned to another category. @@ -61,10 +61,12 @@ The media section operates against the NAS photo library mounted directly to `/a - browse a compact thumbnail workspace instead of a long full-page stack of editors - assign media to a piece, process note, page, or project - rename files in place +- select up to 96 cards and apply one folder, deterministic name pattern, piece assignment, normalized role/stage/visibility, review state, quality rating, and tag change as a compensated batch +- roll back a recent completed batch when none of its media/link snapshots have been changed afterward - edit alt text, tags, focal X/Y, zoom, and reviewed status - use the visual crop editor to set focal point, zoom, crop frame, and crop notes through sliders and form controls - set cleanup mode, photo quality, source credit, visual search labels, and display order; verified-piece metadata is derived from the reviewed piece assignment -- generate a cleaned copy of an image when `OPENAI_API_KEY` and `ENABLE_AI_BACKGROUND_CLEANUP=true` are configured +- generate an unpublished cleaned derivative under `derivatives/background-cleanup/` when `OPENAI_API_KEY` and `ENABLE_AI_BACKGROUND_CLEANUP=true` are configured; the source file is never overwritten, derivatives cannot be chained, and manual review remains required - delete files - refresh the indexed library - select one or more cards and run **Train selected**, **Improve page**, or **Continue library** without choosing individual scan/analyze/embed/cluster steps @@ -74,6 +76,8 @@ The media section operates against the NAS photo library mounted directly to `/a The desk keeps one active inspector beside the thumbnail browser on desktop; phones use a fixed-height Tools / Library / Inspector switcher to avoid stacking three long panes. Routine saves, assignments, renames, uploads, and deletes update in place without reloading the Studio route. `J`/`K` move between visible records, `F` focuses whole-library search, `P` focuses piece assignment, `U` clears the assignment, `R` toggles review state, `I` analyzes, `E` embeds, `C` inspects the current cluster, `S` saves, `Shift+S` saves and advances, and `A` approves and advances. Assignment changes update both media metadata and the affected piece galleries; unreviewed media stays private until approved. Reviewed assignments, reviewer rejections, verified cluster neighbors, and same-folder review history are saved as training evidence and weighted into later candidate rankings. +The **Organize selected** panel uses `{name}`, `{index}`, and `{folder}` rename tokens. Every batch is preflighted for collisions, limited to 96 records, recorded in `media_operation_batches` / `media_operation_items`, and applied with one SQLite reference transaction after the filesystem moves succeed. If a move or database update fails, completed moves are reversed. Rollback performs the same checks in reverse and stops rather than overwriting a media record or normalized link changed after the original batch. Back up `site/data/` and the mounted photo tree together before large production reorganizations. + Synology sidecar files such as `SYNOINDEX_MEDIA_INFO`, `.DS_Store`, `Thumbs.db`, AppleDouble `._*`, `@eaDir`, and `SYNOFILE_THUMB*` files are filtered during indexing and querying. Manual media assignments take priority over heuristic clustering. The verification queue offers at most one sufficiently separated best-piece proposal per unassigned image; ambiguous matches remain in the library for manual review. Inspecting a candidate never assigns it. ### Media automation providers @@ -89,8 +93,14 @@ Advanced actions expose **Rescan files**, **Analyze page**, **Analyze selected** Every cache record includes provider, model, version, source hash, and timestamp. Changing embedding model/provider creates a separate vector space and requires re-embedding. The local cache also holds generated 768px review thumbnails outside the source photo library. Cluster IDs and membership are persisted to media metadata, and partial cluster runs update only their selected paths instead of deleting unrelated cluster state. A cluster can inform ranking, but only manually reviewed labels provide the strongest propagation prior. Reviewer-rejected candidates and contradicted same-folder examples suppress later suggestions. The public gate still requires `reviewed=true`, accurate alt text, and an explicit save/assign action. +The authenticated sidecar health response reports whether work is active, the last action/outcome, actual CPU or CUDA selection, bounded PyTorch allocator memory, the cross-process GPU lease owner, and pending embedding/analysis/cluster counts. Those queue counts are intentionally `indexed-cache-only`; use **Continue library** to discover and advance uncached files rather than interpreting health as a recursive count of the complete NAS tree. The cache makes completed batches resumable after a process restart, but an active synchronous request stops with the process. + +On the validated Windows GPU host, `MEDIA_AI_ACCELERATOR=auto` selects CUDA only when PyTorch exposes a usable device. A representative twelve-image benchmark measured a 0.488176-second CPU median and 0.129774-second CUDA median (about 3.76x faster), with identical eight-label rankings, maximum score drift 0.000105856, and 670 MiB peak reserved VRAM. The visual archive remains CPU/SwiftShader because no deterministic beneficial GPU stage qualified there. Do not run a future CUDA audit and sidecar training concurrently until both use the same operator-controlled maintenance window and shared host lease. `tools/media-ai-sidecar/scripts/probe-sidecar.ps1` verifies health and wrong-token rejection without displaying the configured token. + The same visual picker is now used in Pages, Pieces, and Process editors, so cover images and piece galleries can be selected directly from the mounted library without typing raw paths. +Public piece and shop cards request responsive optimized thumbnails rather than each raw original. Opening a piece image still loads the source-resolution media in the full-screen viewer. Carousel arrows update an announced position; the viewer supports plus/minus/reset controls, bounded drag or arrow-key panning while zoomed, `Esc`, backdrop click, and the fixed X close control. + ### Visitor map The overview workspace now shows recent visitor sessions on a world map and in a recent-session list. The map is sourced from session records stored in SQLite. @@ -120,15 +130,27 @@ Password resets, verification links, project updates, contact requests, and comm ### Custom work contact -The public custom work page collects contact details, location, budget, requested piece type, preferred material, pickup/delivery/shipping preference, attachments, an optional 3D scale preview, and a written brief. It creates a private project record and redirects the buyer to a reference page. If `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, the visualizer can also generate a photorealistic preview and attach it only when the buyer chooses to include the preview. +The public custom work page is a ten-step guided request covering intent, category, room/use, exact working dimensions, materials, private reference uploads, conceptual preview, fulfillment, contact details, and final review. Browser autosave is always available; verified accounts also receive serialized server-side drafts that can resume on another browser. The server, not hidden browser fields, recalculates material, labor, overhead, markup, queue-aware lead time, and the planning total before creating the project. + +Reference uploads are limited to eight allowlisted image files, 20 MB each and 60 MB total. They are staged under the writable media mount, moved into the private project folder only after an idempotent project insert, and removed with the retry key if finalization fails. A honeypot and hashed per-owner submission window limit reduce automated abuse without storing raw IP addresses in quota tables. + +The preview uses a dynamically loaded React Three Fiber scene with perspective/orthographic and front/side/top/isometric controls, while the deterministic SVG drawing remains available if WebGL or motion is unavailable. If `OPENAI_API_KEY` and `ENABLE_PUBLIC_AI_RENDERING=true` are configured, generated previews have a separate owner-bound quota and can attach only once to the request that owns them. +## Private visual archive + +The visual archive is an operational QA tool, not a Studio content panel. It inventories public and authenticated routes, captures the final rendered interfaces, verifies keyboard skip navigation, and produces restricted PNG/HTML/PDF evidence. Schema-v5 runs are explicitly Tier 1 synthetic, Tier 2 production clone, or Tier 3 live production. The protected inventory returns only media counts and hashes; `live-media.json` and `placeholder-report.json` prove mounted public media and account for intentional or unexpected placeholders without recording filenames. Canonical routes retain the complete responsive/theme matrix and deep states; rendered link variants use desktop/tablet/mobile theme representatives plus archival desktop. Every raw restricted capture remains available in the searchable HTML, while the PDF and redacted edition use a recorded per-route representative selection so derived reports remain reviewable. Production capture is read-only at both browser and server layers. Save, upload, rename, delete, invoice, shipping, email, and model actions are captured only against an isolated SQLite/media clone. Read-only mutation failures are accepted only when they match an exact client policy record; aborted resources are accepted only when they carry same-origin Next.js RSC/prefetch evidence. + +Set `VISUAL_AUDIT_ACCELERATOR=auto` for normal operation. The runner records actual CUDA visibility and Chromium CDP renderer details, but enables a CUDA stage only after its representative benchmark is allowlisted in the build. `cpu` explicitly chooses the portable pipeline. `cuda` is a strict diagnostic mode and fails if the device or a verified deterministic CUDA stage is unavailable; it never silently falls back while reporting CUDA. Current RTX/Docker evidence retains CPU/SwiftShader because NVIDIA's tested codec exposed no GPU PNG decoder and headless Chromium remained on SwiftShader. See [docs/visual-archive.md](docs/visual-archive.md) for the complete phase matrix and rerun commands. + +Use [`docs/visual-archive.md`](docs/visual-archive.md) for secret preparation, smoke/full runs, snapshot-lab setup, validation, retention, and post-deployment gates. Never upload the restricted archive or its authentication state to public CI or Git. + ### Shop checkout The cart calculates subtotal, coupon discount, tax estimate, shipping estimate, and total. If Stripe is configured, the app creates a hosted Checkout Session. If Stripe is not configured, checkout stops at a configuration-needed state. ### Buyer project lookup -Buyers can use `/commissions/status` or `/requests/[reference]?email=buyer@example.com`. Reference links should be shared only with the buyer and trusted collaborators. +Buyers use `/commissions/status` to exchange the project reference and buyer email through a POST form for a 30-day, `HttpOnly`, same-site capability cookie. Email addresses are never placed in request URLs. A signed-in buyer whose account email matches the project can open it directly; administrators retain operational access. ## Environment-dependent services @@ -181,4 +203,6 @@ Open Media → Guided media trainer and click **Refresh status**. For the defaul ### Page edits do not survive rebuilds -Open Studio overview and check the Persistence card first. It should show a configured, writable `/app/site/data`, `quick_check=ok`, WAL journal mode, and the expected seed version. If it shows a different path or a warning, verify `DATA_ROOT=/app/site/data`, the Compose mount `/volume2/docker_ssd/woodsmith/site/data:/app/site/data`, and write access for the configured `PUID:PGID`. The application rejects a relative `DATA_ROOT` so a changed working directory cannot silently create a second SQLite database, and seed upgrades no longer overwrite browser-edited pages or settings. Back up `woodsmith.sqlite`, `woodsmith.sqlite-wal`, and `woodsmith.sqlite-shm` together or use SQLite's online backup command. +Open Studio overview and check the Persistence card first. It should show a configured, writable `/app/site/data`, `quick_check=ok`, WAL journal mode, and the expected seed version. If it shows a different path or a warning, verify `DATA_ROOT=/app/site/data`, the Compose mount `/volume2/docker_ssd/woodsmith/site/data:/app/site/data`, and write access for the configured `PUID:PGID`. The application rejects a relative `DATA_ROOT` so a changed working directory cannot silently create a second SQLite database, and seed upgrades no longer overwrite browser-edited pages or settings. + +Do not copy a live WAL database file by itself. Follow the paired runtime-state procedure in `synology-nas-deploy.md`; it uses SQLite `VACUUM INTO`, copies the matching media tree and protected environment file, records SHA-256 evidence, verifies `quick_check`, and restores only into new staging paths. Keep the pre-deploy data, media, environment, and rollback image until the replacement survives recreation and public validation. diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml index 61aa7bc..005949a 100644 --- a/docker-compose.synology.yml +++ b/docker-compose.synology.yml @@ -18,6 +18,9 @@ services: DATA_ROOT: "/app/site/data" STUDIO_PASSWORD: "${STUDIO_PASSWORD}" SESSION_SECRET: "${SESSION_SECRET}" + VISUAL_AUDIT_TOKEN: "${VISUAL_AUDIT_TOKEN}" + VISUAL_AUDIT_MAX_RECORDS: "${VISUAL_AUDIT_MAX_RECORDS:-5000}" + WOODSMITH_MEDIA_PROVENANCE: "production-live" STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY}" STRIPE_PUBLISHABLE_KEY: "${STRIPE_PUBLISHABLE_KEY}" EASYPOST_API_KEY: "${EASYPOST_API_KEY}" diff --git a/docker-compose.visual-audit-lab.yml b/docker-compose.visual-audit-lab.yml new file mode 100644 index 0000000..ea4dc3e --- /dev/null +++ b/docker-compose.visual-audit-lab.yml @@ -0,0 +1,126 @@ +services: + woodsmith-audit-lab: + image: "${WOODSMITH_AUDIT_APP_IMAGE:-woodsmith:prod}" + container_name: woodsmith-audit-lab + restart: "no" + user: "${PUID:?PUID must be set}:${PGID:?PGID must be set}" + read_only: true + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + PORT: "3002" + HOSTNAME: 0.0.0.0 + SELF_HOSTED: "true" + SITE_URL: http://woodsmith-audit-lab:3002 + NEXT_PUBLIC_SITE_URL: http://woodsmith-audit-lab:3002 + MEDIA_ROOT: /app/pics + DATA_ROOT: /app/site/data + STUDIO_PASSWORD: "${AUDIT_LAB_STUDIO_PASSWORD:?AUDIT_LAB_STUDIO_PASSWORD must be set}" + SESSION_SECRET: "${AUDIT_LAB_SESSION_SECRET:?AUDIT_LAB_SESSION_SECRET must be set}" + VISUAL_AUDIT_TOKEN_FILE: /run/secrets/audit_token + VISUAL_AUDIT_MAX_RECORDS: "${VISUAL_AUDIT_MAX_RECORDS:-5000}" + VISUAL_AUDIT_SNAPSHOT_LAB: "true" + ALLOW_INSECURE_AUDIT_COOKIES: "true" + WOODSMITH_MEDIA_PROVENANCE: "${AUDIT_MEDIA_PROVENANCE:?AUDIT_MEDIA_PROVENANCE must be set by prepare-snapshot-lab.sh}" + STRIPE_SECRET_KEY: "" + STRIPE_PUBLISHABLE_KEY: "" + EASYPOST_API_KEY: "" + SMTP_HOST: "" + SMTP_USER: "" + SMTP_PASSWORD: "" + OPENAI_API_KEY: "" + ENABLE_PUBLIC_AI_RENDERING: "false" + ENABLE_AI_BACKGROUND_CLEANUP: "false" + ENABLE_AI_MEDIA_ANALYSIS: "false" + ENABLE_EMBEDDING_SEARCH: "false" + ENABLE_LOCAL_IMAGE_EMBEDDINGS: "false" + ENABLE_GEMINI_FALLBACK: "false" + AI_PROVIDER: disabled + AI_ANALYSIS_PROVIDER: disabled + AI_EMBEDDING_PROVIDER: disabled + AI_FALLBACK_PROVIDER: disabled + LOCAL_AI_SIDECAR_URL: http://127.0.0.1:9 + OLLAMA_BASE_URL: http://127.0.0.1:9 + expose: + - "3002" + volumes: + - "${AUDIT_LAB_DATA_DIR:?AUDIT_LAB_DATA_DIR must be set}:/app/site/data:rw" + - "${AUDIT_LAB_MEDIA_DIR:?AUDIT_LAB_MEDIA_DIR must be set}:/app/pics:rw" + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=128m,mode=1777 + - /app/site/.next/cache:rw,noexec,nosuid,nodev,size=128m,mode=700,uid=${PUID},gid=${PGID} + secrets: + - audit_token + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + networks: + - audit-lab + healthcheck: + test: ["CMD", "node", "--experimental-sqlite", "-e", "const{DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('/app/site/data/woodsmith.sqlite',{readOnly:true});const ok=d.prepare('PRAGMA quick_check').all().some(r=>r.quick_check==='ok');d.close();if(!ok)process.exit(1);fetch('http://127.0.0.1:3002/studio/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 5s + retries: 30 + + visual-audit: + image: "${WOODSMITH_VISUAL_AUDIT_IMAGE:-woodsmith-visual-audit:latest}" + build: + context: . + dockerfile: visual-audit/Dockerfile + container_name: woodsmith-visual-audit-lab-runner + init: true + ipc: host + read_only: true + user: "${PUID:?PUID must be set}:${PGID:?PGID must be set}" + depends_on: + woodsmith-audit-lab: + condition: service_healthy + environment: + TARGET_MODE: snapshot-lab + AUDIT_EVIDENCE_TIER: "${AUDIT_EVIDENCE_TIER:?AUDIT_EVIDENCE_TIER must be set by prepare-snapshot-lab.sh}" + BASE_URL: http://woodsmith-audit-lab:3002 + TARGET_COMMIT_SHA: "${TARGET_COMMIT_SHA:?TARGET_COMMIT_SHA must be set}" + AUDIT_RUN_ID: "${AUDIT_RUN_ID:?AUDIT_RUN_ID must be set}" + AUDIT_SCOPE: "${AUDIT_SCOPE:-full}" + AUDIT_RESUME: "${AUDIT_RESUME:-true}" + RUN_OUTPUT_ROOT: /output + REPO_ROOT: /workspace + AUDIT_TMP_ROOT: /audit-tmp + APPROVED_BASELINE_ROOT: "${APPROVED_BASELINE_ROOT:-}" + WOODSMITH_ADMIN_EMAIL: "${WOODSMITH_ADMIN_EMAIL:-woodsmithbb@proton.me}" + ADMIN_PASSWORD_FILE: /run/secrets/admin_password + AUDIT_TOKEN_FILE: /run/secrets/audit_token + MAX_FULL_PAGE_DEVICE_HEIGHT: "${MAX_FULL_PAGE_DEVICE_HEIGHT:-50000}" + MAX_STITCHED_SEGMENT_HEIGHT: "${MAX_STITCHED_SEGMENT_HEIGHT:-60000}" + AUDIT_STRICT_DIAGNOSTICS: "${AUDIT_STRICT_DIAGNOSTICS:-true}" + VISUAL_AUDIT_ACCELERATOR: "${VISUAL_AUDIT_ACCELERATOR:-auto}" + VISUAL_AUDIT_CAPTURE_WORKERS: "${VISUAL_AUDIT_CAPTURE_WORKERS:-auto}" + VISUAL_AUDIT_VALIDATION_WORKERS: "${VISUAL_AUDIT_VALIDATION_WORKERS:-auto}" + VISUAL_AUDIT_REPORT_WORKERS: "${VISUAL_AUDIT_REPORT_WORKERS:-auto}" + volumes: + - ./:/workspace:ro + - /volume2/docker_ssd/woodsmith/visual-audits:/output:rw + tmpfs: + - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700,uid=${PUID},gid=${PGID} + - /tmp:rw,noexec,nosuid,nodev,size=512m,mode=1777 + secrets: + - admin_password + - audit_token + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + networks: + - audit-lab + restart: "no" + +secrets: + admin_password: + file: ./secrets/woodsmith_audit_lab_password + audit_token: + file: ./secrets/woodsmith_visual_audit_token + +networks: + audit-lab: + internal: true diff --git a/docker-compose.visual-audit-live.yml b/docker-compose.visual-audit-live.yml new file mode 100644 index 0000000..c0698c8 --- /dev/null +++ b/docker-compose.visual-audit-live.yml @@ -0,0 +1,53 @@ +services: + visual-audit: + image: "${WOODSMITH_VISUAL_AUDIT_IMAGE:-woodsmith-visual-audit:latest}" + build: + context: . + dockerfile: visual-audit/Dockerfile + container_name: woodsmith-visual-audit + init: true + ipc: host + read_only: true + user: "${PUID:?PUID must be set}:${PGID:?PGID must be set}" + environment: + TARGET_MODE: live-readonly + AUDIT_EVIDENCE_TIER: tier-3-live-production + BASE_URL: "${VISUAL_AUDIT_BASE_URL:-https://woodmat.ch}" + TARGET_COMMIT_SHA: "${TARGET_COMMIT_SHA:?TARGET_COMMIT_SHA must be set}" + AUDIT_RUN_ID: "${AUDIT_RUN_ID:?AUDIT_RUN_ID must be set}" + AUDIT_SCOPE: "${AUDIT_SCOPE:-full}" + AUDIT_RESUME: "${AUDIT_RESUME:-true}" + RUN_OUTPUT_ROOT: /output + REPO_ROOT: /workspace + AUDIT_TMP_ROOT: /audit-tmp + APPROVED_BASELINE_ROOT: "${APPROVED_BASELINE_ROOT:-}" + WOODSMITH_ADMIN_EMAIL: "${WOODSMITH_ADMIN_EMAIL:-woodsmithbb@proton.me}" + ADMIN_PASSWORD_FILE: /run/secrets/admin_password + AUDIT_TOKEN_FILE: /run/secrets/audit_token + MAX_FULL_PAGE_DEVICE_HEIGHT: "${MAX_FULL_PAGE_DEVICE_HEIGHT:-50000}" + MAX_STITCHED_SEGMENT_HEIGHT: "${MAX_STITCHED_SEGMENT_HEIGHT:-60000}" + AUDIT_STRICT_DIAGNOSTICS: "${AUDIT_STRICT_DIAGNOSTICS:-true}" + VISUAL_AUDIT_ACCELERATOR: "${VISUAL_AUDIT_ACCELERATOR:-auto}" + VISUAL_AUDIT_CAPTURE_WORKERS: "${VISUAL_AUDIT_CAPTURE_WORKERS:-auto}" + VISUAL_AUDIT_VALIDATION_WORKERS: "${VISUAL_AUDIT_VALIDATION_WORKERS:-auto}" + VISUAL_AUDIT_REPORT_WORKERS: "${VISUAL_AUDIT_REPORT_WORKERS:-auto}" + volumes: + - ./:/workspace:ro + - /volume2/docker_ssd/woodsmith/visual-audits:/output:rw + tmpfs: + - /audit-tmp:rw,noexec,nosuid,nodev,size=64m,mode=700,uid=${PUID},gid=${PGID} + - /tmp:rw,noexec,nosuid,nodev,size=512m,mode=1777 + secrets: + - admin_password + - audit_token + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: "no" + +secrets: + admin_password: + file: ./secrets/woodsmith_audit_admin_password + audit_token: + file: ./secrets/woodsmith_visual_audit_token diff --git a/docs/media-surface-ledger.md b/docs/media-surface-ledger.md new file mode 100644 index 0000000..d9c6693 --- /dev/null +++ b/docs/media-surface-ledger.md @@ -0,0 +1,263 @@ +# Media surface ledger + +Updated: 2026-07-17 + +This ledger records every visible grouped-media surface in Beaman Woodworks and the non-overlapping replacement selected for it. It is the acceptance source for the shared media collection work and the visual archive's rendered no-overlap gate. + +## Current implementation evidence + +The shared normal-flow architecture is integrated in this implementation slice. Targeted validation on 2026-07-17 produced: + +- 72 passing application tests, including stable identity, one/two/three/six/twelve-plus normalization, image/video metadata, loading policy, deterministic UTC dates, and snapshot-lab isolation. +- 62 passing visual-audit tests, including exact positive-area intersection detection and deterministic no-overlap report generation. +- A passing TypeScript check and clean `git diff --check`. +- A rendered Edge/Playwright gate with 16 snapshot-lab viewport/theme states and 8 real Pastry Table viewport/theme states. Each snapshot-lab state exposed eight collections and 53 distinct media boxes. The fresh canonical Pastry Table seed exposed its one verified detail collection, while the isolated fixture exercised the process-sequence variant. The gate found zero intersections above the 0.75 px subpixel tolerance, zero broken media, zero document overflow, and zero browser diagnostics. +- Passing keyboard Enter/Space selection, modal focus trap/restoration, zoom, pan, Escape, close button, backdrop click, touch selection, video controls, and reduced-motion behavior. + +This is pre-deployment acceptance evidence, not the final exact-image or post-deployment live-production archive. Those later release gates remain required. + +## Rejected baseline + +The public Pastry Table route on the deployed `81b35c4` production image was measured in a rendered browser before replacement. + +| Viewport | Collection width | Track width | Card width | Adjacent offset | Positive intersections | +| --- | ---: | ---: | ---: | ---: | ---: | +| Desktop, 1440 px | 736 px | 2663 px | 469.33 px | 365.60 px | 6 at about 36,513 px2 each | +| Mobile, 390 px | 354 px | 2250 px | 469.33 px | 296.80 px | 6 at about 60,731 px2 each | + +The cards were static and untransformed. The overlap came from an overflowing column-grid track calculation, not from a deliberate transform. Fixing one transform or z-index would therefore have been insufficient. Screenshots are retained outside Git at `C:\Users\Cooper\Desktop\Woodmat Media Baseline 20260717`. + +## Shared contract and variants + +Every shared collection item preserves a stable identity, source, alt text, image/video kind, focal point, crop zoom, cleanup mode, caption, title, process stage, occurrence date, media role, and normalized order. The full-screen lightbox is a separate dialog/controller so collection layout can change without weakening modal semantics, focus trapping/restoration, Escape, previous/next, zoom, pan, keyboard, pointer, touch, image, or video behavior. + +Selected variants: + +- `detail-stage`: stable primary stage plus an independently laid-out thumbnail rail. Direct selection and previous/next controls are always visible when applicable. +- `editorial-grid`: deterministic responsive grid with one normal-flow box per item and captions adjacent to their media. +- `process-sequence`: stage-aware sequence tied to normalized build metadata and DOM order. +- `picker-grid`: dense selectable administrative grid with explicit selection, order, role, reorder, and remove controls. +- `single`: one media item using the same dialog without collection controls. + +No variant may use negative margins, rotated children, translated pile offsets, ordinary-item absolute positioning, z-index collection order, clipped underlying cards, autoplay, auto-advance, or hover-only discovery. + +## Public surfaces + +### Portfolio piece gallery + +- Route/component: `/portfolio/[slug]`; `PiecePage` and the former `MediaLightbox` preview. +- Audience: public buyers, clients, and signed-in administrators. +- Current classes: `.media-gallery-shell`, `.piece-media-carousel`, `.media-card`. +- Current layout: horizontally scrolling CSS Grid with automatic columns; rendered evidence shows positive-area card intersections. +- Media source: reviewed public normalized `piece_media_links` display roles, with synchronized legacy paths for compatibility. +- Image/video support: current public mapping is image-only even when a media record exists. +- Ordering source: normalized link display order, then compatible legacy order. +- Focal/crop behavior: media focal X/Y and zoom applied to a cover image; cleanup display mode is retained. +- Lightbox behavior: mature shared dialog with focus trap/restoration, Escape, arrows, zoom, pan, pointer, and touch. +- Scrolling behavior: mandatory horizontal snap with explicit previous/next controls. +- Keyboard behavior: collection openers are buttons; dialog supports arrows, plus, minus, zero, Tab, and Escape. +- Mobile behavior: fixed computed card widths overflow and intersect more severely at 390 px. +- Overlap mechanism: automatic grid tracks compute wider than the adjacent column offset. +- Performance concerns: every preview image is mounted; sizing hints describe the old carousel rather than the selected primary image. +- Archive coverage: public route, lightbox boundaries, desktop/tablet/mobile, light/dark; prior archive lacked a collection intersection assertion. +- Implemented replacement variant: `detail-stage`. + +### Portfolio build record + +- Route/component: `/portfolio/[slug]`; `PiecePage` build-record section. +- Audience: public buyers, clients, and signed-in administrators. +- Current classes: `.piece-process-layout`, `.piece-process-timeline`, `.piece-process-carousel`, `.media-card`. +- Current layout: text timeline beside an independently scrolling automatic-column media track. +- Media source: reviewed public normalized `piece_media_links` with `process`, `drawing`, `plan`, or `installation` roles. +- Image/video support: images and videos. +- Ordering source: normalized link display order. +- Focal/crop behavior: focal X/Y and zoom on images; stage preview uses cover framing. +- Lightbox behavior: mature shared dialog. +- Scrolling behavior: independent horizontal snap detached from timeline position. +- Keyboard behavior: button openers and dialog controls; timeline itself is static. +- Mobile behavior: timeline and media collapse but the old media track retains oversized tracks. +- Overlap mechanism: the same automatic-column track behavior as the piece gallery. +- Performance concerns: all sequence media mount at once and visual order can be read separately from timeline metadata. +- Archive coverage: build-record route states and lightbox states; prior archive lacked timeline/order and intersection assertions. +- Implemented replacement variant: `process-sequence`, with each stage's metadata visibly attached to its media and identical DOM/visual order. + +### Process-note cover + +- Route/component: `/process/[slug]`; `ProcessPostPage`. +- Audience: public readers and signed-in administrators. +- Current classes: `.journal-cover`, `.media-card` through the former shared preview. +- Current layout: one cover item. +- Media source: process post `coverMediaPath`. +- Image/video support: image only. +- Ordering source: not applicable; one item. +- Focal/crop behavior: the route currently lacks record-level focal metadata. +- Lightbox behavior: mature shared dialog. +- Scrolling behavior: none. +- Keyboard behavior: button opener and dialog controls. +- Mobile behavior: responsive single media box. +- Overlap mechanism: none. +- Performance concerns: no collection overhead is needed. +- Archive coverage: process detail and lightbox state. +- Implemented replacement variant: `single`. + +### Portfolio, Workshop, and Shop card grids + +- Route/component: `/portfolio`, `/`, `/shop`; `PieceCard` and shop cards. +- Audience: public buyers and clients. +- Current classes: `.piece-grid`, `.piece-card`, shop product-card classes. +- Current layout: normal-flow responsive record grids with one representative image per record. +- Media source: reviewed public hero/display link for each piece. +- Image/video support: representative images only. +- Ordering source: page feature/order logic and piece records. +- Focal/crop behavior: stored focal and zoom metadata where supplied. +- Lightbox behavior: cards navigate to detail or shop actions; no grouped-media dialog. +- Scrolling behavior: document flow only. +- Keyboard behavior: normal links and controls. +- Mobile behavior: columns collapse without item overlap. +- Overlap mechanism: none; retained as a regression surface because multiple media-backed records are visible together. +- Performance concerns: only representative images should be requested and non-LCP cards should remain lazy. +- Archive coverage: canonical public routes across the viewport/theme matrix. +- Implemented variant: retained normal-flow editorial record grid with shared no-overlap audit identities. + +### Shop cart thumbnails + +- Route/component: `/shop/cart`; cart line items. +- Audience: buyers. +- Current classes: cart line and thumbnail classes. +- Current layout: normal-flow line-item list with one image per line. +- Media source: each selected piece's public representative media. +- Image/video support: image only. +- Ordering source: cart insertion order. +- Focal/crop behavior: representative crop. +- Lightbox behavior: none. +- Scrolling behavior: document flow only. +- Keyboard behavior: native cart controls and links. +- Mobile behavior: compact line-item layout. +- Overlap mechanism: none. +- Performance concerns: thumbnails must stay thumbnail-sized. +- Archive coverage: cart empty/populated snapshot-lab states. +- Implemented variant: retained normal-flow compact list with collection audit identity. + +## Administrative and customer surfaces + +### Studio media library browser + +- Route/component: `/studio?panel=media`; `StudioMediaWorkspace`. +- Audience: administrators. +- Current classes: `.studio-media-browser-grid`, media cards and inspector classes. +- Current layout: paginated dense CSS Grid plus master-detail inspector. +- Media source: indexed writable mounted media library. +- Image/video support: image, video, and other-file fallbacks. +- Ordering source: requested library sort and pagination. +- Focal/crop behavior: thumbnails use stored media metadata; inspector exposes crop/edit controls. +- Lightbox behavior: inspector opens the mature dialog for one selected item. +- Scrolling behavior: document or workspace pane; no piled selection track. +- Keyboard behavior: focusable cards, hotkeys, inspector forms, and dialog controls. +- Mobile behavior: grid and inspector collapse to one column. +- Overlap mechanism: none in the current grid; retained and normalized into the shared administrative contract. +- Performance concerns: library is paginated and thumbnails must not request originals unnecessarily. +- Archive coverage: media pages, filters, inspector, expanded inspector, and inspector lightbox. +- Implemented variant: `picker-grid` for the browser and `single` for the inspector. + +### Reusable media picker browser + +- Route/component: Studio page/piece/process editors; `MediaPicker` modal. +- Audience: administrators. +- Current classes: `.media-picker-grid`, `.media-picker-card`, `.media-picker-dialog`. +- Current layout: paginated selectable CSS Grid. +- Media source: the complete indexed mounted library through `loadMediaPageAction`. +- Image/video support: image thumbnails with video/other fallbacks. +- Ordering source: server pagination and filter result order. +- Focal/crop behavior: picker thumbnail currently uses the source without focal positioning. +- Lightbox behavior: none inside the picker. +- Scrolling behavior: bounded dialog content and pagination. +- Keyboard behavior: focus trap, Escape, search, native controls, and selectable buttons. +- Mobile behavior: responsive two-column or single-column grid in a full-height dialog. +- Overlap mechanism: none. +- Performance concerns: 48-item page cap; lazy thumbnail loading. +- Archive coverage: default, filtered-empty, pagination, and selection states. +- Implemented variant: `picker-grid`. + +### Selected piece media and normalized relation editor + +- Route/component: `/studio?panel=pieces`; `MediaPicker` selected strip and `PieceMediaEditor`. +- Audience: administrators. +- Current classes: `.media-picker-strip`, `.media-picker-chip`, `.piece-media-relation-list`, relation cards. +- Current layout: normal-flow selected chips plus one relation form per normalized link. +- Media source: selected mounted-library paths and `piece_media_links`. +- Image/video support: images with kind fallbacks. +- Ordering source: selected path order synchronized transactionally to normalized display order. +- Focal/crop behavior: thumbnail preview; media-level crop is edited in the media workspace. +- Lightbox behavior: none in the relation editor. +- Scrolling behavior: document flow; picker modal when browsing. +- Keyboard behavior: direct earlier/later and remove buttons; labeled role/stage/order controls. +- Mobile behavior: cards collapse to one column. +- Overlap mechanism: none. +- Performance concerns: only selected thumbnails are mounted; long relation sets need compact controls. +- Archive coverage: piece editor, picker, selected states, and relation controls. +- Implemented variant: `picker-grid` selection strip and role editor, retaining direct order/remove operations. + +### Studio project media strips + +- Route/component: `/studio?panel=projects` and the project assignment area in `StudioMediaWorkspace`. +- Audience: administrators. +- Current classes: `.project-media-strip`. +- Current layout: normal-flow thumbnail grid/strip with one box per attached file. +- Media source: private project media assignments and project-linked media records. +- Image/video support: image previews with file links/fallbacks. +- Ordering source: project media record order. +- Focal/crop behavior: compact crop thumbnails. +- Lightbox behavior: one implementation links to media; another uses the inspector workflow. +- Scrolling behavior: document/workspace flow. +- Keyboard behavior: native links/buttons. +- Mobile behavior: fewer columns without overlap. +- Overlap mechanism: none. +- Performance concerns: private media must remain bounded, lazy, and excluded from public output. +- Archive coverage: authenticated project panel and media workspace project state. +- Implemented variant: compact `editorial-grid` audit identity using the shared item contract and `single` dialog where preview is allowed. + +### Commission reference upload previews + +- Route/component: `/commissions`; `CommissionWorkflow` upload step. +- Audience: prospective buyers and signed-in customers. +- Current classes: `.commission-upload-previews` and child figures. +- Current layout: normal-flow local preview grid with filename and remove button. +- Media source: browser-selected local object URLs before private submission. +- Image/video support: accepted reference images and supported upload types. +- Ordering source: browser file selection order. +- Focal/crop behavior: contained local preview; no persisted crop before upload. +- Lightbox behavior: none before submission. +- Scrolling behavior: document flow. +- Keyboard behavior: native file input and remove buttons. +- Mobile behavior: responsive grid. +- Overlap mechanism: none. +- Performance concerns: object URLs are revoked; large selections are bounded by intake validation. +- Archive coverage: snapshot-lab upload step and validation states; real files must remain synthetic. +- Implemented variant: normal-flow `editorial-grid` audit identity with explicit filename/remove controls. + +## Alternatives evaluated + +### A. Detail stage plus thumbnail rail + +The primary image occupies one stable aspect-ratio stage. A distinct normal-flow rail contains direct-selection buttons with fixed thumbnail boxes. The stage alone carries the LCP preload; secondary thumbnails are lazy. The control row exposes previous, next, and `item x of y`. Portrait, landscape, extreme, image, and video items use containment appropriate to kind without changing stage geometry. The stage opens the separate lightbox. + +This pattern minimizes page height and interaction cost for furniture detail photography while retaining direct discoverability. It is strongest for 2-12+ item detail galleries and narrow mobile layouts. One-item mode removes redundant navigation and rail controls. + +### B. Responsive editorial grid + +Every item occupies a deterministic CSS Grid cell in DOM order with an explicit stable aspect-ratio wrapper. Captions remain outside the image. The grid exposes more images without interaction and is strongest for small reference sets, Studio project attachments, and commission previews. For 12+ media it consumes substantially more page height and requests more visible thumbnails, so it is not the default detail-gallery pattern. + +### Process and Studio specializations + +Build records use `process-sequence`: normalized order, stage, date, title, and caption stay attached to the corresponding media, preventing divergence between a text timeline and an independent visual track. Studio selection uses `picker-grid`: selected state, role, order, reorder, and remove remain explicit and keyboard operable. + +## Selection rationale + +The selected mixed architecture follows the client's rejection of all overlapping groups, gives furniture photography one stable visual focus, preserves complete direct discovery, and avoids forcing public detail, chronological process, and administrative selection into one layout. All variants use normal-flow boxes, deterministic DOM order, explicit controls, and stable sizing, which improves accessibility, mobile behavior, image loading, component reuse, and visual-archive reproducibility. The design prototype's normal-flow portfolio cards support this direction; no prototype evidence justified retaining overlap. + +## Acceptance gates + +- Unit and structural gate: implemented and passing for item normalization, variant semantics, one/many behavior, metadata association, snapshot-lab isolation, and preload policy. +- Rendered no-overlap gate: implemented as `npm run test:media-browser`; it compares every visible `[data-media-item]` rectangle with every peer in its nearest `[data-media-collection]`, permits only a documented 0.75 px subpixel tolerance, and fails any positive-area intersection. +- Matrix gate: passing for one, two, three, six, and twelve items; mixed and extreme ratios; image/video; process metadata; 320/375/390/430 mobile, desktop, tablet, archival/ultrawide; light/dark; reduced motion; keyboard; pointer; and touch interaction. +- Archive artifact gate: implemented. Route evidence records collection identity, variant, item count, viewport, theme, and exact findings; capture emits `no-overlap.json`; validation rejects missing, stale, inconsistent, or non-passing reports. Final exact-image and live-production execution remain later release gates. diff --git a/docs/sitewide-ux-overhaul-audit-20260711.md b/docs/sitewide-ux-overhaul-audit-20260711.md new file mode 100644 index 0000000..32581db --- /dev/null +++ b/docs/sitewide-ux-overhaul-audit-20260711.md @@ -0,0 +1,182 @@ +# Beaman Woodworks Sitewide UX and Data Audit + +Date: 2026-07-11 +Branch: `codex/sitewide-studio-ux-commission-overhaul-20260711` + +Rendered verification for this overhaul is now provided by the pinned two-mode system documented in `docs/visual-archive.md`. Its evidence contract covers source/database/rendered-link inventory, read-only live capture, isolated mutation-state capture, required themes and viewports, deep Studio/media/inline-edit/visualizer states, overlapping raw tiles, stitched surfaces, restricted/redacted HTML and PDF reports, checksums, and baseline comparison. Deployment remains gated on a passing final-candidate smoke and full archive. +Baseline commit: `81b35c4b97d464a388560c13c1526b78c10afa45` + +This document records the verified baseline used for the sitewide overhaul. Documentation claims were treated as hypotheses and checked against source, a disposable SQLite backup, supplied screenshots, and read-only production requests. + +## Evidence + +- Supplied screenshot 1, Studio category editor: 1773 x 1417 source pixels. +- Supplied screenshot 2, footer: 3703 x 1119 source pixels. +- Supplied screenshot 3, Portfolio filters: 3703 x 1247 source pixels. +- Supplied screenshot 4, home services/process section: 3703 x 2295 source pixels. +- Production origin checked read-only: `https://woodmat.ch`. +- Production headers reported Next.js behind Cloudflare with private/no-store HTML responses. +- Repository baseline: Next.js 16.2.10, React 19.2.0, TypeScript 5.8.3, ESLint 9.39.4, Node `node:sqlite`, Three.js 0.183.2, React Three Fiber 9.5.0, Drei 10.7.7. +- Disposable SQLite backup passed `PRAGMA quick_check` before and after migration testing. +- Source media inventory supplied by the user includes Synology `@eaDir`, `SYNOPHOTO_*`, `SYNOINDEX_*`, Motion Photo metadata, and recovery/temp artifacts that must never be surfaced. + +## Architecture Baseline + +| Area | Verified implementation | Risk or discrepancy | +| --- | --- | --- | +| Routing | Next App Router under `site/app`; dynamic public page, piece, media, request, and account routes | Route-level loading/error coverage is inconsistent | +| Rendering | Server components by default; client components for theme, header behavior, inline editing, media desk, lightbox, and visualizer | Global `force-dynamic` avoids stale SQLite content but increases repeated database work | +| Styling | `globals.css`, `refinements.css`, `brand-emblem.css`, then `ui-repair.css` | Repeated contracts and late `!important` rules make spacing and header behavior difficult to reason about | +| Persistence | SQLite at `DATA_ROOT/woodsmith.sqlite`, WAL mode, seed tombstones, direct NAS mount | Baseline had no schema migration ledger and startup seed-media synchronization could reverse manual decisions | +| Media | Direct writable `MEDIA_ROOT=/app/pics`, synchronous scan, SQLite metadata, paged Media Desk | Piece relations were duplicated between `pieces.media_paths_json` and `media_items.piece_slug`; no roles/stages | +| Authentication | Admin, woodworker, and customer roles; cookie-backed sessions; email verification/reset fields | Guest project access still relies on reference plus email in URL and requires a separate security slice | +| Inline editing | Public edit targets plus hard-coded API allowlists | Not typed, structural fields missing, multi-patch saves non-atomic, no durable audit trail at baseline | +| Commerce | Cart, totals, optional Stripe/EasyPost, graceful provider degradation | Nullable/negative prices could reach public/cart flows; no typed pricing policy at baseline | +| Commission preview | SVG and CSS pseudo-3D with estimator fields | Installed R3F/Drei/Three stack was unused; hidden fields were client-authoritative | +| Deployment | Synology Compose mounts data, media, and Next image cache; Cloudflare canonical origin | A container started outside authoritative Compose can still create disposable image-local data | + +## Route Matrix + +| Surface | Baseline state | Required correction | +| --- | --- | --- | +| `/` | Public hero, featured work, inert service cards, Process promotion | Linked services; commissioned-build section; compact responsive rhythm | +| `/portfolio` | Query-deep-linked filters and all published pieces | Compact icon rail, extensible icons, pagination/load-more, stable image sizing | +| `/portfolio/[slug]` | Gallery, unconditional inquiry and reviews, ambiguous price | Typed policy gating, normalized gallery, optional build record | +| `/shop` | Inventory pieces and unconditional reserve controls | Fixed-price-only cart controls and distinct inquiry-only work | +| `/shop/cart` | Silently filters null-priced items | Reject non-fixed entries at every server boundary and explain invalid lines | +| `/commissions` | One long contact form with CSS/SVG pseudo-3D | Primary multi-step journey and dynamically loaded real R3F preview with SVG fallback | +| `/commissions/status` | Reference/email lookup | Preserve behavior while replacing URL email capability in security slice | +| `/contact` | Duplicates commission form | Concise general contact and handoff to primary commission flow | +| `/about` | Public people/contact content | Preserve credits, use structured editable contact/footer models | +| `/care-and-warranty` | Dynamic page | Preserve and make structural content editable | +| `/search` | Public/private search boundary | Preserve authorization; add policy-aware labels and normalized media metadata | +| `/process`, `/process/[slug]` | Optional archive | Retain archive, remove home promotion, separate process media from finished galleries | +| `/account/*` | Signup, login, verify, forgot/reset, profile, projects | Preserve; later harden draft/resume and guest project access | +| `/requests/[reference]` | Buyer/project dossier | Preserve privacy checks; add high-entropy guest capability in security slice | +| `/studio/login` | Admin entry | Preserve noindex and session protections | +| `/studio` | One panel at a time, but many panels mount every editor expanded | Master-detail editors, in-place saves, visual media relations, consistent dirty/saved states | +| `/media/[...slug]` | Full-resolution mounted media route | Reject all sidecar/temp paths even when directly requested | +| 404/loading/error | Custom 404 plus generic loading | Add route-level actionable error states and preserve compact shell | + +## Studio Panel Matrix + +| Panel | Baseline finding | Priority | +| --- | --- | --- | +| Overview | Useful persistence and provider summary; oversized/redundant metrics | Medium | +| Settings | Partial brand/home fields only | High | +| Pages | Every page fully expanded; raw hero path | Critical | +| Pieces | Every piece fully expanded; raw gallery paths; no typed public policy controls | Critical | +| Categories | Five-value native icon select; no order/visibility/custom SVG | Critical | +| Custom | Commission types lack category/template/range constraints | High | +| People | Raw avatar path in Studio | High | +| Process | Raw cover path and long editor list | High | +| Media | Strong in-place three-pane foundation and guided trainer | Preserve and extend | +| Projects | Existing stages and attachments | Add visual role/stage relations and stronger access capability | +| Orders | Provider-aware operations | Enforce typed fixed-price policy | +| Reviews | Admin moderation exists | Gate public display/submission per piece | +| Notifications | Durable queue exists | Preserve provider-degradation truth | + +## Screenshot Defects + +1. Portfolio filters use large pill containers, circular count badges, fragile CSS `` icons, excessive wrap height, and weak count hierarchy. +2. Category editing exposes a giant native select with only Table, Bench, Stepstool, Cabinet, and Object choices. It lacks a visual gallery, custom SVG, public preview, order, and visibility. +3. Home service cards are inert articles and expose a private dashboard as a public service. The next section promotes Process rather than commissioned builds. +4. Footer contact lines use literal dot separators inside layout rules that can split each token into its own row. GitHub is duplicated and most footer structure is hard-coded. + +## Data Findings + +- Baseline local production-like backup contained 26 pieces, 426 indexed media rows, 10 projects, 7 pages, 2 users, no orders, and no reviews. +- Legacy piece JSON contained 196 media references. Three paths had no indexed media row; those were reported and left unmapped. +- Nine pieces used negative price sentinels and one used zero. They were not valid public money values. +- Piece/media assignment was duplicated and frequently inconsistent between piece JSON and media rows. +- Startup reapplied seed media relationships and forced `reviewed=1`; this could reverse an administrator's later unassignment or review decision. +- Related writes had no transaction wrapper, referential integrity was disabled, and destructive media operations lacked a durable rename ledger. +- WAL is part of the acknowledged database state. Backups must use SQLite online backup or include WAL/SHM consistently; copying only the main file is not sufficient while live. + +## Data Slice Implemented + +Commit `d35eb35` introduces: + +- `schema_migrations` with identity checks and per-migration reports; +- `piece_media_links` with role, stage, date, caption, alt override, order, public state, and compatibility provenance; +- typed price, inquiry, and review policy columns and helpers; +- `admin_edit_audit` and `media_rename_history`; +- non-destructive legacy media backfill; +- conservative pricing conversion: positive values become fixed, available inventory sentinels become contact-for-price, all other sentinels become not-listed; +- fixed-price-only cart enforcement at server actions and local reservation API; +- transactional reference rewrites with physical rename rollback and staged deletion; +- sidecar/temp filtering expansion; +- startup seed assignments limited to a genuinely new database; +- disposable migration, rollback, policy, and rename tests. + +Production-like disposable migration result: 26 pieces retained, 193 normalized valid links, three stale links reported, zero destructive legacy-column removal, schema version 3, and `PRAGMA quick_check=ok` after two application starts. + +## Accessibility Baseline + +- Present: semantic landmarks, labels on most controls, lightbox Escape/close/zoom, reduced-motion rule, no observed 320/390 page overflow in read-only live checks. +- Missing or incomplete: skip link/main target, consistent `:focus-visible`, active primary navigation state, modal focus trap/inert background, keyboard panning, structural-editor keyboard alternatives, contextual error association, and 200 percent reflow evidence. +- Header focus listener was attached globally and could reveal the sticky header when focus moved anywhere on the page. + +## Performance Baseline + +- Portfolio renders every published piece and raw originals, producing very long mobile pages and oversized downloads. +- Homepage can render too many featured cards. +- Media Desk pages the main library but builds its verification queue from the full image library. +- Studio Pages/Pieces/Process mount every editor at once. +- Existing pseudo-3D is small but not functionally sufficient; true Three.js must be dynamically isolated from unrelated routes. +- Four overlapping CSS files increase override cost and visual regression risk. + +## Documentation Discrepancies + +- Documentation claimed visual media pickers were in Page, Piece, and Process editors; source still used raw path fields and `MediaPicker` had no consumer. +- PLANS described the guided media trainer as both validation-pending and deployed/live-verified in different sections. +- Documentation described visual/semantic search capabilities more strongly than provider configuration and current public UI support justified. +- Persistence documentation was directionally correct about the mount, but did not explain startup seed reassignment or intentional-empty form semantics. +- The visualizer was labeled 3D despite rendering CSS geometry and SVG. + +## Requirement-to-File Map + +| Requirement | Primary implementation areas | +| --- | --- | +| Design system and density | `site/app/globals.css`, `site/app/refinements.css`, `site/app/ui-repair.css`, shared shell components | +| Inline editing | `site/components/inline-edit-*`, `site/app/api/studio/inline-edit/route.ts`, typed registry and audit helpers | +| Visual media selection | `site/components/media-picker.tsx`, Studio editors, media load actions | +| Normalized piece media/process | `site/lib/db.ts`, `site/lib/catalog.ts`, piece page and Studio Piece editor | +| Category icons | `site/lib/categories.ts`, shared SVG component, Portfolio and Studio category editor | +| Media operations | `site/components/studio-media-workspace.tsx`, `site/lib/actions.ts`, `site/lib/media.ts`, sidecar | +| Pricing/inquiry/reviews | `site/lib/piece-model.ts`, Shop/cart APIs, piece page, Studio Piece editor | +| Home/commissions/footer | home route, commissions flow, site chrome, structured settings | +| True 3D | dynamically imported R3F client surface, generator registry, SVG fallback | +| Deployment/data safety | Docker/Compose, migration tests, backup and rollback documentation | + +## Implementation and Validation Sequence + +1. Establish migration-safe normalized data and typed public policy. Completed in `d35eb35`. +2. Consolidate design tokens and public shell; repair footer, services, home commission positioning, and Portfolio density. +3. Implement extensible shared SVG category icons and visual category administration. +4. Connect a paged, focus-safe media library dialog to Page, Piece, Process, profile, and project editors; remove raw paths from normal workflows. +5. Convert long Studio panels to searchable master-detail editors and in-place action forms. +6. Render normalized process/build records and policy-gated pricing, inquiry, and reviews. +7. Extend Media Desk batch operations, rename previews, roles/stages, and provider-safe derivatives. +8. Replace pseudo-3D with dynamically loaded R3F generators and retain an honest scale-drawing fallback. +9. Replace inline-edit conditionals with a typed registry and atomic audited patch application. + +Implemented after the baseline audit: the public editor now derives its allowlist from `site/lib/inline-edit-registry.ts`, validates complete batches before an outer SQLite transaction, records admin audit entries, rejects stale expected values and untrusted origins, and returns reversible patches for one-step Undo. The full visual editor remains the explicit path for structural changes. +10. Run static, disposable-database, browser, accessibility, performance, Docker, backup, candidate, rollback, and live deployment gates. + +## Validation Matrix + +| Gate | Baseline/data-slice status | +| --- | --- | +| TypeScript | Passed after data slice | +| ESLint | Passed after data slice | +| Focused Node tests | 11 passed; SQLite API/module warnings documented | +| Production build | Passed after data slice | +| Disposable migration twice | Passed; schema version remained 3 | +| SQLite quick check | Passed before/after | +| Destructive production mutation | Not performed | +| Public browser matrix | Pending redesigned surfaces | +| Admin browser matrix | Pending redesigned surfaces | +| Accessibility matrix | Pending redesigned surfaces | +| Performance comparison | Pending redesigned surfaces | +| Docker candidate/deploy | Pending all implementation and safety gates | diff --git a/docs/visual-archive.md b/docs/visual-archive.md new file mode 100644 index 0000000..5d16d7a --- /dev/null +++ b/docs/visual-archive.md @@ -0,0 +1,378 @@ +# Deterministic Visual Archive + +The visual archive is a private, repository-integrated Playwright system for rendered-browser QA and long-term evidence. It uses pinned Playwright 1.61.0, Sharp 0.35.3, and PDFKit 0.19.1, records the deployed commit, and reconciles routes from source files, the protected SQLite inventory, and rendered same-origin links. + +Generated archives are runtime artifacts. They must remain outside Git and private unless the redacted edition has been reviewed for sharing. + +## Safety Model + +The two modes are intentionally separate: + +- `live-readonly` authenticates to the deployed site, then blocks every unsafe same-origin and cross-origin request in Playwright. Same-origin requests also carry `x-woodsmith-audit-readonly: 1`, which makes `site/proxy.ts` reject unsafe methods with HTTP 409. The one Studio login POST is the explicit authentication exception. +- `snapshot-lab` runs against a verified SQLite `VACUUM INTO` clone and a reflink or full-copy media tree. Its Docker network is internal, provider keys are empty, paid/local model providers are disabled, and the production data/media paths are rejected. + +The inventory endpoint requires both normal admin authentication and a separate audit token. It returns bounded route-driving metadata, counts, and public-media aggregates, not users, customer contact details, notification bodies, payment data, reset tokens, session state, or media paths. Public media references and mounted file versions are represented only by SHA-256 digests. Inventory acquisition permits only the exact same-origin GET endpoint. The ordinary capture context adds the audit token only to that endpoint and authenticated `audit=all` Studio pages. + +Network diagnostics are evidence-based. A blocked mutation is expected only when its unsafe method and exact URL match a route-guard policy record. An aborted request is expected only when it is a same-origin fetch/XHR with Next.js RSC or prefetch evidence, or a safe same-origin font/image/media request canceled after the page has completed capture, passed the final visual/request drain, and entered deliberate teardown. The teardown exception is lifecycle-scoped: aborts during navigation, screenshots, interactions, or settling remain failures, and diagnostics include the active capture phase. Aborted documents, scripts, stylesheets, API calls, inventory calls, cross-origin resources, and unrelated resources remain validation failures. + +Secrets, storage state, SQLite files, media, raw tiles, PNGs, HTML, PDFs, traces, and reports are ignored by Git. Authentication state is held under the runner tmpfs and removed in `finally`. + +## Evidence Tiers + +Every schema-v5 archive has one explicit evidence tier. Resume refuses a different tier, schema, mode, origin, commit, or accelerator record. + +- `tier-1-synthetic` is the bounded Windows/local Docker smoke. The app must report `WOODSMITH_MEDIA_PROVENANCE=synthetic-fixture`. It proves application, browser, policy, report, and cleanup behavior but is not production-media evidence. +- `tier-2-production-clone` requires `snapshot-lab`, a verified SQLite clone, and a reflink or full copy of the production media mount. The app must report `production-clone`. It is the predeployment exact-candidate media and mutation-state gate. +- `tier-3-live-production` requires `live-readonly` against a non-loopback HTTPS origin. The deployed app must report `production-live`. It is the postdeployment public/admin rendered-state gate and never permits a capture-time production mutation. + +`live-media.json` reconciles the protected public-reference aggregate with hashed rendered mounted-media observations. `placeholder-report.json` records bounded placeholder digests and reasons. Tier 2 and Tier 3 reject synthetic markers, missing public files, absence of mounted media on anonymous routes, visible load failures, and any visible placeholder without an explicit audited allowance. Intentional “media verification pending” surfaces are marked in source and remain counted rather than silently ignored. Raw media paths are never written to these reports or the shareable manifest. + +## One-Time NAS Setup + +Run from the single authoritative checkout: + +```bash +cd /volume2/docker_ssd/woodsmith +``` + +Set `VISUAL_AUDIT_TOKEN` in `.env` to one independently generated 64-character hexadecimal value. Keep the value single-quoted if needed. Then prepare the private Docker secret files: + +```bash +chmod 700 visual-audit/scripts/prepare-live-secrets.sh +visual-audit/scripts/prepare-live-secrets.sh +``` + +The script obtains `STUDIO_PASSWORD` from the running `woodsmith` container, parses the token from `.env`, writes both files atomically with mode 600, and prints only nonsecret status. It does not use `read -p`, so it is safe when launched from zsh. It also does not use `docker compose config --environment`; the installed Synology Compose build does not support that option, and that failed approach must not be retried. + +Required private files: + +```text +secrets/woodsmith_audit_admin_password +secrets/woodsmith_visual_audit_token +``` + +Create the private output root: + +```bash +install -d -m 700 /volume2/docker_ssd/woodsmith/visual-audits +``` + +## Static Validation + +Use explicit nonsecret placeholders for Compose parsing. Do not render secret-bearing Compose output into logs. + +```bash +cd /volume2/docker_ssd/woodsmith + +export TARGET_COMMIT_SHA="$(git rev-parse HEAD)" +export AUDIT_RUN_ID="config-check-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)" + +npm run typecheck +npm run test +npm run lint +npm run build +npm --prefix visual-audit test + +docker compose --env-file .env \ + -f docker-compose.synology.yml \ + config --quiet + +docker compose --env-file .env \ + -f docker-compose.visual-audit-live.yml \ + config --quiet + +docker compose --env-file .env \ + --env-file .visual-audit-lab.env \ + -f docker-compose.visual-audit-lab.yml \ + config --quiet +``` + +The lab Compose check requires a prepared `.visual-audit-lab.env`. If no lab has been prepared, validate with temporary nonsecret placeholder variables and nonproduction directories instead. + +NAS candidate and production images must report the audited commit. Build them with: + +```bash +docker buildx build \ + --platform linux/amd64 \ + --build-arg WOODSMITH_BUILD_SHA="$(git rev-parse HEAD)" \ + -t woodsmith:prod \ + --load . +``` + +The runner permits `WOODSMITH_BUILD_SHA=unknown` only for the bounded loopback disposable smoke in either read-only or cloned-lab mode. It rejects missing or mismatched build identity for remote or full targets. After committing a candidate, rebuild both images with the exact commit before accepting Tier 2 or release evidence. + +## Live Read-Only Smoke + +Always run a smoke archive before the full archive. One exported `AUDIT_RUN_ID` must be shared by capture, comparison, report, and validation. + +```bash +cd /volume2/docker_ssd/woodsmith + +export TARGET_COMMIT_SHA="$(git rev-parse HEAD)" +export AUDIT_RUN_ID="smoke-$(date -u '+%Y%m%dT%H%M%SZ')-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)" +export AUDIT_SCOPE=smoke +export AUDIT_RESUME=true + +visual-audit/scripts/run-live-audit.sh +``` + +The smoke is accepted only when: + +- the inventory endpoint is hidden without the token and denies a token-only unauthenticated request; +- a read-only unsafe request returns HTTP 409; +- the manifest reports zero successful unsafe requests; +- every rendered route has focused and activated skip-link evidence, and activation transfers focus to `#main-content`; +- capture, comparison, report, and validation use the same run ID; +- `validation.json` reports `passed: true`. + +Resume an interrupted run by preserving the same `AUDIT_RUN_ID`, `TARGET_COMMIT_SHA`, scope, and output directory. + +On a Windows release workstation, validate exact local app and runner images without mounting repository data or media: + +```powershell +visual-audit/scripts/run-local-disposable-smoke.ps1 ` + -AppImage woodsmith:candidate- ` + -AuditImage woodsmith-visual-audit: ` + -CommitSha +``` + +The default `live-readonly` mode uses fake credentials, synthetic media, a network-isolated app, non-root containers, disabled external providers, and uniquely named disposable volumes. It is always labeled `tier-1-synthetic`; the harness verifies that the app reports `synthetic-fixture`, both media reports pass, and every container and volume is removed in `finally`. + +During a dirty pre-commit loop, build the app with its default `WOODSMITH_BUILD_SHA=unknown`; the same command accepts that identity only for its loopback disposable smoke modes and reports `APP_BUILD_IDENTITY=unknown-loopback-smoke`. Never stamp a dirty image with the current committed SHA. Once the slice is committed, rebuild and require `APP_BUILD_IDENTITY=exact`. + +Run the same current-image gate against an isolated local snapshot lab with `-TargetMode snapshot-lab`. The harness first initializes disposable source data/media, creates the lab database online with SQLite `VACUUM INTO`, copies media into distinct lab volumes, and then runs the app and audit only against those clones. Acceptance requires exactly one draft save and one cleanup delete, zero residual drafts, SQLite `quick_check`, unchanged source data/media fingerprints, an unchanged cloned media tree, and complete container/volume cleanup. + +## Full Live Archive + +```bash +cd /volume2/docker_ssd/woodsmith + +export TARGET_COMMIT_SHA="$(git rev-parse HEAD)" +export AUDIT_RUN_ID="full-$(date -u '+%Y%m%dT%H%M%SZ')-$(printf '%s' "$TARGET_COMMIT_SHA" | cut -c1-8)" +export AUDIT_SCOPE=full +export AUDIT_RESUME=true + +visual-audit/scripts/run-live-audit.sh +``` + +The live Compose runner fixes `AUDIT_EVIDENCE_TIER=tier-3-live-production`, while the production app fixes `WOODSMITH_MEDIA_PROVENANCE=production-live`. Do not override either value to make a mismatched archive pass. + +Full mode covers dark and light themes at: + +- 1440 x 900, 1280 x 800, and 1024 x 768 desktop profiles; +- 1024 x 1366 tablet at DPR 2; +- 430 x 932, 390 x 844, 375 x 812, and 320 x 720 mobile profiles at DPR 3; +- 2560 x 1440 archival desktop at DPR 2. + +Source- and database-inventoried canonical routes use the complete matrix above. Rendered same-origin links discovered from those pages use desktop, tablet, and mobile representatives in both themes plus archival desktop dark. This accounts for every finite link target without repeating deep captures for query variants that render the same page template. `coverage-plan.json` records both matrices and the sampling rationale. + +Every captured route state receives a deterministic keyboard skip-link focus and activation check. Deep archival-dark capture on canonical routes opens disclosures, lightboxes, media pickers, inline editing, Studio editors, media inspectors, validation states, visualizer boundaries, and the element atlas. Every full-page capture and every independently scrollable surface uses 12 percent overlapping viewport tiles, stitched PNGs, tile manifests, and image-correlation seam checks. The runner does not use Playwright's geometry-changing `fullPage: true` path: repeated disposable runs proved that it could intermittently cancel six responsive Next Image candidates on `/portfolio`, while the tiled path preserved responsive selection and completed three consecutive strict smokes without unexpected diagnostics. Chromium uses the container's explicit shared-memory mount rather than spilling into the bounded `/tmp` tmpfs; the audit runner has a 512 MiB scratch ceiling for browser profiles and temporary files. + +## Bounded Parallelism And Benchmarks + +`VISUAL_AUDIT_CAPTURE_WORKERS`, `VISUAL_AUDIT_VALIDATION_WORKERS`, and `VISUAL_AUDIT_REPORT_WORKERS` accept `auto` or a bounded integer. `auto` uses the available CPU parallelism with measured caps of 2 capture workers and 6 validation/report workers. Explicit capture values are limited to 1-6; validation and report values are limited to 1-8. Snapshot-lab capture is always forced to one worker because its mutation states are ordered, although its post-capture validation and report work can remain bounded and parallel. + +A disposable live-readonly capture matrix kept the serial mutation-sensitive special stage separate and preserved 310 captures, 27 routes, zero unexpected diagnostics, zero successful unsafe requests, and complete cleanup at each worker count: + +| Capture workers | Anonymous seconds | Admin seconds | Serial special seconds | Total capture seconds | Speedup | +|---:|---:|---:|---:|---:|---:| +| 1 | 35.118 | 58.493 | 45.192 | 138.803 | 1.00x | +| 2 | 18.496 | 31.121 | 45.234 | 94.851 | 1.46x | +| 4 | 11.216 | 18.047 | 45.159 | 74.422 | 1.87x | +| 6 | 8.517 | 12.303 | 45.139 | 65.959 | 2.10x | + +End-to-end wall time also includes application startup, comparison, report generation, validation, and cleanup; the measured totals were 156.226, 109.242, 90.804, and 80.292 seconds respectively. Two workers remain the automatic capture cap because they materially reduce route capture time without imposing the four- or six-browser peak load on the NAS. After replacing resize-based full-page screenshots with tiled capture, three consecutive two-worker smokes completed in stable 104.750-105.029 second capture-stage totals. Each produced 310 captures, 39 tile manifests, 689 checksums, zero unexpected diagnostics, zero successful unsafe requests, 29 blocked unsafe requests, zero cross-origin requests, no temporary files, and zero residual containers or volumes. + +The validator reads decoded pixel channels as a sequential stream and computes the same sample standard deviation used by Sharp without materializing full raw images or asking libvips for random-access statistics. This prevents large full-page PNG validation from spilling temporary images into the bounded scratch filesystem. Decode dimensions, blank-image thresholds, checksums, diagnostics, seam checks, and canonical output ordering remain unchanged. + +A corrected full-clone benchmark over 37,957 checksummed artifacts produced the same 23 pre-existing validation failures, two diagnostics, semantic digest `2d20c9c564fc4dae4e468936aa5a054f7f2326a8613aaf3af6084e53fc080d74`, validation hash, and checksum hashes at every worker count: + +| Validation workers | Artifact seconds | Total seconds | Speedup | CPU seconds | Peak container bytes | +|---:|---:|---:|---:|---:|---:| +| 1 | 1350.314 | 1359.808 | 1.00x | 1840.969 | 1,360,478,208 | +| 4 | 429.744 | 437.022 | 3.11x | 1776.173 | 2,195,255,296 | +| 6 | 342.494 | 348.317 | 3.90x | 2112.510 | 2,603,417,600 | +| 8 | 322.335 | 328.050 | 4.15x | 2635.124 | 3,154,866,176 | + +Eight workers improved total time by only 5.8 percent over six while consuming about 24.7 percent more CPU time and 21.2 percent more peak memory, so six is the automatic validation/report cap. Six simultaneous report-slice tasks converted 156 slices from six 1170 x 39996 sources in 8.502 seconds under the production 512 MiB tmpfs, and a repeated run produced identical slice hashes. + +The validator benchmark utility operates only on a disposable clone of a retained output volume and removes that clone in `finally`: + +```powershell +visual-audit/scripts/benchmark-validator-volume.ps1 ` + -SourceVolume ` + -RunId ` + -Workers 6 ` + -AuditImage woodsmith-visual-audit: ` + -TargetCommit +``` + +Do not increase worker counts blindly. Re-run the matrix against representative full-run evidence after changing the capture matrix, image sizes, NAS CPU/memory limits, Sharp, libvips, Playwright, or report selection logic. + +## Accelerator Contract And GPU Evidence + +`VISUAL_AUDIT_ACCELERATOR` accepts only `auto`, `cpu`, or `cuda`: + +- `auto` probes `nvidia-smi` with a three-second bound and selects CUDA only for stages that this build explicitly marks benchmark-verified. +- `cpu` forces the portable canonical pipeline even when a device is visible. +- `cuda` fails before capture when no CUDA device is visible or when this build has no deterministic benchmark-verified CUDA stage. It never falls through while claiming acceleration. + +The restricted manifest records the requested and selected mode, bounded CUDA-device facts, the Chromium renderer reported by `SystemInfo.getInfo`, and one backend decision for each browser, PNG, resize/print, tile/seam, hash/redaction, and PDF phase. The redacted manifest retains the mode and stage decisions but omits device identity. Resume refuses to combine output produced under different accelerator provenance. Archive schema version 5 also records the evidence tier, protected mounted-media aggregates, hashed rendered-source observations, and placeholder evidence. It is intentionally incompatible with older incomplete manifests. + +The 2026-07-17 workstation probe used Docker Engine 29.6.1, BuildKit 0.31.1, an RTX 3070 Ti Laptop GPU with 8,192 MiB, driver 573.91, CUDA compatibility 12.8, and compute capability 8.6. A minimal isolated container successfully loaded `libcuda.so.1` and enumerated one device. That proves device access, not an accelerated audit stage. + +Three browser launches per candidate used the pinned Playwright image, `linux/amd64`, `--gpus all`, no network, a four-GiB memory limit, twelve CPUs, and the deterministic 1440 x 900 benchmark surface: + +| Candidate | CDP renderer | Warm total seconds | Pixel result | Decision | +|---|---|---:|---|---| +| Canonical | ANGLE SwiftShader | 0.257-0.280 | Stable `a38cf990...` | Retain | +| Explicit SwiftShader GPU flags | ANGLE SwiftShader | 0.571-0.579 | Different `1b150fc8...` | Reject: slower and noncanonical | +| CUDA/Vulkan flags | ANGLE SwiftShader | 0.290-0.295 | Canonical | Reject: hardware backend unavailable | +| CUDA/GL flags | ANGLE SwiftShader | 0.263-0.281 | Canonical | Reject: hardware backend unavailable | + +NVIDIA nvImageCodec 0.9.0.20 was evaluated separately against all 299 retained PNGs (24,768,208 encoded bytes; 288 RGB and 11 RGBA). `CPU_ONLY` with `I_UNCHANGED` reproduced every Sharp raw-pixel hash, including alpha. `GPU_ONLY`, `HYBRID_CPU_GPU`, and `HW_GPU_ONLY` could not create a PNG decoder. The default decoder used the CPU PNG plugin and copied its result to device memory; that is transfer overhead, not GPU decode. The 32.1-MiB wheel and temporary 399-MiB benchmark image are not production dependencies. + +The reproducible three-pass phase profiler preserved the same semantic digest on every pass and removed its scratch root in `finally`: + +| Phase | Cold seconds | Warm seconds | +|---|---:|---:| +| Directory inventory | 0.012 | 0.008 | +| Streaming SHA-256 | 0.058 | 0.042-0.049 | +| PNG metadata | 0.031 | 0.027-0.029 | +| PNG decode and blankness | 4.442 | 3.215-3.235 | +| PNG thumbnail resize/encode | 0.542 | 0.527-0.531 | +| Seam resize/difference (64 pairs) | 0.293 | 0.283-0.301 | +| Tile composite/PNG (16 tiles) | 0.113 | 0.106-0.108 | +| Print resize/JPEG (12 files) | 0.365 | 0.356-0.357 | +| Redacted copy/hash (64 files) | 0.031 | 0.022-0.024 | +| JSON manifest | 0.001 | 0.001 | +| PDF atlas (12 image pages) | 0.058 | 0.014 | + +The decode/blankness phase is the only material post-processing hotspot in this representative corpus, and the tested NVIDIA library provides no PNG GPU backend. NPP resize/composite would not preserve Sharp/libvips interpolation, blend, PNG, and JPEG byte semantics without a separately maintained native implementation; the measured resize/composite phases are also subsecond. Hashing, JSON, copy/redaction, and PDF assembly are I/O or serial metadata/document work rather than suitable GPU kernels. The current zero-stage CUDA allowlist is therefore an evidence-based result, not a claim that CUDA was enabled. + +Re-run the source-controlled benchmarks after changing Playwright, Chromium, Sharp/libvips, capture sizes, codecs, GPU runtime, or the representative corpus: + +```bash +BENCHMARK_RUN_ROOT=/restricted/archive/png \ +BENCHMARK_REPEATS=3 \ +VISUAL_AUDIT_VALIDATION_WORKERS=6 \ +npm --prefix visual-audit run benchmark:phases + +docker run --rm --init --gpus all --network none \ + --ipc private --shm-size 1g --memory 4g --cpus 12 \ + -e BENCHMARK_BROWSER_VARIANTS=all \ + -e BENCHMARK_REPEATS=3 \ + --entrypoint node woodsmith-visual-audit: \ + dist/benchmark-browser-gpu.js +``` + +The browser procedure follows Chromium's official headless GPU guidance and verifies the actual renderer rather than flags alone. nvImageCodec backend expectations follow NVIDIA's codec documentation. See [Chromium headless GPU guidance](https://chromium.googlesource.com/chromium/src/+/HEAD/docs/gpu/using-gpu-hardware-in-headless-chrome.md), [Docker Desktop GPU support](https://docs.docker.com/desktop/features/gpu/), and [NVIDIA nvImageCodec](https://docs.nvidia.com/cuda/nvimagecodec/). + +## Snapshot Lab + +Prepare a new isolated lab for every mutation-state run: + +```bash +cd /volume2/docker_ssd/woodsmith +visual-audit/scripts/prepare-snapshot-lab.sh +visual-audit/scripts/run-snapshot-lab.sh +``` + +Preparation performs an online SQLite backup with `VACUUM INTO`, verifies `PRAGMA quick_check`, copies the database into a unique lab root, and creates a Btrfs reflink media copy when supported. If reflinks are unavailable, it performs a full `rsync -a` copy. Hardlinks are forbidden because lab rename/delete operations must not alter production originals. + +Preparation writes the ignored lab environment with `AUDIT_EVIDENCE_TIER=tier-2-production-clone` and `AUDIT_MEDIA_PROVENANCE=production-clone`. The runner refuses a mismatch before starting Compose. + +Preparation also resolves `woodsmith:candidate-` and `woodsmith-visual-audit:candidate-` by default, requires both images to be `linux/amd64`, and requires the application image to report the exact full Git commit through `WOODSMITH_BUILD_SHA`. Override `WOODSMITH_AUDIT_APP_IMAGE` or `WOODSMITH_VISUAL_AUDIT_IMAGE` only with another exact, prevalidated tag. The operational scripts use direct Docker access when available and otherwise use Synology's noninteractive `/usr/local/bin/docker` through `sudo -n`; only an explicit nonsecret Compose-selector allowlist crosses that `sudo` boundary. Passwords and tokens remain restricted files or env-file inputs. The scripts never silently substitute a source rebuild. + +Both lab mounts contain a matching run marker. The runner rejects missing markers, production paths, mismatched run IDs, and unavailable clones. The lab container health check verifies the cloned database before capture begins. The internal Docker network blocks outbound provider access. + +The lab uses an internal HTTP origin. Client-only operation and telemetry IDs therefore prefer `crypto.randomUUID()` but fall back to `crypto.getRandomValues()` when secure-context-only APIs are unavailable; authentication and access secrets remain server-generated. Session, cart, and project cookies omit `Secure` only when both `VISUAL_AUDIT_SNAPSHOT_LAB=true` and the lab-only `ALLOW_INSECURE_AUDIT_COOKIES=true` guard are present. Normal production cookies remain `Secure`. + +The runner creates the restricted `visual-audits/` root before Compose starts and uses a dedicated `woodsmith-visual-audit-lab` Compose project. Live runs use `woodsmith-visual-audit-live`. This keeps archive networks and lifecycle cleanup separate from the production Compose project. + +The Compose files assign the private audit and image-cache tmpfs mounts to the configured `PUID:PGID`. Keep those values aligned with the container user; a root-owned mode-700 tmpfs prevents the non-root app or runner from writing its disposable cache. + +The lab archive performs one bounded commission-draft save, reads the saved record back, captures the visible saved state, and deletes the draft before continuing. Validation requires exactly those two successful unsafe responses and the saved-state capture. Form error-state capture uses browser-native constraint validation without submitting, and synthetic `/api/visits` telemetry is blocked so the archive does not create visitor sessions. These mutations run only in `snapshot-lab`; `live-readonly` continues to reject every capture-time unsafe request. + +Do not run `docker compose down -v`; the scripts use ordinary `down` and preserve bind-mounted evidence for review. + +## Artifacts + +Each restricted run directory uses mode 700 and contains mode-600 files: + +```text +visual-audits// + coverage-plan.json + manifest.json + live-media.json + placeholder-report.json + comparison.json + validation.json + checksums.json + checksums.sha256 + png/ + report/index.html + report/print.html + report/selection.json + report/report-index.json + woodmat-visual-atlas.pdf + shareable/index.html + shareable/manifest.redacted.json + shareable/woodmat-visual-atlas-redacted.pdf +``` + +Raw tiles and tile manifests live beside their stitched capture. The restricted HTML report remains complete and searchable across every capture. `report/selection.json` records a deterministic maximum of 16 print representatives per authenticated route, covering route, theme, viewport, accessibility, header, and available deep-state families. PDFKit streams only that reviewed selection as bounded A3 image slices with selectable route/state metadata and native bookmarks. This prevents query variants and element atlases from expanding derived PDFs without bound; no raw restricted PNG is removed. Report reruns clear only their generated report/shareable directories and atomically replace both PDFs. The PNG/raw-tile tree and complete restricted HTML remain the highest-resolution sources of truth. + +The shareable edition includes the same deterministic representatives filtered to anonymous, nonsensitive captures only. Its copied image assets use opaque sequence names and preserve only a safe image extension; captions and PDF labels do not reuse source filenames. The redacted manifest records both source and selected counts. Review it before moving it outside the restricted NAS archive. + +## Validation Contract + +`dist/validate.js` fails the run for: + +- incomplete or truncated inventory; +- evidence-tier, target-mode, or media-provenance mismatch; +- missing public mounted files, synthetic markers in production tiers, or no anonymous mounted-media observation; +- visible media load failures or unapproved visible placeholders; +- media/placeholder report content that does not exactly reproduce from the final manifest; +- missing canonical or discovered-link route/theme/viewport combinations; +- missing focused or activated skip-link evidence for any rendered route; +- discovered same-origin links not captured; +- missing deep states that were present in the rendered surface inventory; +- unexpected status codes, redirects, console/page errors, request failures, broken media, or horizontal overflow; +- invalid, blank, missing, or duplicate PNG captures; +- raw-tile coverage gaps or seam-correlation failures; +- missing HTML contents targets, PDFs, PDF pages, or bookmark trees; +- a report selection count mismatch, unknown capture key, or omitted route; +- commit/run/mode mismatches; +- successful unsafe live requests; +- exact secret values found in any output artifact; +- permissive archive directory modes on Linux. + +Checksums cover the final report, validation record, PNGs, raw tiles, manifests, HTML, PDFs, and comparison output. + +## Scheduling And Retention + +Use Synology Task Scheduler as `root` after deployment and after the first manual full run succeeds: + +```bash +AUDIT_SCOPE=full /volume2/docker_ssd/woodsmith/visual-audit/scripts/run-live-audit.sh \ + >> /volume2/docker_ssd/woodsmith/visual-audits/scheduler.log 2>&1 +``` + +The run script uses a lock directory to prevent overlap. Retention is dry-run by default: + +```bash +AUDIT_RETENTION_DAYS=90 visual-audit/scripts/prune-audits.sh +``` + +After reviewing the listed paths: + +```bash +AUDIT_RETENTION_DAYS=90 visual-audit/scripts/prune-audits.sh --apply +``` + +Retention refuses roots outside `/volume2/docker_ssd/woodsmith/visual-audits` and refuses periods shorter than 30 days. + +## Release Gate + +Do not deploy merely to obtain screenshots. The application candidate must first pass the normal database backup, `quick_check`, no-embedded-database, disposable-data, mounted media/data/cache, route, log, rollback-image, and public canonical-origin gates. Run the smoke archive against the promoted candidate, then the full live archive and isolated lab archive. Keep the previous image and database backup until the post-deployment archive validates. diff --git a/site/app/api/commissions/draft/route.ts b/site/app/api/commissions/draft/route.ts new file mode 100644 index 0000000..9c84893 --- /dev/null +++ b/site/app/api/commissions/draft/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; + +import { getCurrentUser } from "@/lib/auth"; +import { deleteCommissionDraftForUser, getCommissionDraftForUser, listCommissionDraftsForUser, saveCommissionDraftForUser } from "@/lib/db"; +import { assertTrustedMutationOrigin, UntrustedMutationOriginError } from "@/lib/request-security"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function errorResponse(message: string, status: number) { + return NextResponse.json({ ok: false, message }, { status, headers: { "cache-control": "no-store" } }); +} + +export async function GET(request: Request) { + const user = await getCurrentUser(); + if (!user || user.role === "customer" && !user.emailVerified) return errorResponse("A verified account is required for server drafts.", 401); + const id = new URL(request.url).searchParams.get("id")?.trim(); + const result = id ? getCommissionDraftForUser(id, user.email) : listCommissionDraftsForUser(user.email); + if (id && !result) return errorResponse("Draft not found.", 404); + return NextResponse.json({ ok: true, draft: id ? result : undefined, drafts: id ? undefined : result }, { headers: { "cache-control": "no-store" } }); +} + +export async function POST(request: Request) { + try { + assertTrustedMutationOrigin(request); + const user = await getCurrentUser(); + if (!user || user.role === "customer" && !user.emailVerified) return errorResponse("A verified account is required for server drafts.", 401); + const body = await request.json().catch(() => null) as Record | null; + if (!body || !body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) return errorResponse("Draft payload is invalid.", 400); + const draft = saveCommissionDraftForUser({ + id: typeof body.id === "string" ? body.id : null, + userEmail: user.email, + payload: body.payload as Record, + currentStep: Number(body.currentStep ?? 1), + idempotencyKey: String(body.idempotencyKey ?? ""), + expectedUpdatedAt: typeof body.expectedUpdatedAt === "string" ? body.expectedUpdatedAt : null + }); + return NextResponse.json({ ok: true, draft }, { headers: { "cache-control": "no-store" } }); + } catch (error) { + if (error instanceof UntrustedMutationOriginError) return errorResponse(error.message, error.status); + const message = error instanceof Error ? error.message : "Draft save failed."; + return errorResponse(message, /another session/.test(message) ? 409 : 400); + } +} + +export async function DELETE(request: Request) { + try { + assertTrustedMutationOrigin(request); + const user = await getCurrentUser(); + if (!user) return errorResponse("Authentication is required.", 401); + const id = new URL(request.url).searchParams.get("id")?.trim() ?? ""; + if (!id || !deleteCommissionDraftForUser(id, user.email)) return errorResponse("Draft not found.", 404); + return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } }); + } catch (error) { + if (error instanceof UntrustedMutationOriginError) return errorResponse(error.message, error.status); + return errorResponse(error instanceof Error ? error.message : "Draft deletion failed.", 400); + } +} diff --git a/site/app/api/render-preview/route.ts b/site/app/api/render-preview/route.ts index 864888e..fc0f558 100644 --- a/site/app/api/render-preview/route.ts +++ b/site/app/api/render-preview/route.ts @@ -1,8 +1,12 @@ -import { NextResponse } from "next/server"; -import { createPhotorealisticPreview, getAiServiceStatus, type PhotorealisticRenderInput } from "@/lib/ai-services"; -import { persistGeneratedMedia } from "@/lib/media"; -import { refreshMediaLibrary, saveMediaMetadata } from "@/lib/db"; -import { toMediaUrl } from "@/lib/format"; +import { NextResponse } from "next/server"; +import { createPhotorealisticPreview, getAiServiceStatus, type PhotorealisticRenderInput } from "@/lib/ai-services"; +import { persistGeneratedMedia } from "@/lib/media"; +import { consumeCommissionRenderQuota, refreshMediaLibrary, registerCommissionRenderAsset, saveMediaMetadata } from "@/lib/db"; +import { toMediaUrl } from "@/lib/format"; +import { getCurrentUser } from "@/lib/auth"; +import { commissionOwnerKey } from "@/lib/commission-security"; +import { normalizeVisualizerState } from "@/lib/estimator"; +import { assertTrustedMutationOrigin, UntrustedMutationOriginError } from "@/lib/request-security"; export const runtime = "nodejs"; @@ -11,31 +15,59 @@ function numberField(value: unknown, fallback: number) { return Number.isFinite(parsed) ? parsed : fallback; } -export async function POST(request: Request) { - const status = getAiServiceStatus(); +export async function POST(request: Request) { + try { + assertTrustedMutationOrigin(request); + } catch (error) { + return NextResponse.json({ error: error instanceof UntrustedMutationOriginError ? error.message : "Preview request was rejected." }, { status: 403 }); + } + const status = getAiServiceStatus(); if (!status.publicRendering) { return NextResponse.json({ error: "AI rendering is not enabled. Set OPENAI_API_KEY and ENABLE_PUBLIC_AI_RENDERING=true to activate photorealistic previews." }, { status: 503 }); } - const body = await request.json().catch(() => ({})) as Partial; - const input: PhotorealisticRenderInput = { - pieceType: String(body.pieceType || "custom woodworking piece"), - material: String(body.material || "White maple"), - joinery: String(body.joinery || "Mortise and tenon"), - width: numberField(body.width, 48), - depth: numberField(body.depth, 24), - height: numberField(body.height, 30), - drawers: numberField(body.drawers, 0), - shelves: numberField(body.shelves, 0), - notes: String(body.notes || "") + const user = await getCurrentUser(); + const ownerKey = await commissionOwnerKey(user?.email); + const quota = consumeCommissionRenderQuota(ownerKey, user ? 8 : 3); + if (!quota.allowed) { + return NextResponse.json({ error: "The preview limit for this 24-hour window has been reached.", retryAfterSeconds: quota.retryAfterSeconds }, { + status: 429, + headers: { "retry-after": String(quota.retryAfterSeconds) } + }); + } + + const body = await request.json().catch(() => ({})) as Partial; + const normalized = normalizeVisualizerState({ + kind: String(body.pieceType || "other-custom-work").slice(0, 120), + material: String(body.material || "White maple").slice(0, 120), + joinery: String(body.joinery || "Mortise and tenon").slice(0, 120), + width: numberField(body.width, 48), + depth: numberField(body.depth, 24), + height: numberField(body.height, 30), + drawers: numberField(body.drawers, 0), + shelves: numberField(body.shelves, 0), + notes: String(body.notes || "").slice(0, 2000), + includeVisualization: true + }); + const input: PhotorealisticRenderInput = { + pieceType: normalized.kind, + material: normalized.material, + joinery: normalized.joinery, + width: normalized.width, + depth: normalized.depth, + height: normalized.height, + drawers: normalized.drawers, + shelves: normalized.shelves, + notes: normalized.notes }; try { const generated = await createPhotorealisticPreview(input); if (generated.b64Json) { - const relativePath = persistGeneratedMedia(generated.b64Json, "ai-renderings", input.pieceType, ".png"); + const relativePath = persistGeneratedMedia(generated.b64Json, "ai-renderings", input.pieceType, ".png"); + registerCommissionRenderAsset(relativePath, ownerKey); refreshMediaLibrary(); saveMediaMetadata({ relativePath, @@ -54,10 +86,10 @@ export async function POST(request: Request) { } }); - return NextResponse.json({ relativePath, mediaUrl: toMediaUrl(relativePath), model: status.imageModel }); + return NextResponse.json({ relativePath, mediaUrl: toMediaUrl(relativePath), model: status.imageModel, remaining: quota.remaining }); } - return NextResponse.json({ imageUrl: generated.url, model: status.imageModel }); + return NextResponse.json({ imageUrl: generated.url, model: status.imageModel, remaining: quota.remaining }); } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "AI preview generation failed." }, { status: 502 }); } diff --git a/site/app/api/shop/local-reservation/route.ts b/site/app/api/shop/local-reservation/route.ts index d2a4dc1..3ece770 100644 --- a/site/app/api/shop/local-reservation/route.ts +++ b/site/app/api/shop/local-reservation/route.ts @@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/auth"; import { createDraftOrder, getPiece, getSiteSettings, listCartItems } from "@/lib/db"; import { getDropoffDriveMinutes, getFulfillmentSummary, getWoodshopZip, pieceShippingEnabled } from "@/lib/catalog"; import { calculateCheckoutTotals } from "@/lib/payments"; +import { pieceCanEnterCart } from "@/lib/piece-model"; function redirectTo(path: string, request: Request) { return NextResponse.redirect(new URL(path, request.url)); @@ -34,7 +35,7 @@ export async function POST(request: Request) { const invalidItems: string[] = []; const pieces = cartItems.flatMap((item) => { const piece = getPiece(item.pieceSlug); - if (!piece || piece.priceCents == null) { + if (!piece || !pieceCanEnterCart(piece) || piece.priceCents == null || item.quantity > piece.inventoryCount) { invalidItems.push(item.pieceSlug); return []; } diff --git a/site/app/api/studio/inline-edit/route.ts b/site/app/api/studio/inline-edit/route.ts index 1d04e86..fb223d1 100644 --- a/site/app/api/studio/inline-edit/route.ts +++ b/site/app/api/studio/inline-edit/route.ts @@ -1,147 +1,501 @@ -import { NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; import { revalidatePath } from "next/cache"; -import { requireAdmin } from "@/lib/auth"; -import { getPage, getPiece, getPost, getSiteSettings, getUserByEmail, savePage, savePiece, savePost, saveSiteSettings, saveUserProfile } from "@/lib/db"; - -type InlineMode = "update" | "add" | "cut"; -type InlinePatch = { resource?: string; id?: string; field?: string; index?: number | string | null; value?: string; mode?: InlineMode }; -type NavLink = { label: string; href: string }; -type SocialLink = { label: string; url: string }; - -const settingsText = new Set(["brandName", "brandTagline", "siteAnnouncement", "builderName", "builderHeadline", "builderEmail", "developerName", "developerHeadline", "developerEmail", "supportEmail", "notificationForwardEmail", "repoLabel", "repoUrl"]); -const homeText = new Set(["eyebrow", "title", "copy", "primaryCta.label", "primaryCta.href", "secondaryCta.label", "secondaryCta.href"]); -const pageText = new Set(["title", "navLabel", "intro", "body"]); -const pieceText = new Set(["title", "subtitle", "summary", "story", "availabilityLabel"]); -const pieceLists = new Set(["details", "materials", "tags"]); -const postText = new Set(["title", "excerpt", "body", "sourceLabel", "sourceUrl"]); -const postLists = new Set(["tags"]); -const userText = new Set(["displayName", "headline", "bio"]); - -const clean = (value: unknown) => String(value ?? "").replace(/\u00a0/g, " ").replace(/[ \t]+\n/g, "\n").trim(); -const cleanIndex = (value: unknown) => { const n = Number(value); return Number.isInteger(n) && n >= 0 ? n : null; }; -const responseError = (message: string, status = 400, details?: unknown) => NextResponse.json({ ok: false, message, details }, { status }); - -function safeUrl(value: string) { - const trimmed = value.trim(); - if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed; - if (trimmed.startsWith("mailto:")) { - const address = trimmed.slice(7).split("?")[0] ?? ""; - if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address)) return trimmed; - throw new Error("mailto: links must contain a valid email address."); +import { NextResponse } from "next/server"; + +import { getCurrentUser } from "@/lib/auth"; +import { sanitizeCategoryIconSvg } from "@/lib/category-icons"; +import { normalizePieceCategories } from "@/lib/categories"; +import { + getCommissionType, + getMedia, + getPage, + getPiece, + getPost, + getProject, + getSiteSettings, + getUserByEmail, + recordAdminEditAudit, + saveCommissionType, + savePage, + savePiece, + savePost, + saveSiteSettings, + saveUserProfile, + updateProject, + withDatabaseTransaction, + type SiteSettings +} from "@/lib/db"; +import { + editInlineList, + validateInlineEditPatch, + type InlineEditPatchInput, + type ValidatedInlineEditPatch +} from "@/lib/inline-edit-registry"; +import { assertTrustedMutationOrigin, UntrustedMutationOriginError } from "@/lib/request-security"; +import { normalizeFooterConfiguration, normalizeHomeServices } from "@/lib/site-structure"; + +type RevertPatch = { + resource: string; + id?: string; + field: string; + index?: number; + toIndex?: number; + value?: unknown; + expectedValue?: string; + mode?: "update" | "add" | "cut" | "move"; +}; + +type DeepMutable = T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? DeepMutable[] + : T extends object + ? { -readonly [Key in keyof T]: DeepMutable } + : T; +type MutableSiteSettings = DeepMutable; + +type AppliedPatch = { + resource: string; + id: string; + field: string; + index: number | null; + mode: string; + auditId: string; + revertPatches: RevertPatch[]; +}; + +class InlineEditError extends Error { + constructor(message: string, readonly status: number, readonly details?: unknown) { + super(message); } - const parsed = new URL(trimmed); - if (!["https:", "http:"].includes(parsed.protocol)) throw new Error("Inline URL must be https, http, mailto, or a root-relative /path."); - return parsed.toString(); -} -function normalize(field: string, value: string) { return field === "repoUrl" || field === "sourceUrl" || field.endsWith(".href") || field.endsWith(".url") ? safeUrl(value) : value; } -function splitLines(value: string) { return value.split(/\r?\n|,/g).map((item) => item.trim()).filter(Boolean); } -function editList(current: string[], mode: InlineMode, index: number | null, value: string) { - const next = [...current]; - if (mode === "add") return [...next, ...splitLines(value)]; - if (mode === "cut") { if (index == null || index >= next.length) throw new Error("A valid item index is required for this list edit."); next.splice(index, 1); return next; } - if (index == null) return splitLines(value); - if (index >= next.length) throw new Error("Array index is outside the current editable range."); - next[index] = value; - return next.map((item) => item.trim()).filter(Boolean); -} -function editLabel(current: T[], mode: InlineMode, index: number | null, value: string) { - const next = [...current]; - if (mode === "cut") { if (index == null || index >= next.length) throw new Error("A valid link index is required for this link edit."); next.splice(index, 1); return next; } - if (mode === "add") return next; - if (index == null || index >= next.length) throw new Error("A valid link index is required for this link edit."); - next[index] = { ...next[index], label: value }; - return next; -} -function editNavUrl(current: NavLink[], index: number | null, value: string) { - if (index == null || index >= current.length) throw new Error("A valid navigation index is required for this URL edit."); - return current.map((item, i) => i === index ? { ...item, href: safeUrl(value) } : item); -} -function editSocialUrl(current: SocialLink[], index: number | null, value: string) { - if (index == null || index >= current.length) throw new Error("A valid social-link index is required for this URL edit."); - return current.map((item, i) => i === index ? { ...item, url: safeUrl(value) } : item); -} -function refresh(resource: string, id?: string) { - revalidatePath("/", "layout"); - for (const route of ["/", "/portfolio", "/shop", "/process", "/about", "/contact", "/studio"]) revalidatePath(route); - if (resource === "page" && id) revalidatePath(id === "home" ? "/" : `/${id}`); - if (resource === "piece" && id) revalidatePath(`/portfolio/${id}`); - if (resource === "post" && id) revalidatePath(`/process/${id}`); } -function updateHomeSection(section: Record, field: string, value: string) { - if (field.startsWith("primaryCta.") || field.startsWith("secondaryCta.")) { - const key = field.startsWith("primary") ? "primaryCta" : "secondaryCta"; - const prop = field.endsWith(".href") ? "href" : "label"; - const current = typeof section[key] === "object" && section[key] ? section[key] as Record : {}; - return { ...section, [key]: { ...current, [prop]: normalize(field, value) } }; + +function responseError(message: string, status = 400, details?: unknown) { + return NextResponse.json({ ok: false, message, details }, { status }); +} + +function comparable(value: unknown) { + if (value == null) return ""; + if (typeof value === "string") return value.replace(/\u00a0/g, " ").trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function assertExpected(patch: ValidatedInlineEditPatch, current: unknown) { + if (patch.expectedValue === undefined) return; + if (comparable(current) !== comparable(patch.expectedValue)) { + throw new InlineEditError( + `${patch.definition.label} changed after edit mode opened. Refresh and review the newer value before saving.`, + 409, + { resource: patch.resource, id: patch.id, field: patch.field, currentValue: current } + ); + } +} + +function patchIdentity(patch: ValidatedInlineEditPatch) { + return `${patch.resource}:${patch.id}:${patch.field}:${patch.index ?? "all"}`; +} + +function directInverse(patch: ValidatedInlineEditPatch, before: unknown, after: unknown): RevertPatch[] { + return [{ + resource: patch.resource, + ...(patch.id ? { id: patch.id } : {}), + field: patch.field, + ...(patch.index != null ? { index: patch.index } : {}), + value: before, + expectedValue: comparable(after), + mode: "update" + }]; +} + +function listValues(patch: ValidatedInlineEditPatch) { + return Array.isArray(patch.value) ? patch.value : [patch.value]; +} + +function applyList(current: readonly T[], patch: ValidatedInlineEditPatch, additions: readonly T[]) { + const selectedBefore = patch.index == null ? current : current[patch.index]; + if (patch.mode !== "add") assertExpected(patch, selectedBefore); + const insertionIndex = patch.toIndex == null ? current.length : Math.min(patch.toIndex, current.length); + const next = editInlineList(current, patch, additions); + let revertPatches: RevertPatch[]; + if (patch.mode === "add") { + revertPatches = additions.map(() => ({ resource: patch.resource, ...(patch.id ? { id: patch.id } : {}), field: patch.field, index: insertionIndex, mode: "cut" })); + } else if (patch.mode === "cut") { + revertPatches = [{ resource: patch.resource, ...(patch.id ? { id: patch.id } : {}), field: patch.field, value: selectedBefore, toIndex: patch.index ?? 0, mode: "add" }]; + } else if (patch.mode === "move") { + revertPatches = [{ resource: patch.resource, ...(patch.id ? { id: patch.id } : {}), field: patch.field, index: patch.toIndex ?? 0, toIndex: patch.index ?? 0, mode: "move" }]; + } else { + const selectedAfter = patch.index == null ? next : next[patch.index]; + revertPatches = directInverse(patch, selectedBefore, selectedAfter); } - return { ...section, [field]: value }; -} - -function applyPatch(patch: InlinePatch) { - const resource = clean(patch.resource), field = clean(patch.field), id = clean(patch.id), rawValue = clean(patch.value); - const index = cleanIndex(patch.index), mode: InlineMode = patch.mode === "add" || patch.mode === "cut" ? patch.mode : "update"; - if (!resource || !field) throw new Error("Inline edit target is missing resource or field metadata."); - if (!rawValue && mode !== "cut") throw new Error("Inline edit value cannot be empty."); - const value = normalize(field, rawValue); - - if (resource === "settings") { - const settings = getSiteSettings(); - if (field === "navigation") saveSiteSettings({ ...settings, navigation: editLabel([...settings.navigation] as unknown as NavLink[], mode, index, value) as unknown as typeof settings.navigation }); - else if (field === "navigation.href") { if (mode !== "update") throw new Error("Navigation URLs can only be updated inline."); saveSiteSettings({ ...settings, navigation: editNavUrl([...settings.navigation] as unknown as NavLink[], index, value) as unknown as typeof settings.navigation }); } - else if (field === "socialLinks") saveSiteSettings({ ...settings, socialLinks: editLabel([...settings.socialLinks] as unknown as SocialLink[], mode, index, value) as unknown as typeof settings.socialLinks }); - else if (field === "socialLinks.url") { if (mode !== "update") throw new Error("Social link URLs can only be updated inline."); saveSiteSettings({ ...settings, socialLinks: editSocialUrl([...settings.socialLinks] as unknown as SocialLink[], index, value) as unknown as typeof settings.socialLinks }); } - else { if (!settingsText.has(field) || mode !== "update") throw new Error(`Settings field '${field}' is not inline editable for '${mode}'.`); saveSiteSettings({ ...settings, [field]: value }); } - refresh(resource); - return { resource, field, index, mode }; + return { next, revertPatches }; +} + +function audit(input: { + actorEmail: string; + requestId: string; + patch: ValidatedInlineEditPatch; + entityType: string; + entityKey: string; + before: unknown; + after: unknown; + revertPatches: RevertPatch[]; +}): AppliedPatch { + const auditId = recordAdminEditAudit({ + actorEmail: input.actorEmail, + entityType: input.entityType, + entityKey: input.entityKey, + operation: `inline-${input.patch.mode}:${input.patch.field}`, + before: input.before, + after: input.after, + requestId: input.requestId + }); + return { + resource: input.patch.resource, + id: input.patch.id, + field: input.patch.field, + index: input.patch.index, + mode: input.patch.mode, + auditId, + revertPatches: input.revertPatches + }; +} + +function applySettingsPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const before = getSiteSettings(); + const next = structuredClone(before) as MutableSiteSettings; + let currentValue: unknown; + let afterValue: unknown; + let revertPatches: RevertPatch[]; + + if (patch.field === "navigation" || patch.field === "socialLinks") { + const links = patch.field === "navigation" + ? next.navigation.map((item) => ({ label: item.label, url: item.href })) + : next.socialLinks.map((item) => ({ label: item.label, url: item.url })); + const additions = patch.mode === "update" && patch.index == null + ? (patch.value as Array<{ label: string; url: string }>) + : listValues(patch).map((value) => { + if (value && typeof value === "object") return value as { label: string; url: string }; + const existing = patch.mode === "update" && patch.index != null ? links[patch.index] : null; + return { label: String(value), url: existing?.url ?? (patch.field === "navigation" ? "/" : "") }; + }); + if (patch.index != null && patch.mode !== "add") assertExpected(patch, links[patch.index]?.label); + const result = applyList(links, { ...patch, expectedValue: undefined }, additions); + currentValue = patch.index == null ? links : links[patch.index]?.label; + afterValue = patch.index == null ? result.next : result.next[patch.index]?.label; + revertPatches = patch.mode === "update" && patch.index != null + ? directInverse(patch, links[patch.index]?.label ?? "", result.next[patch.index]?.label ?? "") + : result.revertPatches; + if (patch.field === "navigation") next.navigation = result.next.map((item) => ({ label: item.label, href: item.url })); + else next.socialLinks = result.next; + } else if (patch.field === "navigation.href" || patch.field === "socialLinks.url") { + const links = patch.field === "navigation.href" ? next.navigation : next.socialLinks; + if (patch.index == null || patch.index >= links.length) throw new Error("The selected link is no longer available."); + const property = patch.field === "navigation.href" ? "href" : "url"; + currentValue = String((links[patch.index] as unknown as Record)[property] ?? ""); + assertExpected(patch, currentValue); + (links[patch.index] as unknown as Record)[property] = String(patch.value ?? ""); + afterValue = patch.value; + revertPatches = directInverse(patch, currentValue, afterValue); + } else if (patch.field === "footer.introHeading" || patch.field === "footer.introBody") { + const property = patch.field === "footer.introHeading" ? "introHeading" : "introBody"; + currentValue = next.footer[property]; + assertExpected(patch, currentValue); + next.footer[property] = String(patch.value ?? ""); + afterValue = next.footer[property]; + revertPatches = directInverse(patch, currentValue, afterValue); + } else if (patch.field.startsWith("footer.group.")) { + const group = next.footer.groups.find((entry) => entry.id === patch.id); + if (!group) throw new Error("The selected footer group is no longer available."); + const property = patch.field.slice("footer.group.".length) as "heading" | "visible" | "order"; + currentValue = group[property]; + assertExpected(patch, currentValue); + (group as unknown as Record)[property] = patch.value; + afterValue = group[property]; + revertPatches = directInverse(patch, currentValue, afterValue); + } else if (patch.field.startsWith("footer.item.")) { + const [groupId, itemId] = patch.id.split("/"); + const item = next.footer.groups.find((entry) => entry.id === groupId)?.items.find((entry) => entry.id === itemId); + if (!item) throw new Error("The selected footer item is no longer available."); + const property = patch.field.slice("footer.item.".length) as "label" | "value" | "url" | "type" | "visible" | "newTab" | "order"; + currentValue = item[property]; + assertExpected(patch, currentValue); + (item as unknown as Record)[property] = patch.value; + afterValue = item[property]; + revertPatches = directInverse(patch, currentValue, afterValue); + } else { + currentValue = (next as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + (next as unknown as Record)[patch.field] = patch.value; + afterValue = patch.value; + revertPatches = directInverse(patch, currentValue, afterValue); } - if (resource === "homeSection") { - if (!id) throw new Error("Home-section inline edit is missing a section key."); - if (!homeText.has(field) || mode !== "update") throw new Error(`Home section field '${field}' is not inline editable for '${mode}'.`); - const settings = getSiteSettings(); - const homeSections = settings.homeSections.map((section) => section.key === id ? updateHomeSection(section, field, value) : section) as unknown as typeof settings.homeSections; - saveSiteSettings({ ...settings, homeSections }); - refresh(resource, id); - return { resource, field, id, mode }; + next.footer = normalizeFooterConfiguration(next.footer); + saveSiteSettings(next as SiteSettings); + return audit({ actorEmail, requestId, patch, entityType: "settings", entityKey: "site", before, after: next, revertPatches }); +} + +function applyHomeSectionPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const settings = getSiteSettings(); + const next = structuredClone(settings) as MutableSiteSettings; + const section = next.homeSections.find((entry) => entry.key === patch.id) as Record | undefined; + if (!section) throw new Error("The selected home section is no longer available."); + const [root, child] = patch.field.split("."); + const currentValue = child ? (section[root] as Record | undefined)?.[child] : section[root]; + assertExpected(patch, currentValue); + if (child) section[root] = { ...(section[root] as Record ?? {}), [child]: patch.value }; + else section[root] = patch.value; + const afterValue = child ? (section[root] as Record)[child] : section[root]; + const revertPatches = directInverse(patch, currentValue, afterValue); + saveSiteSettings(next as SiteSettings); + return audit({ actorEmail, requestId, patch, entityType: "home-section", entityKey: patch.id, before: settings, after: next, revertPatches }); +} + +function applyHomeServicePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const settings = getSiteSettings(); + const next = structuredClone(settings) as MutableSiteSettings; + const service = next.homeServices.find((entry) => entry.id === patch.id); + if (!service) throw new Error("The selected home service is no longer available."); + const currentValue = (service as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + (service as unknown as Record)[patch.field] = patch.value; + next.homeServices = normalizeHomeServices(next.homeServices); + const afterValue = (next.homeServices.find((entry) => entry.id === patch.id) as unknown as Record)[patch.field]; + const revertPatches = directInverse(patch, currentValue, afterValue); + saveSiteSettings(next as SiteSettings); + return audit({ actorEmail, requestId, patch, entityType: "home-service", entityKey: patch.id, before: settings, after: next, revertPatches }); +} + +function applyPagePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const page = getPage(patch.id); + if (!page) throw new Error(`Page '${patch.id}' was not found.`); + if (patch.field === "heroMediaPath" && patch.value && !getMedia(String(patch.value))) throw new Error("Select media that exists in the mounted library."); + const currentValue = (page as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + const nextValue = patch.value === "" && patch.definition.nullable ? null : patch.value; + const next = { ...page, [patch.field]: nextValue }; + savePage(next); + const revertPatches = directInverse(patch, currentValue, nextValue); + return audit({ actorEmail, requestId, patch, entityType: "page", entityKey: patch.id, before: page, after: next, revertPatches }); +} + +function applyPiecePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const piece = getPiece(patch.id); + if (!piece) throw new Error(`Piece '${patch.id}' was not found.`); + const next = { ...piece }; + let currentValue: unknown; + let afterValue: unknown; + let revertPatches: RevertPatch[]; + if (["details", "materials", "tags", "mediaPaths"].includes(patch.field)) { + const current = [...((piece as unknown as Record)[patch.field] as string[])]; + const additions = listValues(patch).map(String); + if (patch.field === "mediaPaths") for (const mediaPath of additions) if (mediaPath && !getMedia(mediaPath)) throw new Error(`Media '${mediaPath}' is not in the mounted library.`); + const result = applyList(current, patch, additions); + (next as unknown as Record)[patch.field] = result.next; + currentValue = patch.index == null ? current : current[patch.index]; + afterValue = patch.index == null ? result.next : result.next[patch.index]; + revertPatches = result.revertPatches; + } else { + currentValue = (piece as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + if (patch.field === "category" && !getSiteSettings().pieceCategories.some((entry) => entry.key === patch.value)) throw new Error("Select an existing portfolio category."); + if (patch.field === "commissionTypeSlug" && patch.value && !getCommissionType(String(patch.value))) throw new Error("Select an existing commission type."); + (next as unknown as Record)[patch.field] = patch.value === "" && patch.definition.nullable ? null : patch.value; + afterValue = (next as unknown as Record)[patch.field]; + revertPatches = directInverse(patch, currentValue, afterValue); } - if (resource === "page") { - if (!id) throw new Error("Page inline edit is missing a slug."); - if (!pageText.has(field) || mode !== "update") throw new Error(`Page field '${field}' is not inline editable for '${mode}'.`); - const page = getPage(id); if (!page) throw new Error(`Page '${id}' was not found.`); - savePage({ ...page, [field]: value }); refresh(resource, id); return { resource, field, id, mode }; + savePiece(next); + return audit({ actorEmail, requestId, patch, entityType: "piece", entityKey: patch.id, before: piece, after: next, revertPatches }); +} + +function applyPostPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const post = getPost(patch.id); + if (!post) throw new Error(`Process note '${patch.id}' was not found.`); + const next = { ...post }; + let currentValue: unknown; + let afterValue: unknown; + let revertPatches: RevertPatch[]; + if (patch.field === "tags") { + const result = applyList(post.tags, patch, listValues(patch).map(String)); + next.tags = result.next; + currentValue = patch.index == null ? post.tags : post.tags[patch.index]; + afterValue = patch.index == null ? next.tags : next.tags[patch.index]; + revertPatches = result.revertPatches; + } else { + currentValue = (post as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + if (patch.field === "coverMediaPath" && patch.value && !getMedia(String(patch.value))) throw new Error("Select media that exists in the mounted library."); + (next as unknown as Record)[patch.field] = patch.value === "" && patch.definition.nullable ? null : patch.value; + afterValue = (next as unknown as Record)[patch.field]; + revertPatches = directInverse(patch, currentValue, afterValue); } - if (resource === "piece") { - if (!id) throw new Error("Piece inline edit is missing a slug."); - const piece = getPiece(id); if (!piece) throw new Error(`Piece '${id}' was not found.`); - if (pieceLists.has(field)) { const current = field === "details" ? piece.details : field === "materials" ? piece.materials : piece.tags; savePiece({ ...piece, [field]: editList(current, mode, index, value) }); } - else { if (!pieceText.has(field) || mode !== "update") throw new Error(`Piece field '${field}' is not inline editable for '${mode}'.`); savePiece({ ...piece, [field]: value }); } - refresh(resource, id); return { resource, field, id, index, mode }; + savePost(next); + return audit({ actorEmail, requestId, patch, entityType: "post", entityKey: patch.id, before: post, after: next, revertPatches }); +} + +function applyUserPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const user = getUserByEmail(patch.id); + if (!user) throw new Error(`Profile '${patch.id}' was not found.`); + const next = { ...user }; + let currentValue: unknown; + let afterValue: unknown; + let revertPatches: RevertPatch[]; + if (patch.field === "links") { + const links = user.links.map((entry) => ({ label: entry.label, url: entry.url })); + const additions = patch.mode === "update" && patch.index == null + ? patch.value as Array<{ label: string; url: string }> + : listValues(patch).map((value) => { + if (value && typeof value === "object") return value as { label: string; url: string }; + const existing = patch.mode === "update" && patch.index != null ? links[patch.index] : null; + return { label: String(value), url: existing?.url ?? "" }; + }); + if (patch.index != null && patch.mode !== "add") assertExpected(patch, links[patch.index]?.label); + const result = applyList(links, { ...patch, expectedValue: undefined }, additions); + next.links = result.next; + currentValue = patch.index == null ? links : links[patch.index]?.label; + afterValue = patch.index == null ? next.links : next.links[patch.index]?.label; + revertPatches = patch.mode === "update" && patch.index != null + ? directInverse(patch, links[patch.index]?.label ?? "", result.next[patch.index]?.label ?? "") + : result.revertPatches; + } else { + currentValue = (user as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + if (patch.field === "avatarPath" && patch.value && !getMedia(String(patch.value))) throw new Error("Select media that exists in the mounted library."); + (next as unknown as Record)[patch.field] = patch.value === "" && patch.definition.nullable ? null : patch.value; + afterValue = (next as unknown as Record)[patch.field]; + revertPatches = directInverse(patch, currentValue, afterValue); } - if (resource === "post") { - if (!id) throw new Error("Post inline edit is missing a slug."); - const post = getPost(id); if (!post) throw new Error(`Process note '${id}' was not found.`); - if (postLists.has(field)) savePost({ ...post, tags: editList(post.tags, mode, index, value) }); - else { if (!postText.has(field) || mode !== "update") throw new Error(`Post field '${field}' is not inline editable for '${mode}'.`); savePost({ ...post, [field]: value }); } - refresh(resource, id); return { resource, field, id, index, mode }; + saveUserProfile(next); + return audit({ actorEmail, requestId, patch, entityType: "user", entityKey: patch.id, before: user, after: next, revertPatches }); +} + +function applyCategoryPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const settings = getSiteSettings(); + const next = structuredClone(settings) as MutableSiteSettings; + const category = next.pieceCategories.find((entry) => entry.key === patch.id); + if (!category) throw new Error(`Category '${patch.id}' was not found.`); + let currentValue: unknown; + let afterValue: unknown; + let revertPatches: RevertPatch[]; + if (patch.field === "aliases") { + const result = applyList(category.aliases, patch, listValues(patch).map(String)); + currentValue = patch.index == null ? category.aliases : category.aliases[patch.index]; + category.aliases = result.next; + afterValue = patch.index == null ? category.aliases : category.aliases[patch.index]; + revertPatches = result.revertPatches; + } else { + currentValue = (category as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + (category as unknown as Record)[patch.field] = patch.field === "customIconSvg" ? sanitizeCategoryIconSvg(String(patch.value ?? "")) : patch.value; + afterValue = (category as unknown as Record)[patch.field]; + revertPatches = directInverse(patch, currentValue, afterValue); } - if (resource === "user") { - if (!id) throw new Error("Profile inline edit is missing an email."); - if (!userText.has(field) || mode !== "update") throw new Error(`Profile field '${field}' is not inline editable for '${mode}'.`); - const user = getUserByEmail(id); if (!user) throw new Error(`Profile '${id}' was not found.`); - saveUserProfile({ ...user, [field]: value }); refresh(resource, id); return { resource, field, id, mode }; + next.pieceCategories = normalizePieceCategories(next.pieceCategories); + saveSiteSettings(next as SiteSettings); + return audit({ actorEmail, requestId, patch, entityType: "category", entityKey: patch.id, before: settings, after: next, revertPatches }); +} + +function applyCommissionTypePatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const commissionType = getCommissionType(patch.id); + if (!commissionType) throw new Error(`Commission type '${patch.id}' was not found.`); + const next = { ...commissionType }; + let currentValue: unknown; + let afterValue: unknown; + let revertPatches: RevertPatch[]; + if (patch.field === "materialOptions") { + const result = applyList(commissionType.materialOptions, patch, listValues(patch).map(String)); + next.materialOptions = result.next; + currentValue = patch.index == null ? commissionType.materialOptions : commissionType.materialOptions[patch.index]; + afterValue = patch.index == null ? next.materialOptions : next.materialOptions[patch.index]; + revertPatches = result.revertPatches; + } else { + currentValue = (commissionType as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + (next as unknown as Record)[patch.field] = patch.value; + afterValue = patch.value; + revertPatches = directInverse(patch, currentValue, afterValue); } - throw new Error(`Resource '${resource}' is not inline editable.`); + saveCommissionType(next); + return audit({ actorEmail, requestId, patch, entityType: "commission-type", entityKey: patch.id, before: commissionType, after: next, revertPatches }); +} + +function applyProjectPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + const project = getProject(patch.id); + if (!project) throw new Error(`Project '${patch.id}' was not found.`); + const currentValue = (project as unknown as Record)[patch.field]; + assertExpected(patch, currentValue); + const nextValue = patch.value === "" && patch.definition.nullable ? null : patch.value; + updateProject(patch.id, { [patch.field]: nextValue }); + const after = getProject(patch.id); + const revertPatches = directInverse(patch, currentValue, nextValue); + return audit({ actorEmail, requestId, patch, entityType: "project", entityKey: patch.id, before: project, after, revertPatches }); +} + +function applyPatch(patch: ValidatedInlineEditPatch, actorEmail: string, requestId: string) { + if (patch.resource === "settings") return applySettingsPatch(patch, actorEmail, requestId); + if (patch.resource === "homeSection") return applyHomeSectionPatch(patch, actorEmail, requestId); + if (patch.resource === "homeService") return applyHomeServicePatch(patch, actorEmail, requestId); + if (patch.resource === "page") return applyPagePatch(patch, actorEmail, requestId); + if (patch.resource === "piece") return applyPiecePatch(patch, actorEmail, requestId); + if (patch.resource === "post") return applyPostPatch(patch, actorEmail, requestId); + if (patch.resource === "user") return applyUserPatch(patch, actorEmail, requestId); + if (patch.resource === "category") return applyCategoryPatch(patch, actorEmail, requestId); + if (patch.resource === "commissionType") return applyCommissionTypePatch(patch, actorEmail, requestId); + return applyProjectPatch(patch, actorEmail, requestId); +} + +function refresh(resource: string, id: string) { + revalidatePath("/", "layout"); + for (const route of ["/", "/portfolio", "/shop", "/process", "/about", "/contact", "/commissions", "/studio"]) revalidatePath(route); + if (resource === "page" && id) revalidatePath(id === "home" ? "/" : `/${id}`); + if (resource === "piece" && id) revalidatePath(`/portfolio/${id}`); + if (resource === "post" && id) revalidatePath(`/process/${id}`); + if (resource === "project" && id) revalidatePath(`/requests/${id}`); } export async function POST(request: Request) { try { - await requireAdmin(); - const body = await request.json().catch(() => null) as { patches?: InlinePatch[] } | InlinePatch | null; - const patches = Array.isArray((body as { patches?: InlinePatch[] } | null)?.patches) ? (body as { patches: InlinePatch[] }).patches : body ? [body as InlinePatch] : []; - if (patches.length === 0) return responseError("No inline edit patches were provided."); - if (patches.length > 80) return responseError("Too many inline edit patches in one request."); - return NextResponse.json({ ok: true, applied: patches.map(applyPatch) }); + assertTrustedMutationOrigin(request); + const admin = await getCurrentUser(); + if (!admin || admin.role !== "admin") return responseError("Admin authentication is required.", 401); + const body = await request.json().catch(() => null) as { patches?: InlineEditPatchInput[] } | InlineEditPatchInput | null; + const inputs = Array.isArray((body as { patches?: InlineEditPatchInput[] } | null)?.patches) + ? (body as { patches: InlineEditPatchInput[] }).patches + : body ? [body as InlineEditPatchInput] : []; + if (inputs.length === 0) return responseError("No inline edit patches were provided."); + if (inputs.length > 80) return responseError("Too many inline edit patches in one request."); + + const errors: Array<{ index: number; resource: string; id: string; field: string; message: string }> = []; + const patches = inputs.flatMap((input, index) => { + try { return [validateInlineEditPatch(input)]; } + catch (error) { + errors.push({ index, resource: String(input.resource ?? ""), id: String(input.id ?? ""), field: String(input.field ?? ""), message: error instanceof Error ? error.message : "Patch validation failed." }); + return []; + } + }); + if (errors.length) return responseError("Inline edit validation failed.", 400, errors); + const identities = patches.map(patchIdentity); + if (new Set(identities).size !== identities.length) return responseError("A field may be patched only once per atomic request."); + + const requestId = randomUUID(); + const applied = withDatabaseTransaction(() => patches.map((patch) => { + try { + return applyPatch(patch, admin.email, requestId); + } catch (error) { + if (error instanceof InlineEditError) throw error; + throw new InlineEditError( + error instanceof Error ? error.message : "Inline edit field could not be applied.", + 400, + { resource: patch.resource, id: patch.id, field: patch.field } + ); + } + })); + for (const result of applied) refresh(result.resource, result.id); + return NextResponse.json({ ok: true, requestId, applied, revertPatches: applied.flatMap((result) => result.revertPatches).reverse() }); } catch (error) { + if (error instanceof UntrustedMutationOriginError) return responseError(error.message, error.status); + if (error instanceof InlineEditError) return responseError(error.message, error.status, error.details); return responseError(error instanceof Error ? error.message : "Inline edit save failed.", 500); } } diff --git a/site/app/api/visual-audit/inventory/route.ts b/site/app/api/visual-audit/inventory/route.ts new file mode 100644 index 0000000..fb467a1 --- /dev/null +++ b/site/app/api/visual-audit/inventory/route.ts @@ -0,0 +1,214 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { getCurrentUser } from "@/lib/auth"; +import { + countMedia, + listMedia, + listNotifications, + listOrders, + listPages, + listPieceMediaLinks, + listPieces, + listPosts, + listProjects, + listReviews, + listUsers +} from "@/lib/db"; +import { visualAuditTokenValid } from "@/lib/visual-audit"; +import { buildPublicMediaEvidence, parseMediaProvenance } from "@/lib/visual-audit-media-evidence"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const STUDIO_PANELS = [ + "overview", + "settings", + "pages", + "pieces", + "categories", + "custom", + "people", + "process", + "media", + "projects", + "orders", + "reviews", + "notifications" +] as const; + +const STATIC_ROUTES = [ + "/", + "/about", + "/contact", + "/portfolio", + "/shop", + "/shop/cart", + "/process", + "/commissions", + "/commissions/status", + "/search", + "/account/signup", + "/account/login", + "/account/forgot", + "/account/reset", + "/account/verify", + "/account/profile", + "/account/projects", + "/studio/login", + "/studio" +] as const; + +const LEGACY_ROUTES = [ + "/journal" +] as const; + +const INVENTORY_RECORD_LIMIT = Math.min( + 5_000, + Math.max(100, Number.parseInt(process.env.VISUAL_AUDIT_MAX_RECORDS ?? "5000", 10) || 5_000) +); + +export async function GET(request: NextRequest) { + if ( + !visualAuditTokenValid( + request.headers.get("x-woodsmith-audit-token") + ) + ) { + return NextResponse.json( + { error: "Not found." }, + { + status: 404, + headers: { "cache-control": "no-store" } + } + ); + } + + const user = await getCurrentUser(); + + if (!user || user.role !== "admin") { + return NextResponse.json( + { error: "Admin authentication is required." }, + { + status: 401, + headers: { "cache-control": "no-store" } + } + ); + } + + const pages = listPages(true); + const pieces = listPieces(true); + const posts = listPosts(true); + const projects = listProjects(true); + const orders = listOrders(); + const reviews = listReviews(); + const notifications = listNotifications(); + const users = listUsers(); + const mediaCount = countMedia({ includeUnreviewed: true }); + const media = listMedia({ includeUnreviewed: true }); + const pieceMediaLinks = pieces.flatMap((piece) => listPieceMediaLinks(piece.slug)); + const mediaEvidence = buildPublicMediaEvidence({ + provenance: parseMediaProvenance(process.env.WOODSMITH_MEDIA_PROVENANCE), + databaseRecords: mediaCount, + pages, + pieces, + pieceMediaLinks, + posts, + users, + media + }); + const truncatedCollections: string[] = []; + + function bounded(name: string, records: T[]) { + if (records.length > INVENTORY_RECORD_LIMIT) truncatedCollections.push(name); + return records.slice(0, INVENTORY_RECORD_LIMIT); + } + + return NextResponse.json( + { + schemaVersion: 2, + generatedAt: new Date().toISOString(), + buildSha: process.env.WOODSMITH_BUILD_SHA ?? "unknown", + + staticRoutes: STATIC_ROUTES, + legacyRoutes: LEGACY_ROUTES, + studioPanels: STUDIO_PANELS, + + dynamicPatterns: [ + "/[slug]", + "/portfolio/[slug]", + "/process/[slug]", + "/requests/[reference]", + "/studio/request/[reference]", + "/account/verify/[token]" + ], + + pages: bounded("pages", pages).map((page) => ({ + slug: page.slug, + title: page.title, + status: page.status + })), + + pieces: bounded("pieces", pieces).map((piece) => ({ + slug: piece.slug, + title: piece.title, + publicationStatus: piece.publicationStatus, + status: piece.status + })), + + posts: bounded("posts", posts).map((post) => ({ + slug: post.slug, + title: post.title, + publicationStatus: post.publicationStatus + })), + + projects: bounded("projects", projects).map((project) => ({ + reference: project.reference, + status: project.status, + stage: project.stage + })), + + orders: bounded("orders", orders).map((order) => ({ + orderNumber: order.orderNumber, + status: order.status, + paymentStatus: order.paymentStatus + })), + + reviews: bounded("reviews", reviews).map((review) => ({ + id: review.id, + pieceSlug: review.pieceSlug, + status: review.status + })), + + notifications: bounded("notifications", notifications).map((notification) => ({ + id: notification.id, + status: notification.status + })), + + counts: { + pages: pages.length, + pieces: pieces.length, + posts: posts.length, + projects: projects.length, + orders: orders.length, + reviews: reviews.length, + notifications: notifications.length, + users: users.length, + media: mediaCount + }, + + mediaEvidence, + + limits: { + recordsPerCollection: INVENTORY_RECORD_LIMIT, + truncatedCollections + } + }, + { + headers: { + "cache-control": "no-store, no-cache, must-revalidate", + pragma: "no-cache", + "x-content-type-options": "nosniff" + } + } + ); +} diff --git a/site/app/commissions/page.tsx b/site/app/commissions/page.tsx index 6227ec4..199f9ad 100644 --- a/site/app/commissions/page.tsx +++ b/site/app/commissions/page.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { connection } from "next/server"; import { ContactRequestForm } from "@/components/forms"; import { PageIntro, PageSection, Shell } from "@/components/site-chrome"; -import { getBandwidthSnapshot, getPage, listCommissionTypes } from "@/lib/db"; +import { getBandwidthSnapshot, getPage, listCommissionTypes } from "@/lib/db"; +import { getCurrentUser } from "@/lib/auth"; export const metadata: Metadata = { title: "Custom Work", @@ -14,7 +15,8 @@ export const metadata: Metadata = { export default async function CommissionsPage() { await connection(); const page = getPage("commissions"); - const bandwidth = getBandwidthSnapshot(); + const bandwidth = getBandwidthSnapshot(); + const user = await getCurrentUser(); return ( @@ -23,10 +25,13 @@ export default async function CommissionsPage() { {page?.body ?

{page.body}

: null}

Already have a reference? Look up your project status here.

+ bandwidthLeadTimeDays={bandwidth.leadTimeDays} + commissionTypes={listCommissionTypes()} + defaultEmail={user?.email} + defaultName={user?.displayName} + queueCount={bandwidth.activeProjects} + signedIn={Boolean(user && (user.role !== "customer" || user.emailVerified))} + />
); diff --git a/site/app/commissions/status/page.tsx b/site/app/commissions/status/page.tsx index 9bf8a7e..38943ff 100644 --- a/site/app/commissions/status/page.tsx +++ b/site/app/commissions/status/page.tsx @@ -1,64 +1,22 @@ -import Link from "next/link"; -import { ProjectReplyForm } from "@/components/forms"; -import { PageIntro, PageSection, Shell } from "@/components/site-chrome"; -import { getProject, listProjectUpdates } from "@/lib/db"; -import { formatDateTime, formatLeadTime, toMediaUrl } from "@/lib/format"; - -export default async function CommissionStatusPage({ searchParams }: { searchParams: Promise<{ reference?: string; email?: string }> }) { - const { reference = "", email = "" } = await searchParams; - const project = reference ? getProject(reference) : null; - const matches = project && email && project.guestEmail.toLowerCase() === email.toLowerCase(); - const updates = matches && project ? listProjectUpdates(project.reference) : []; - const aiPreviewPath = project && typeof project.options.aiPreviewPath === "string" && project.options.aiPreviewPath ? project.options.aiPreviewPath : null; - - return ( - - - -
-
- - -
- -
-
- - {reference && !matches ? ( - -
-

Project not found. The reference or email address did not match any project on file. Check for typos or try the email used during your original request.

-
-
- ) : null} - - {matches && project ? ( - -
-
-

{project.reference}

-

{project.commissionTypeSlug || project.pieceSlug || "Custom project"}

-

Status: {project.status} · Stage: {project.stage} · Estimated lead time {formatLeadTime(project.leadTimeDays)}

-
-
-
-
-

Project brief

-

{project.brief}

- {project.includeVisualization && aiPreviewPath ? {`${project.reference} : null} - {project.publicNotes ?

{project.publicNotes}

: null} -
-
-

Timeline

-
    - {updates.map((update) =>
  1. {update.authorRole}{formatDateTime(update.createdAt)}

    {update.body}

  2. )} -
-
-
- -

Open the dedicated request page.

-
- ) : null} -
- ); -} +import { lookupProjectStatusAction } from "@/lib/actions"; +import { PageIntro, PageSection, Shell } from "@/components/site-chrome"; + +export default async function CommissionStatusPage({ searchParams }: { searchParams: Promise<{ reference?: string; error?: string }> }) { + const { reference = "", error = "" } = await searchParams; + return ( + + + + {error ?

The reference and email did not match a project. Check both entries and try again.

: null} +
+
+ + +
+ +
+

Access expires after 30 days on this browser. Repeat this lookup whenever access needs to be renewed.

+
+
+ ); +} diff --git a/site/app/contact/page.tsx b/site/app/contact/page.tsx index 2a1ebcd..56ca2e4 100644 --- a/site/app/contact/page.tsx +++ b/site/app/contact/page.tsx @@ -1,8 +1,10 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { ContactRequestForm } from "@/components/forms"; -import { PageIntro, PageSection, Shell } from "@/components/site-chrome"; -import { getBandwidthSnapshot, getSiteSettings, listCommissionTypes } from "@/lib/db"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { connection } from "next/server"; +import { ContactRequestForm } from "@/components/forms"; +import { PageIntro, PageSection, Shell } from "@/components/site-chrome"; +import { pieceAllowsInquiry } from "@/lib/catalog"; +import { getBandwidthSnapshot, getPiece, getSiteSettings, listCommissionTypes } from "@/lib/db"; export const metadata: Metadata = { title: "Contact Beaman Woodworks", @@ -10,9 +12,13 @@ export const metadata: Metadata = { openGraph: { title: "Contact | Beaman Woodworks", description: "Contact the Beaman Woodworks woodshop." } }; -export default function ContactPage() { - const site = getSiteSettings(); - const bandwidth = getBandwidthSnapshot(); +export default async function ContactPage({ searchParams }: { searchParams: Promise<{ piece?: string; error?: string }> }) { + await connection(); + const { piece: pieceSlug = "", error = "" } = await searchParams; + const site = getSiteSettings(); + const bandwidth = getBandwidthSnapshot(); + const requestedPiece = pieceSlug ? getPiece(pieceSlug) : null; + const selectedPiece = requestedPiece && pieceAllowsInquiry(requestedPiece) ? requestedPiece : undefined; return ( @@ -22,7 +28,7 @@ export default function ContactPage() { title="Ask about a custom piece" copy="Use the intake form below to send a message directly to the woodshop. Every inquiry is read personally — replies come from a real human, usually within a business day." /> -

+

Prefer email? Reach us at {site.builderEmail}. You can also {" "} open the full custom-work form @@ -30,12 +36,15 @@ export default function ContactPage() { or {" "} check the status of an existing project. -

- +

+ {error ?

{error}

: null} + {pieceSlug && !selectedPiece ?

That piece is not currently accepting inquiries. You can still send a general custom-work request below.

: null} +
); diff --git a/site/app/globals.css b/site/app/globals.css index ad11d35..3009467 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -786,8 +786,10 @@ h3 { grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); } -.visualizer-stage-3d { - position: relative; +.visualizer-stage-3d { + position: relative; + min-width: 0; + width: 100%; min-height: 28rem; overflow: hidden; border: 1px solid var(--line); @@ -796,8 +798,133 @@ h3 { linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 36%), radial-gradient(circle at 50% 88%, rgba(0, 0, 0, 0.28), transparent 44%), var(--bg-card); - perspective: 58rem; -} + perspective: 58rem; +} + +.commission-scene-shell { + display: grid; + grid-template-rows: auto minmax(22rem, 1fr) auto auto; + min-height: 28rem; + min-width: 0; + width: 100%; + max-width: 100%; + height: 100%; + outline: none; +} + +.commission-scene-shell:focus-visible { + box-shadow: inset 0 0 0 2px var(--focus, var(--accent)); +} + +.commission-scene-toolbar { + position: relative; + z-index: 2; + display: flex; + min-width: 0; + max-width: 100%; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.55rem; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg-elevated) 92%, transparent); +} + +.commission-scene-toolbar button { + min-height: 2rem; + padding: 0.32rem 0.58rem; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--bg-soft); + color: var(--muted); + font-size: 0.76rem; + line-height: 1; +} + +.commission-scene-toolbar button:hover, +.commission-scene-toolbar button.is-active, +.commission-scene-toolbar button[aria-pressed="true"] { + border-color: color-mix(in srgb, var(--accent) 56%, var(--line)); + background: color-mix(in srgb, var(--accent) 13%, var(--bg-elevated)); + color: var(--text); +} + +.commission-scene-canvas { + min-height: 22rem; + min-width: 0; + width: 100%; + max-width: 100%; + background: #0d0c0a; +} + +.commission-scene-canvas canvas { + display: block; + min-height: 22rem; + touch-action: none; +} + +.scene-dimension-label { + display: block; + width: max-content; + max-width: 14rem; + padding: 0.3rem 0.5rem; + border: 1px solid rgba(242, 216, 171, 0.38); + border-radius: var(--radius-pill); + background: rgba(13, 12, 10, 0.82); + color: #f2d8ab; + font-family: var(--font-display); + font-size: 0.76rem; + line-height: 1; + white-space: nowrap; +} + +.commission-scene-help, +.commission-scene-equivalent, +.commission-scene-export-status { + margin: 0; + padding-inline: 0.7rem; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.commission-scene-help { + padding-top: 0.55rem; +} + +.commission-scene-equivalent { + color: var(--text); +} + +.commission-scene-export-status { + min-height: 1.5rem; + padding-bottom: 0.55rem; +} + +.visualizer-static-fallback, +.visualizer-scene-loading { + display: grid; + place-items: center; + align-content: center; + gap: var(--space-3); + min-height: 28rem; + padding: var(--space-4); + text-align: center; +} + +.visualizer-static-fallback > div, +.visualizer-static-fallback svg { + display: block; + width: 100%; + max-height: 25rem; +} + +.visualizer-static-fallback p, +.visualizer-type-description { + margin: 0; + color: var(--muted); + font-size: 0.84rem; +} .visualizer-floor-grid { position: absolute; @@ -911,7 +1038,7 @@ h3 { gap: var(--space-3); } -.compact-estimate { +.compact-estimate { border-top: 1px solid var(--line); padding-top: var(--space-3); } @@ -1042,35 +1169,7 @@ img.cleanup-subject-isolate { gap: var(--space-4); } -.piece-media-carousel { - display: grid; - grid-auto-flow: column; - grid-auto-columns: minmax(min(22rem, 80%), 1fr); - gap: var(--space-4); - overflow-x: auto; - overflow-y: hidden; - scroll-snap-type: x mandatory; - scroll-padding: var(--space-2); - padding-bottom: var(--space-3); - scrollbar-gutter: stable; -} - -.piece-media-carousel > .media-card { - scroll-snap-align: start; - min-height: 22rem; - aspect-ratio: 4 / 3; - box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.1)); - border-radius: var(--radius-md, 0.75rem); - overflow: hidden; -} - -.piece-media-carousel > .media-card .media-card-image { - width: 100%; - height: 100%; - object-fit: cover; -} - -.lightbox-shell { +.lightbox-shell { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.92); @@ -1853,35 +1952,89 @@ img.cleanup-subject-isolate { height: 0.42rem; } -.portfolio-filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.85rem; - margin: 2rem 0; -} - -.portfolio-filter-pill { - display: inline-flex; - align-items: center; - gap: 0.8rem; - min-height: 3rem; - padding: 0.75rem 1rem; - border: 1px solid var(--line); - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); - color: var(--muted); -} +.portfolio-filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; + margin: 1.15rem 0 1.4rem; +} + +@media (max-width: 980px) { + .visualizer-3d-grid { + grid-template-columns: 1fr; + } + + .visualizer-stage-3d, + .commission-scene-shell, + .visualizer-static-fallback, + .visualizer-scene-loading { + min-height: 25rem; + } +} + +@media (max-width: 640px) { + .custom-visualizer-3d { + gap: var(--space-3); + padding: 0.55rem; + } + + .visualizer-panel-heading { + grid-template-columns: 1fr; + } + + .visualizer-panel-heading > span { + width: fit-content; + } + + .commission-scene-toolbar { + flex-wrap: nowrap; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; + } + + .commission-scene-toolbar button { + flex: 0 0 auto; + } + + .visualizer-stage-3d, + .commission-scene-shell, + .visualizer-static-fallback, + .visualizer-scene-loading { + min-height: 22rem; + } + + .commission-scene-canvas, + .commission-scene-canvas canvas { + min-height: 18rem; + } +} + +.portfolio-filter-pill { + display: inline-flex; + align-items: center; + gap: 0.52rem; + min-height: 2.75rem; + padding: 0.42rem 0.62rem; + border: 1px solid var(--line); + border-radius: 0.72rem; + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); + color: var(--muted); + font-size: 0.86rem; + line-height: 1; + text-decoration: none; +} .portfolio-filter-pill strong { display: inline-grid; place-items: center; - min-width: 1.6rem; - height: 1.6rem; - padding-inline: 0.35rem; + min-width: 1.35rem; + height: 1.35rem; + padding-inline: 0.28rem; border-radius: var(--radius-pill); background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); - font-size: 0.78rem; + font-size: 0.7rem; line-height: 1; } @@ -1891,18 +2044,182 @@ img.cleanup-subject-isolate { background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); } -.portfolio-filter-mark { +.portfolio-filter-mark { display: inline-grid; place-items: center; - width: 1.9rem; - height: 1.9rem; - border-radius: 50%; + width: 1.55rem; + height: 1.55rem; + border-radius: 0.42rem; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); - font-size: 0.82rem; -} - -.shop-detail-list { +} + +.category-icon-svg { + display: block; + width: 1.12rem; + height: 1.12rem; + flex: 0 0 auto; + overflow: visible; +} + +.category-icon-custom > svg { + display: block; + width: 100%; + height: 100%; +} + +@media (max-width: 640px) { + .portfolio-filter-row { + flex-wrap: nowrap; + width: 100%; + min-width: 0; + max-width: 100%; + margin-inline: 0; + padding: 0 0 0.4rem; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; + scroll-snap-type: inline proximity; + } + + .portfolio-filter-pill { + flex: 0 0 auto; + scroll-snap-align: start; + } +} + +.category-editor-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); + align-items: start; +} + +.category-editor { + display: grid; + gap: 0.85rem; + padding: 1rem; +} + +.category-editor-head { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.category-editor-head h3, +.category-editor-head p { + margin: 0; +} + +.category-editor-preview { + display: grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); + border-radius: 0.72rem; + background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); + color: var(--accent); +} + +.category-editor-preview .category-icon-svg { + width: 1.65rem; + height: 1.65rem; +} + +.category-icon-fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.category-icon-fieldset legend { + margin-bottom: 0.45rem; + font-size: 0.8rem; + color: var(--muted); +} + +.category-icon-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5.25rem, 1fr)); + gap: 0.38rem; +} + +.category-icon-choice { + position: relative; + display: grid; + grid-template-columns: 1.2rem 1fr; + align-items: center; + gap: 0.4rem; + min-height: 2.75rem; + padding: 0.38rem 0.48rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--bg-elevated); + color: var(--muted); + cursor: pointer; +} + +.category-icon-choice:has(input:focus-visible) { + outline: 2px solid var(--focus, var(--accent)); + outline-offset: 2px; +} + +.category-icon-choice.is-selected { + border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); + color: var(--text); +} + +.category-icon-choice input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.category-icon-choice span { + font-size: 0.72rem; + line-height: 1.15; +} + +.category-custom-icon, +.category-delete-panel { + padding: 0.65rem 0.72rem; + border: 1px solid var(--line); + border-radius: 0.72rem; +} + +.category-custom-icon > summary, +.category-delete-panel > summary { + cursor: pointer; + font-size: 0.82rem; +} + +.category-custom-icon-controls, +.category-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; + margin-top: 0.65rem; +} + +.category-icon-upload input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.form-status.success { + color: var(--success, #5f8b68); +} + +.form-status.error { + color: var(--danger, #b66a5d); +} + +.shop-detail-list { display: grid; gap: 0.55rem; margin: 0; @@ -2245,6 +2562,262 @@ input[type="range"]::-moz-range-thumb { scroll-margin-top: 6rem; } -.studio-loading-shell { - min-height: 40vh; -} +.studio-loading-shell { + min-height: 40vh; +} + +/* --- Resumable commission workflow --- */ + +.commission-workflow { + gap: clamp(1rem, 2vw, 1.5rem); + width: 100%; + min-width: 0; + max-width: 100%; +} + +.form-honeypot { + height: 1px !important; + inset-inline-start: -10000px !important; + overflow: hidden !important; + position: absolute !important; + width: 1px !important; +} + +.form-honeypot[hidden] { + display: none !important; +} + +.commission-workflow > section { + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 22rem; + padding: clamp(1rem, 2.5vw, 1.75rem); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--panel) 92%, transparent); +} + +.commission-workflow > section[hidden] { + display: none; +} + +.commission-progress { + position: sticky; + top: calc(var(--header-height, 4.5rem) + 0.5rem); + z-index: 8; + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + gap: 0.65rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg) 94%, transparent); + backdrop-filter: blur(18px); +} + +.commission-progress > div:nth-child(2) { + display: flex; + justify-content: space-between; + gap: 1rem; + color: var(--muted); + font-size: 0.82rem; +} + +.commission-progress-meter { + height: 0.35rem; + overflow: hidden; + border-radius: var(--radius-pill); + background: var(--line); +} + +.commission-progress-meter span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--button)); + transition: width 180ms ease; +} + +.commission-progress ol { + display: grid; + grid-template-columns: repeat(10, minmax(4.5rem, 1fr)); + gap: 0.35rem; + width: 100%; + min-width: 0; + max-width: 100%; + margin: 0; + padding: 0 0 0.2rem; + overflow-x: auto; + list-style: none; + scrollbar-width: thin; +} + +.commission-progress li button { + width: 100%; + min-height: 3.1rem; + padding: 0.35rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + font: inherit; + font-size: 0.72rem; +} + +.commission-progress li button span { + display: block; + font-family: var(--font-display); + font-size: 1rem; +} + +.commission-progress li button[aria-current="step"] { + border-color: var(--accent); + background: var(--panel); + color: var(--ink); +} + +.choice-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.choice-card { + position: relative; + display: block; + min-height: 9rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + cursor: pointer; +} + +.choice-card input { + position: absolute; + inset: 0.8rem 0.8rem auto auto; + width: 1.15rem; + height: 1.15rem; +} + +.choice-card span { + display: grid; + gap: 0.5rem; + padding-right: 1.5rem; +} + +.choice-card small { + color: var(--muted); + line-height: 1.45; +} + +.choice-card:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); +} + +.commission-upload-previews { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.65rem; +} + +.commission-upload-previews figure { + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--panel); +} + +.commission-upload-previews img { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: var(--radius-sm); + object-fit: cover; +} + +.commission-upload-previews figcaption { + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.commission-upload-previews button { + min-height: 2rem; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: transparent; + color: var(--ink); +} + +.commission-review-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.65rem; +} + +.commission-review-list div { + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); +} + +.commission-review-list dt { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.commission-review-list dd { + margin: 0.2rem 0 0; +} + +.commission-step-actions { + display: flex; + justify-content: space-between; + gap: 0.75rem; +} + +@media (max-width: 720px) { + .commission-workflow > section { + min-height: 0; + } + + .commission-progress { + position: relative; + top: auto; + margin-inline: -0.25rem; + } + + .commission-progress > div:nth-child(2) { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } + + .choice-card-grid { + grid-template-columns: 1fr; + } + + .choice-card { + min-height: 0; + } + + .commission-step-actions { + position: sticky; + bottom: 0.5rem; + z-index: 7; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--bg) 94%, transparent); + backdrop-filter: blur(16px); + } +} diff --git a/site/app/layout.tsx b/site/app/layout.tsx index 0264258..da6dec8 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -83,14 +83,15 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac const theme = cookieStore.get("beaman-theme")?.value === "light" ? "light" : "dark"; return ( - +
+ Skip to main content -
{children}
+
{children}
diff --git a/site/app/media/[...slug]/route.ts b/site/app/media/[...slug]/route.ts index 4e240ed..1a2a39f 100644 --- a/site/app/media/[...slug]/route.ts +++ b/site/app/media/[...slug]/route.ts @@ -1,6 +1,12 @@ -import { createReadStream, existsSync, statSync } from "node:fs"; -import { NextResponse } from "next/server"; -import { detectMediaKind, resolveMediaPath } from "@/lib/media"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { Readable } from "node:stream"; +import { NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { commissionOwnerKey, userCanAccessProject } from "@/lib/commission-security"; +import { commissionRenderAssetOwnedBy, getMediaAccessAssociations, getProject } from "@/lib/db"; +import { detectMediaKind, resolveMediaPath } from "@/lib/media"; +import { classifyMediaAccess, mediaAccessAllowed, mediaCacheHeaders, normalizeMediaRequestPath } from "@/lib/media-access"; +import { mediaEntityTag, mediaLastModified, mediaRequestIsFresh } from "@/lib/media-http"; const MIME_TYPES: Record = { ".jpg": "image/jpeg", @@ -15,18 +21,49 @@ const MIME_TYPES: Record = { ".webm": "video/webm" }; -function notFound() { - return new NextResponse("Not found", { status: 404, headers: { "Cache-Control": "no-store" } }); -} - -export async function GET(_: Request, { params }: { params: Promise<{ slug: string[] }> }) { - const { slug } = await params; - const relativePath = slug.join("/"); - if (!relativePath || relativePath.includes("..")) { - return notFound(); - } - - let absolutePath: string; +function notFound() { + return new NextResponse("Not found", { + status: 404, + headers: { + ...mediaCacheHeaders("denied"), + "Content-Type": "text/plain; charset=utf-8", + "X-Content-Type-Options": "nosniff" + } + }); +} + +export async function GET(request: Request, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params; + const relativePath = normalizeMediaRequestPath(slug.join("/")); + if (!relativePath) return notFound(); + + const access = classifyMediaAccess( + relativePath, + getMediaAccessAssociations(relativePath) + ); + + if (access.kind !== "public-library") { + if (access.kind === "invalid" || access.kind === "transient") return notFound(); + + const user = await getCurrentUser(); + const admin = user?.role === "admin"; + let projectAuthorized = false; + let previewOwner = false; + + if (access.kind === "private-project") { + const project = getProject(access.projectReference); + projectAuthorized = Boolean(project && await userCanAccessProject(project, user)); + } else if (access.kind === "private-preview" && !admin) { + const ownerKey = await commissionOwnerKey(user?.email); + previewOwner = commissionRenderAssetOwnedBy(relativePath, ownerKey); + } + + if (!mediaAccessAllowed(access, { admin, projectAuthorized, previewOwner })) { + return notFound(); + } + } + + let absolutePath: string; try { absolutePath = resolveMediaPath(relativePath); } catch { @@ -48,21 +85,26 @@ export async function GET(_: Request, { params }: { params: Promise<{ slug: stri return notFound(); } - const extension = absolutePath.slice(absolutePath.lastIndexOf(".")).toLowerCase(); - const kind = detectMediaKind(relativePath); - - const stream = createReadStream(absolutePath); - stream.on("error", () => { - stream.destroy(); - }); - - return new NextResponse(stream as never, { - headers: { - "Content-Type": - MIME_TYPES[extension] || - (kind === "video" ? "application/octet-stream" : "image/jpeg"), - "Cache-Control": "no-store", - "CDN-Cache-Control": "no-store" - } - }); -} + const extension = absolutePath.slice(absolutePath.lastIndexOf(".")).toLowerCase(); + const kind = detectMediaKind(relativePath); + const publicMedia = access.kind === "public-library"; + const responseHeaders = { + "Content-Type": MIME_TYPES[extension] || (kind === "video" ? "application/octet-stream" : "image/jpeg"), + "Content-Length": String(stat.size), + ...mediaCacheHeaders(publicMedia ? "public" : "private"), + ...(publicMedia ? { + ETag: mediaEntityTag(stat), + "Last-Modified": mediaLastModified(stat) + } : {}), + "X-Content-Type-Options": "nosniff" + }; + + if (publicMedia && mediaRequestIsFresh(request.headers, stat)) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + + const stream = Readable.toWeb(createReadStream(absolutePath)) as ReadableStream; + return new NextResponse(stream, { + headers: responseHeaders + }); +} diff --git a/site/app/page.tsx b/site/app/page.tsx index f7f195a..e63d6ee 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import Link from "next/link"; import { connection } from "next/server"; -import { PageIntro, PageSection, PieceCard, PostCard, SectionHeading, Shell } from "@/components/site-chrome"; +import { PageIntro, PageSection, PieceCard, SectionHeading, Shell } from "@/components/site-chrome"; import { inlineEditAttrs } from "@/components/inline-editable"; -import { getPage, getSiteSettings, listPieces, listPosts } from "@/lib/db"; +import { getPage, getSiteSettings, listPieces } from "@/lib/db"; import { getPortfolioCategories } from "@/lib/catalog"; export const metadata: Metadata = { @@ -21,7 +21,7 @@ export default async function HomePage() { const pieces = listPieces().filter((piece) => featuredSlugs.has(piece.slug)).sort((left, right) => left.featuredRank - right.featuredRank); const hero = site.homeSections.find((section) => section.key === "hero") as Record | undefined; const services = site.homeSections.find((section) => section.key === "services") as Record | undefined; - const processNotes = listPosts().slice(0, 2); + const homeServices = [...site.homeServices].filter((service) => service.visible).sort((left, right) => left.order - right.order); const heroCopy = home?.intro || String(hero?.copy ?? ""); const servicesCopy = home?.body || String(services?.copy ?? "The public site handles portfolio, shop, process notes, and buyer communication while the private dashboard manages content, media, project stages, inventory, invoices, and shipping workflows."); @@ -54,7 +54,7 @@ export default async function HomePage() { title="Current collection and established build patterns" copy="Public piece pages stay selective and accurate. Available work can be reserved from the shop, while custom work starts with a direct contact request." /> -
{pieces.map((piece) => )}
+
{pieces.map((piece, index) => )}
@@ -69,23 +69,31 @@ export default async function HomePage() { }} />
-

Portfolio

Finished work with verified photography, materials, and dimensional notes.

-

Shop

Available pieces with asking prices, tax at checkout, and pickup, delivery, or shipping review.

-

Custom work

Contact-first intake, lead-time guidance, and project tracking once a request is accepted.

-

Private dashboard

Content, media, inventory, reviews, orders, and project updates managed from the browser.

+ {homeServices.map((service, index) => ( + + +

{service.title}

+

{service.body}

+ {service.linkLabel} + + ))}
- + -
{processNotes.map((post) => )}
+
    +
  1. 01

    Send the brief

    Describe the intended use, room, approximate size, material preferences, and timing.

  2. +
  3. 02

    Review the scope

    The woodshop confirms fit, current capacity, likely fulfillment method, and what still needs measuring.

  4. +
  5. 03

    Approve the project

    A detailed estimate and project reference are prepared before the build enters the active queue.

  6. +
- Read more process notes Ask about a custom piece + Check a project
diff --git a/site/app/portfolio/[slug]/page.tsx b/site/app/portfolio/[slug]/page.tsx index e104ec7..5852712 100644 --- a/site/app/portfolio/[slug]/page.tsx +++ b/site/app/portfolio/[slug]/page.tsx @@ -1,14 +1,14 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { connection } from "next/server"; import { notFound } from "next/navigation"; -import { MediaLightbox } from "@/components/lightbox"; +import { MediaCollection } from "@/components/media-collection"; import { ContactRequestForm, ReviewForm } from "@/components/forms"; import { inlineEditAttrs } from "@/components/inline-editable"; -import { getDisplayMediaPaths, getFulfillmentOptions } from "@/lib/catalog"; +import { getDisplayMediaPaths, getFulfillmentOptions, getPieceProcessMediaLinks, pieceAcceptsReviews, pieceAllowsInquiry, pieceDisplaysReviews } from "@/lib/catalog"; import { PageSection, ShareLinks, Shell } from "@/components/site-chrome"; -import { getBandwidthSnapshot, getMedia, getPiece, listCommissionTypes, listReviews } from "@/lib/db"; -import { addToCartAction } from "@/lib/actions"; -import { formatDate, formatDimensions, formatLeadTime, formatMoney, toMediaUrl } from "@/lib/format"; +import { getBandwidthSnapshot, getMedia, getPiece, listCommissionTypes, listPieceMediaLinks, listReviews } from "@/lib/db"; +import { formatDate, formatDimensions, formatLeadTime, toMediaUrl } from "@/lib/format"; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { const { slug } = await params; @@ -37,19 +37,52 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); const bandwidth = getBandwidthSnapshot(); - const reviews = listReviews(piece.slug).filter((review) => review.status === "published"); - const mediaItems = getDisplayMediaPaths(piece).map((path) => { - const media = getMedia(path); - return { - src: toMediaUrl(path), - alt: media?.altText || piece.title, - focalX: media?.focalX, - focalY: media?.focalY, - zoom: media?.zoom, - cleanupMode: typeof media?.metadata.cleanupMode === "string" ? media.metadata.cleanupMode : undefined - }; - }); - const fulfillment = getFulfillmentOptions(piece); + const showReviews = pieceDisplaysReviews(piece); + const acceptReviews = pieceAcceptsReviews(piece); + const allowInquiry = pieceAllowsInquiry(piece); + const reviews = showReviews ? listReviews(piece.slug).filter((review) => review.status === "published") : []; + const displayPaths = getDisplayMediaPaths(piece); + const displayLinkByPath = new Map(listPieceMediaLinks(piece.slug, { publicOnly: true, roles: ["hero", "gallery", "detail", "context"] }).map((link) => [link.relativePath, link])); + const mediaItems = displayPaths.map((path, index) => { + const media = getMedia(path); + const link = displayLinkByPath.get(path); + return { + id: link?.id ?? `piece:${piece.slug}:${path}`, + src: toMediaUrl(path), + alt: media?.altText || piece.title, + kind: media?.kind === "video" ? "video" as const : "image" as const, + focalX: media?.focalX, + focalY: media?.focalY, + zoom: media?.zoom, + cleanupMode: typeof media?.metadata.cleanupMode === "string" ? media.metadata.cleanupMode : undefined, + caption: link?.caption, + title: link?.title, + role: link?.role, + order: link?.displayOrder ?? index + }; + }); + const fulfillment = getFulfillmentOptions(piece); + const processLinks = getPieceProcessMediaLinks(piece); + const processMediaItems = processLinks.flatMap((link) => { + const media = getMedia(link.relativePath); + if (!media) return []; + return [{ + id: link.id, + src: toMediaUrl(link.relativePath), + alt: link.altOverride || media.altText || link.caption || piece.title, + kind: media.kind === "video" ? "video" as const : "image" as const, + focalX: media.focalX, + focalY: media.focalY, + zoom: media.zoom, + cleanupMode: typeof media.metadata.cleanupMode === "string" ? media.metadata.cleanupMode : undefined, + caption: link.caption, + title: link.title, + stage: link.stage, + occurredAt: link.occurredAt, + role: link.role, + order: link.displayOrder + }]; + }); return ( @@ -73,43 +106,44 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st {piece.status === "inventory" ? (
- Asking price - {piece.priceCents == null ? "Available by request" : formatMoney(piece.priceCents)} + Availability + {piece.availabilityLabel} {Math.max(0, piece.inventoryCount)} available
-
- - - -
+ View shop details
) : null}
{mediaItems.length > 0 ? ( - + ) : ( -
Archival media is being verified for this piece before additional images are shown publicly.
+
Archival media is being verified for this piece before additional images are shown publicly.
)}
- - - + + + {processLinks.length > 0 ? +

Build record

{piece.processSectionTitle || "Build record"}

{piece.processSectionIntro ?

{piece.processSectionIntro}

: null}
+ {processMediaItems.length > 0 ? : null} +
: null} + + {allowInquiry ?

{piece.status === "inventory" ? "Ask about this piece" : "Use this piece as the starting point for custom work"}

{piece.status === "inventory" ? "Use this contact form if you want to reserve the current build, confirm delivery options, or ask for a related variation. Checkout details stay in the shop." : "Custom work begins with a direct note about the room, intended use, timing, and material preferences. The private project workflow takes over after the initial review."}

- -
- - -

Reviews

+ queueCount={bandwidth.activeProjects} + /> +
: null} + + {showReviews ? +

Reviews

{reviews.length > 0 ? reviews.map((review) => (
@@ -119,8 +153,8 @@ export default async function PiecePage({ params }: { params: Promise<{ slug: st
)) :

No approved reviews are published for this piece yet.

}
- -
+ {acceptReviews ? : null} + : null} ); } diff --git a/site/app/portfolio/page.tsx b/site/app/portfolio/page.tsx index 7da969b..26898a7 100644 --- a/site/app/portfolio/page.tsx +++ b/site/app/portfolio/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; import { connection } from "next/server"; -import { CategoryIcon, PageIntro, PageSection, PieceCard, Shell } from "@/components/site-chrome"; +import { CategoryIcon } from "@/components/category-icon"; +import { PageIntro, PageSection, PieceCard, Shell } from "@/components/site-chrome"; import { inlineEditAttrs } from "@/components/inline-editable"; import { getPiecePortfolioCategory, getPortfolioCategories } from "@/lib/catalog"; import { getPage, getSiteSettings, listPieces } from "@/lib/db"; @@ -17,7 +18,7 @@ export default async function PortfolioPage({ searchParams }: { searchParams: Pr const { category } = await searchParams; const page = getPage("portfolio"); const categories = getPortfolioCategories(getSiteSettings().pieceCategories); - const portfolioCategories = [{ key: "all", label: "All pieces", icon: "all" as const, aliases: [] }, ...categories]; + const portfolioCategories = [{ key: "all", label: "All pieces", icon: "all" as const, iconName: "all" as const, iconType: "builtin" as const, customIconSvg: null, aliases: [], sortOrder: -1, visible: true }, ...categories]; const selectedCategory = portfolioCategories.some((item) => item.key === category) ? String(category) : "all"; const allPieces = listPieces(); const pieces = allPieces.filter((piece) => selectedCategory === "all" || getPiecePortfolioCategory(piece, categories) === selectedCategory); @@ -52,14 +53,15 @@ export default async function PortfolioPage({ searchParams }: { searchParams: Pr href={href} key={item.key} > - + {item.label} {counts.get(item.key) ?? 0} ); })} -
{pieces.map((piece) => )}
+

Portfolio pieces

+
{pieces.map((piece, index) => )}
diff --git a/site/app/process/[slug]/page.tsx b/site/app/process/[slug]/page.tsx index 7afe7e0..f86afb6 100644 --- a/site/app/process/[slug]/page.tsx +++ b/site/app/process/[slug]/page.tsx @@ -1,7 +1,7 @@ import { marked } from "marked"; import { connection } from "next/server"; import { notFound } from "next/navigation"; -import { MediaLightbox } from "@/components/lightbox"; +import { MediaCollection } from "@/components/media-collection"; import { PageSection, Shell } from "@/components/site-chrome"; import { inlineEditAttrs } from "@/components/inline-editable"; import { getPost } from "@/lib/db"; @@ -21,7 +21,7 @@ export default async function ProcessPostPage({ params }: { params: Promise<{ sl

{post.publishedAt ? formatDate(post.publishedAt) : "Draft"}

{post.title}

{post.excerpt}

- {post.coverMediaPath ? : null} + {post.coverMediaPath ? : null} {post.sourceUrl ?

Source: {post.sourceLabel || post.sourceUrl}

: null}
diff --git a/site/app/process/page.tsx b/site/app/process/page.tsx index 4aba42e..daa1293 100644 --- a/site/app/process/page.tsx +++ b/site/app/process/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { connection } from "next/server"; -import { PageIntro, PageSection, PostCard, Shell } from "@/components/site-chrome"; +import { PageIntro, PageSection, PostCard, SectionHeading, Shell } from "@/components/site-chrome"; import { inlineEditAttrs } from "@/components/inline-editable"; import { getPage, listPosts } from "@/lib/db"; @@ -22,11 +22,12 @@ export default async function ProcessPage() { {page?.body ?

{page.body}

: null} +

Process notes

{notes.map((post) => )}
- {highlights.length > 0 ? ( - - + {highlights.length > 0 ? ( + +
{highlights.map((post) => )}
) : null} diff --git a/site/app/requests/[reference]/page.tsx b/site/app/requests/[reference]/page.tsx index dd90ea7..5e4419f 100644 --- a/site/app/requests/[reference]/page.tsx +++ b/site/app/requests/[reference]/page.tsx @@ -1,12 +1,15 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { ProjectReplyForm } from "@/components/forms"; -import { getCurrentUser } from "@/lib/auth"; +import { ProjectReplyForm } from "@/components/forms"; +import { CommissionDraftCleanup } from "@/components/commission-draft-cleanup"; +import { getCurrentUser } from "@/lib/auth"; +import { lookupProjectStatusAction } from "@/lib/actions"; +import { userCanAccessProject } from "@/lib/commission-security"; import { PageSection, Shell } from "@/components/site-chrome"; import { getProject, listProjectUpdates } from "@/lib/db"; import { formatDateTime, formatLeadTime, formatMoney, sanitizeHtml, toMediaUrl } from "@/lib/format"; -export default async function RequestPage({ params, searchParams }: { params: Promise<{ reference: string }>; searchParams: Promise<{ created?: string; updated?: string; error?: string; email?: string }> }) { +export default async function RequestPage({ params, searchParams }: { params: Promise<{ reference: string }>; searchParams: Promise<{ created?: string; updated?: string; error?: string }> }) { const { reference } = await params; const flags = await searchParams; const project = getProject(reference); @@ -15,13 +18,7 @@ export default async function RequestPage({ params, searchParams }: { params: Pr } const user = await getCurrentUser(); - const lookupEmail = (flags.email ?? "").trim().toLowerCase(); - const signedInEmail = user?.email?.toLowerCase() ?? ""; - const canView = Boolean( - user?.role === "admin" - || (signedInEmail && [project.userEmail, project.guestEmail].filter(Boolean).some((value) => String(value).toLowerCase() === signedInEmail)) - || (lookupEmail && lookupEmail === project.guestEmail.toLowerCase()) - ); + const canView = await userCanAccessProject(project, user); if (!canView) { return ( @@ -34,16 +31,16 @@ export default async function RequestPage({ params, searchParams }: { params: Pr

Enter the same email used during the custom work request or checkout to open this project tracker.

- {flags.created ?

The project was created successfully. Enter the buyer email used on the form to open the tracker.

: null} - {flags.error ?

The reference and email did not match a live project record.

: null} -
-