Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d95578c6a | ||
|
|
157e04ed24 | ||
|
|
27f658c1f7 | ||
|
|
05392ee0db | ||
|
|
2af79012e4 | ||
|
|
dc9ebbe51b | ||
|
|
b6981fbcb1 | ||
|
|
5a9ae58a2a | ||
|
|
52b2c09a9e | ||
|
|
18b36870d4 | ||
|
|
cac26286b1 | ||
|
|
c432fe9199 | ||
|
|
f14875557b | ||
|
|
7b150275ae | ||
|
|
9e227e29eb | ||
|
|
256fa4f29f | ||
|
|
7015148edf | ||
|
|
887a3c2cc6 | ||
|
|
ace696467b | ||
|
|
a72ddefc99 | ||
|
|
cf37e57808 | ||
|
|
08d8c5e47d |
@@ -42,7 +42,13 @@ jobs:
|
|||||||
# and cache the reusable-workflow ref, so a moved v1 tag keeps resolving to the
|
# and cache the reusable-workflow ref, so a moved v1 tag keeps resolving to the
|
||||||
# stale cached copy. A unique sha forces a cache miss → fresh fetch. Bump this
|
# stale cached copy. A unique sha forces a cache miss → fresh fetch. Bump this
|
||||||
# sha to adopt central swarm changes.
|
# sha to adopt central swarm changes.
|
||||||
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@c9dab69d143cb614c1840a5b06d6ffc358f4752d
|
#
|
||||||
|
# 8adeeea resolves the reviewer image tag at RUN time (reviewer_tag input →
|
||||||
|
# the owner's GADFLY_REVIEWER_TAG var → a baked fallback that exists in the
|
||||||
|
# registry), so a retired image tag can't strand this stub again: the previous
|
||||||
|
# pin (c9dab69) hard-coded gadfly:sha-b37cd09, which had been pruned from the
|
||||||
|
# registry by 2026-08-22 and every review died at "manifest unknown".
|
||||||
|
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@8adeeeabe0738a797a1bdfc42c5176ea8ee627e4
|
||||||
# Least privilege: forward only the review secrets (not `secrets: inherit`,
|
# Least privilege: forward only the review secrets (not `secrets: inherit`,
|
||||||
# which would expose every repo secret). GITEA_TOKEN is the automatic token.
|
# which would expose every repo secret). GITEA_TOKEN is the automatic token.
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -72,6 +72,42 @@ handler, put it in the service instead.
|
|||||||
Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
||||||
`internal/webdist/dist` and embedded with `embed.FS`.
|
`internal/webdist/dist` and embedded with `embed.FS`.
|
||||||
|
|
||||||
|
## The look comes from a handoff — don't improvise it
|
||||||
|
|
||||||
|
The frontend implements `docs/design_handoff_pansy_ui/` (read its README before
|
||||||
|
touching anything visual; the `.dc.html` files there are references, not code).
|
||||||
|
Conventions that follow from it:
|
||||||
|
|
||||||
|
- **Tokens live in two places, on purpose.** Light values in
|
||||||
|
`web/src/styles/index.css` (`@theme`, same names as the handoff's
|
||||||
|
`styles.css`); dark values ONLY in the bootstrap script in `web/index.html`.
|
||||||
|
A new color goes in both; a raw hex in a component is wrong. Tailwind's shadow
|
||||||
|
utilities inline their values and can't follow the runtime override — use
|
||||||
|
`.elev-sm/md/lg` instead.
|
||||||
|
- **The component classes are in index.css** (`.btn`, `.input`, `.tag`, `.seg`,
|
||||||
|
`.toggle`, `.chip`, `.panel`, `.dialog`). Use them with Tailwind utilities for
|
||||||
|
layout rather than restyling a pill from scratch.
|
||||||
|
- **The editor's breakpoint is container width** (`PHONE_BREAKPOINT` = 760 in
|
||||||
|
`web/src/editor/shared.ts`, measured with a ResizeObserver), not a media
|
||||||
|
query. One component tree, two chromes; don't build a second page.
|
||||||
|
- **Plant markers are monograms** derived from the name (`web/src/lib/monogram.ts`);
|
||||||
|
the collision set is the whole catalog so the letters match everywhere.
|
||||||
|
`plant.icon` still exists in the API but nothing renders it.
|
||||||
|
- **Season plans are a naming convention** (`web/src/lib/plan.ts`): a copy named
|
||||||
|
`<garden> — <year>` is that garden's plan. The API keeps no link; renaming the
|
||||||
|
copy quietly makes it a plain garden, which is fine.
|
||||||
|
- **Tap-to-place makes a one-plant plop** (radius = spacing/2, per the handoff);
|
||||||
|
"Fill the bed" (rows / clumps) is the bulk tool. Fill geometry still follows
|
||||||
|
the clump rules below — different tools, not a conflict.
|
||||||
|
- **Undo in the header re-reads history before reverting.** The cached list
|
||||||
|
trails the canvas right after a placement, and undoing the step *before* the
|
||||||
|
one you meant is the worst thing an undo button can do. Keep it that way.
|
||||||
|
- **Checking a change against the handoff:** run the API on a scratch DB
|
||||||
|
(`PANSY_PORT=8099 PANSY_DB=/tmp/x.db GOWORK=off go run ./cmd/pansy`) plus
|
||||||
|
`PANSY_PORT=8099 npx vite` in `web/`, seed through the API, and drive the
|
||||||
|
Playwright MCP at 1280×800 and 390×844. Its screenshots must be named under
|
||||||
|
`.playwright-mcp/` (gitignored) or it writes them into the repo root.
|
||||||
|
|
||||||
## Conventions that bite if you miss them
|
## Conventions that bite if you miss them
|
||||||
|
|
||||||
- **Everything is centimeters**, stored as SQLite `REAL`. Imperial is a display
|
- **Everything is centimeters**, stored as SQLite `REAL`. Imperial is a display
|
||||||
@@ -93,6 +129,24 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
|||||||
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
|
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
|
||||||
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
|
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
|
||||||
`removed_at IS NULL`. Hard delete is a different operation.
|
`removed_at IS NULL`. Hard delete is a different operation.
|
||||||
|
- **Length fields keep centimeters as the source of truth.** A dialog field
|
||||||
|
that takes a length is a `LengthField` (`web/src/lib/units.ts`): the text is
|
||||||
|
a view, `cm` changes only when the person types. Never re-parse the display
|
||||||
|
string on save — "29′ 6.3″" is the nearest tenth of an inch, and parsing it
|
||||||
|
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
|
||||||
|
version, and wrote a bogus history entry). The inspector still keeps display
|
||||||
|
strings but gets the same result by refusing to commit text that still equals
|
||||||
|
the formatted original (`commitDim`); either way, a no-op save sends exactly
|
||||||
|
what was loaded — or nothing.
|
||||||
|
- **"Today" is the browser's local day**, from `today()` in
|
||||||
|
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
|
||||||
|
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
|
||||||
|
API callers and the agent. A gardener placing at 9 pm in Ohio planted today,
|
||||||
|
not tomorrow — don't add a UI path that leaves the date to the server.
|
||||||
|
- **A wrapped `ErrInvalidInput` is shown to the person verbatim.**
|
||||||
|
`fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)`
|
||||||
|
reaches the client as the 400's message (minus the sentinel prefix); the bare
|
||||||
|
sentinel reads "invalid input". Write the reason for the keyboard, not the log.
|
||||||
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
|
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
|
||||||
at startup, embedded. Never edit one that has shipped.
|
at startup, embedded. Never edit one that has shipped.
|
||||||
- **Every service mutation lands in history** (#48). If you add one, record it —
|
- **Every service mutation lands in history** (#48). If you add one, record it —
|
||||||
|
|||||||
@@ -43,13 +43,10 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
|||||||
## Editor / rendering
|
## Editor / rendering
|
||||||
|
|
||||||
- **Plain SVG in React.** Tens of objects + low-hundreds of plops is far below SVG's ceiling; native DOM hit-testing, Tailwind styling, crisp text at any zoom. No Konva/canvas.
|
- **Plain SVG in React.** Tens of objects + low-hundreds of plops is far below SVG's ceiling; native DOM hit-testing, Tailwind styling, crisp text at any zoom. No Konva/canvas.
|
||||||
- **One gesture library: `@use-gesture/react`** for unified drag / wheel-zoom / pinch on desktop + touch. Everything else is hand-rolled pointer math.
|
- **Hand-rolled pointer events, no gesture library.** One pointer pans or drags, two pinch about their centroid, the wheel zooms to the cursor; a click is a drag that never crossed its threshold (3px mouse, 7px touch). Ported from the design prototype (`web/src/editor/Canvas.tsx`), which proved the model on both inputs.
|
||||||
- Viewport = a single `<g transform="translate(tx,ty) scale(s)">`; state `{tx, ty, scale}` where scale = px per cm. Wheel/pinch zooms to cursor; drag on empty space pans; drag on an element moves it.
|
- Viewport = a single `<g transform="translate(tx,ty) scale(s)">`; state `{tx, ty, scale}` where scale = px per cm. Wheel/pinch zooms to cursor; drag on empty space pans; drag on an element moves it.
|
||||||
- **Field view and bed interior are the same canvas.** "Click into a bed" animates the viewport to fit the object and sets `focusedObjectId` (mirrored to `?focus=` URL param for deep links). Focused mode dims siblings and enables plop placement; Escape/tap-out zooms back.
|
- **Field view and bed interior are the same canvas.** "Click into a bed" animates the viewport to fit the object and sets `focusedObjectId` (mirrored to `?focus=` URL param for deep links). Focused mode dims siblings and enables plop placement; Escape/tap-out zooms back.
|
||||||
- **Semantic zoom**, three bands by scale (thresholds tuned by feel):
|
- **Semantic zoom**, by on-screen size rather than a fixed scale: a plop is always a circle in its plant's color; its monogram appears once its radius is ≥ 9px on screen, its plant name below it at ≥ 34px; an object's name appears when its longer side is > 54px. Zoomed right out the beds still read by their plops' colors — "what's planted where" at a glance.
|
||||||
- zoomed out (< ~0.75 px/cm): plops render as flat color patches; plantable objects show name + dominant-plant color — "what's planted where" at a glance;
|
|
||||||
- mid: plops as colored circles with the plant's emoji icon;
|
|
||||||
- zoomed in (> ~3 px/cm): icon + plant name + count per plop.
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -67,7 +64,7 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
|
|||||||
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
||||||
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||||
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
|
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect; optional plantedAt (default UTC today)
|
||||||
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
||||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||||
@@ -78,7 +75,8 @@ GET /gardens/:id/journal/counts ← entries per object, for the "has notes" in
|
|||||||
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||||
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
|
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
|
||||||
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model
|
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model;
|
||||||
|
plus a read-only `auth` view (registration mode, local auth, OIDC issuer)
|
||||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||||
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
||||||
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
||||||
@@ -125,13 +123,17 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
|
|||||||
|
|
||||||
## Frontend layout
|
## Frontend layout
|
||||||
|
|
||||||
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server. The look is the "Organic" design handed off in `docs/design_handoff_pansy_ui/` (Aug 2026): warm cream ground, terracotta + sage accents, Caprasimo display over Figtree body, every control a pill. That README is the visual spec; this section is how it's built.
|
||||||
|
|
||||||
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
- **Tokens, once.** `web/src/styles/index.css` declares the handoff's `styles.css` variables (colors, fonts, radii, the `--p-*` canvas/ink tokens) through Tailwind's `@theme`, so utilities reference them by name — and dark mode is nothing but those same variables overridden on `<html>` by the bootstrap inlined in `web/index.html` (a port of the handoff's `pansy-theme.js`, run before first paint so a dark-mode user never sees a cream flash). No second stylesheet, no class swapping; `web/src/lib/theme.ts` is the typed React face of it.
|
||||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
|
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog), `/settings` (admin), `/g/:token` (public read-only). Auth guard on the router root via `/auth/me`. Every page renders its own nav; the root shell is only a Suspense boundary plus the toast stack.
|
||||||
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (Assistant hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. The mode bar is **always visible**, and the rail (inspector, journal, history, assistant) is an **in-flow peek** (#101): a ≤50vh panel the editor's flex column places BETWEEN the canvas and the mode bar, so the canvas flexes to keep the garden visible above it and the mode bar reachable below — selecting a bed no longer hides the whole garden, and you can switch modes without closing a panel. Desktop keeps its side-column layout (the mode bar is `md:hidden`, the rail is the right column) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
|
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One Zustand store (`web/src/editor/store.ts`) for ephemeral editor state only: camera `{tx, ty, s}`, selection, focused bed, the armed kind or plant (+ seed lot), rail tab, phone mode, journal scope, in-flight drag geometry.
|
||||||
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
- **The canvas (`web/src/editor/Canvas.tsx`)** is one SVG with one `translate/scale` group and its own pointer-event model (above). Object drags snap their center to a 3″ grid (the garden's own grid when it snaps) and commit ONE PATCH on release; plops drag in their bed's local frame and may overhang its edge by half their radius (the spacing rule). Fit and focus animate through a CSS transition on the group; drags and zooms don't. Corner handles on the selected object resize it — the one addition to the prototype, since the kinds' fixed default sizes can't make a 2′×8′ bed. Tap-to-place makes a one-plant plop (radius = spacing/2); "Fill the bed" (rows or clumps, `POST /objects/:id/fill`) is how you plant in bulk.
|
||||||
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
- **Two chromes, one tree.** The editor measures its own container: ≥ 760px is the desktop workspace (toolkit 216px | plan | rail 336px, the rail's Plot/Journal/History/Assistant tabs); below it the phone layout — header, full-screen canvas, an in-flow **peek** (≤ 45% tall, docked between the canvas and the mode bar) for the inspector, journal or assistant, a tool strip for Build or Plants mode, and the always-visible mode bar. Focusing a bed on the phone switches to Plants mode; a plant can also be tapped straight into any bed without focusing.
|
||||||
|
- **Plant markers** are the plant's color plus a monogram derived from its name (`web/src/lib/monogram.ts`, collisions resolved across the whole catalog so a letter means the same thing on every screen).
|
||||||
|
- **Undo** in the editor's header reverts the newest change set still in effect (not already reverted, not itself a revert) — after re-reading the history, because the cached list trails the canvas right after a placement, and undoing the step *before* the one you meant is the worst thing an undo button can do. The History tab offers every step, including undoing an undo.
|
||||||
|
- **Seasons** are the years with planting data (`GET /gardens/:id/years`); the current year is live, any other is read-only. A *plan* is a whole-garden copy named `<garden> — <year>` (`web/src/lib/plan.ts`); the season control lists a garden's plan copies and, from inside one, the way back. The name is the only link the API keeps, which is deliberate — rename the copy and it is simply a garden.
|
||||||
|
- Pure helpers stay in `web/src/lib/` (geometry, units including the compact `3′` / `1′4″` display, monograms, plan names), unit-tested.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -151,6 +153,6 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
|||||||
1. Plop `count` derived from area ÷ spacing², explicit override allowed.
|
1. Plop `count` derived from area ÷ spacing², explicit override allowed.
|
||||||
2. Shapes: rect + circle only (polygon reserved in schema).
|
2. Shapes: rect + circle only (polygon reserved in schema).
|
||||||
3. Seasons = planted/removed dates. `?year=` filters `/full` to the plops whose `[planted_at, removed_at]` interval overlapped that calendar year, so garlic planted in October and pulled in July shows in both — and undated plops show in every year, since everything predating the feature has a null `planted_at`. Deliberately **no `seasons` table**: it would duplicate what the dates already say and create a second source of truth about when something was in the ground. Past seasons are read-only; #46's garden copy is the scenario-planning half.
|
3. Seasons = planted/removed dates. `?year=` filters `/full` to the plops whose `[planted_at, removed_at]` interval overlapped that calendar year, so garlic planted in October and pulled in July shows in both — and undated plops show in every year, since everything predating the feature has a null `planted_at`. Deliberately **no `seasons` table**: it would duplicate what the dates already say and create a second source of truth about when something was in the ground. Past seasons are read-only; #46's garden copy is the scenario-planning half.
|
||||||
4. Emoji plant icons (zero assets); SVG icon set later if wanted.
|
4. Plant markers are the plant's color plus a 1–2 letter monogram derived from its name (`web/src/lib/monogram.ts`) — still zero assets. The `icon` (emoji) column stays in the API for compatibility; the UI no longer shows it.
|
||||||
5. No background/satellite image tracing (cheap to add later as a garden background field).
|
5. No background/satellite image tracing (cheap to add later as a garden background field).
|
||||||
6. 409-and-refetch conflict handling; no real-time sync.
|
6. 409-and-refetch conflict handling; no real-time sync.
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`,
|
|||||||
|
|
||||||
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
||||||
|
|
||||||
|
## The UI
|
||||||
|
|
||||||
|
The frontend implements the design handoff in [`docs/design_handoff_pansy_ui/`](docs/design_handoff_pansy_ui/README.md) — read that README before changing how anything looks; the `.dc.html` files there are references, not shipped code.
|
||||||
|
|
||||||
|
- **Theme.** Light by default, with a `system | light | dark` preference (the monitor/sun/moon button in every nav, or Settings → Appearance) kept in `localStorage['pansy-theme']`. Dark mode is the same stylesheet with its tokens overridden on `<html>` by a small script in `web/index.html`, which runs before the first paint.
|
||||||
|
- **Fonts.** Caprasimo and Figtree are loaded from Google Fonts (`fonts.googleapis.com`). That is the app's only request to a third party; offline it falls back to system fonts and everything else works.
|
||||||
|
- **Phone vs desktop.** The editor picks its chrome by *container* width — below 760px it is the one-column phone layout (mode bar, tool strip, a peek panel that docks between the canvas and the bar); above it, the three-card workspace. Every other page is one responsive layout.
|
||||||
|
- **Season plans.** "Copy — plan a season from it" duplicates a garden (`POST /gardens/:id/copy`) under the name `<garden> — <year>`; the editor's season control and the `plan` tag on the gardens list read that name back. Rename the copy and it is just a garden again.
|
||||||
|
- **Settings → Who gets in** is read-only: `PANSY_REGISTRATION`, `PANSY_LOCAL_AUTH` and the OIDC issuer are reported there (the `auth` block of `GET /settings`) so an admin can see what is in force without shell access; they deploy with the environment.
|
||||||
|
|
||||||
## Docker & deployment
|
## Docker & deployment
|
||||||
|
|
||||||
CI (`.gitea/workflows/build-image.yml`) builds the single-binary image and pushes it to the Gitea registry on every branch push:
|
CI (`.gitea/workflows/build-image.yml`) builds the single-binary image and pushes it to the Gitea registry on every branch push:
|
||||||
|
|||||||
@@ -0,0 +1,901 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||||
|
<script src="pansy-theme.js"></script>
|
||||||
|
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||||
|
html,body{height:100%;margin:0}
|
||||||
|
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||||
|
::-webkit-scrollbar{height:6px;width:6px} ::-webkit-scrollbar-thumb{background:var(--color-neutral-400);border-radius:99px} ::-webkit-scrollbar-track{background:transparent}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div ref="{{ rootRef }}" style="height:100%;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);user-select:none">
|
||||||
|
|
||||||
|
<sc-if value="{{ isDesktop }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div data-screen-label="Editor — desktop" style="height:100%;display:flex;flex-direction:column">
|
||||||
|
<nav class="nav" style="flex:none">
|
||||||
|
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||||
|
pansy
|
||||||
|
</span>
|
||||||
|
<a href="Pansy Gardens.dc.html" aria-current="page">Gardens</a>
|
||||||
|
<a href="Pansy Plants.dc.html">Plants</a>
|
||||||
|
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||||
|
</button>
|
||||||
|
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||||
|
</a>
|
||||||
|
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div style="flex:1;display:grid;grid-template-columns:216px minmax(0,1fr) 336px;gap:14px;padding:14px;min-height:0">
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:14px;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:8px">
|
||||||
|
<sc-if value="{{ notFocus }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<h6 style="margin:4px 4px 6px">Toolkit</h6>
|
||||||
|
<sc-for list="{{ kindsR }}" as="k" hint-placeholder-count="7">
|
||||||
|
<button draggable="true" onDragStart="{{ k.onDrag }}" onClick="{{ k.onClick }}" style="{{ k.btnStyle }}" title="Drag onto the plan — or click to arm, then click the plan">
|
||||||
|
<svg width="38" height="30" viewBox="-20 -15 40 30" style="flex:none">
|
||||||
|
<sc-if value="{{ k.isRect }}"><rect x="{{ k.mx0 }}" y="{{ k.my0 }}" width="{{ k.mw }}" height="{{ k.mh }}" rx="3" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ k.isCircle }}"><circle r="{{ k.mr }}" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></circle></sc-if>
|
||||||
|
</svg>
|
||||||
|
<span style="display:flex;flex-direction:column;align-items:flex-start;gap:1px">
|
||||||
|
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ k.label }}</span>
|
||||||
|
<span style="font-size:11px;color:var(--p-ink-mute);white-space:nowrap">{{ k.sizeText }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
<div style="margin-top:auto;font-size:12px;color:var(--p-ink-mute);line-height:1.5;padding:8px 6px 2px">Drag a shape onto the plan. Double-click any bed to plant it.</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin:2px 0 4px">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:30px;height:30px;flex:none" onClick="{{ onBack }}" title="Back to the plan">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
|
||||||
|
</button>
|
||||||
|
<span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">{{ focusName }}</span>
|
||||||
|
</div>
|
||||||
|
<input class="input" style="border-radius:999px" placeholder="Find a plant…" value="{{ q }}" onChange="{{ onQ }}">
|
||||||
|
<sc-for list="{{ plantsR }}" as="c" hint-placeholder-count="8">
|
||||||
|
<button draggable="true" onDragStart="{{ c.onDrag }}" onClick="{{ c.onClick }}" style="{{ c.btnStyle }}" title="Drag into the bed — or click to arm, then click the bed">
|
||||||
|
<svg width="16" height="16" style="flex:none"><circle cx="8" cy="8" r="8" fill="{{ c.color }}"></circle></svg>
|
||||||
|
<span style="font-size:13px;font-weight:700">{{ c.name }}</span>
|
||||||
|
<span style="font-size:11px;color:var(--p-ink-mute);margin-left:auto">{{ c.spacingText }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);display:flex;flex-direction:column;min-height:0;overflow:hidden">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--color-divider);flex-wrap:wrap">
|
||||||
|
<h4 style="margin:0;font-size:19px">Home Garden</h4>
|
||||||
|
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">40′ × 28′</span>
|
||||||
|
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="opacity:0.4">/</span><span style="font-size:14px;font-weight:700;color:var(--color-accent-700)">{{ focusName }}</span>
|
||||||
|
</sc-if>
|
||||||
|
<span style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;min-width:0">
|
||||||
|
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
|
||||||
|
<sc-for list="{{ seasonsR }}" as="sn">
|
||||||
|
<button onClick="{{ sn.onClick }}" style="{{ sn.style }}">{{ sn.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px;gap:7px" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ banner }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:8px 18px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:13px;font-weight:600">{{ banner }}</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="flex:1;min-height:0;position:relative">
|
||||||
|
<svg ref="{{ svgRef }}" width="100%" height="100%" style="{{ svgStyle }}" onPointerDown="{{ onCanvasDown }}" onPointerMove="{{ onCanvasMove }}" onPointerUp="{{ onCanvasUp }}" onPointerCancel="{{ onCanvasUp }}" onDragOver="{{ onDragOver }}" onDrop="{{ onDrop }}">
|
||||||
|
<g style="{{ viewStyle }}">
|
||||||
|
<rect x="0" y="0" width="{{ gw }}" height="{{ gh }}" rx="18" fill="var(--p-field)" stroke="var(--color-accent-2-500)" stroke-width="{{ borderW }}"></rect>
|
||||||
|
<path d="{{ gridMinor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.06" fill="none"></path>
|
||||||
|
<path d="{{ gridMajor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.12" fill="none"></path>
|
||||||
|
<sc-for list="{{ objectsR }}" as="o" hint-placeholder-count="4">
|
||||||
|
<g transform="{{ o.transform }}" opacity="{{ o.opacity }}" style="cursor:grab" onPointerDown="{{ o.onDown }}" onDoubleClick="{{ o.onDbl }}">
|
||||||
|
<sc-if value="{{ o.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ o.x0 }}" y="{{ o.y0 }}" width="{{ o.w }}" height="{{ o.h }}" rx="{{ o.rr }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ o.isCircle }}" hint-placeholder-val="{{ false }}"><circle r="{{ o.r }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></circle></sc-if>
|
||||||
|
</g>
|
||||||
|
</sc-for>
|
||||||
|
<sc-for list="{{ plopsR }}" as="p" hint-placeholder-count="0">
|
||||||
|
<g transform="{{ p.transform }}" opacity="{{ p.opacity }}" style="{{ p.gStyle }}" onPointerDown="{{ p.onDown }}">
|
||||||
|
<circle r="{{ p.r }}" fill="{{ p.fill }}" stroke="{{ p.stroke }}" stroke-width="{{ p.sw }}"></circle>
|
||||||
|
</g>
|
||||||
|
</sc-for>
|
||||||
|
<g style="pointer-events:none">{{ labelsLayer }}</g>
|
||||||
|
<sc-if value="{{ selO }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<g transform="{{ selO.transform }}" style="pointer-events:none">
|
||||||
|
<sc-if value="{{ selO.isRect }}"><rect x="{{ selO.x0 }}" y="{{ selO.y0 }}" width="{{ selO.w }}" height="{{ selO.h }}" rx="{{ selO.rr }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ selO.isCircle }}"><circle r="{{ selO.r }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></circle></sc-if>
|
||||||
|
</g>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ ghostR }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<g transform="{{ ghostR.transform }}" opacity="0.55" style="pointer-events:none">
|
||||||
|
<sc-if value="{{ ghostR.isRect }}"><rect x="{{ ghostR.x0 }}" y="{{ ghostR.y0 }}" width="{{ ghostR.w }}" height="{{ ghostR.h }}" rx="12" fill="{{ ghostR.fill }}" stroke="var(--color-accent)" stroke-width="{{ ghostR.sw }}" stroke-dasharray="{{ ghostR.dash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ ghostR.isCircle }}"><circle r="{{ ghostR.r }}" fill="{{ ghostR.fill }}" stroke="var(--color-accent)" stroke-width="{{ ghostR.sw }}" stroke-dasharray="{{ ghostR.dash }}"></circle></sc-if>
|
||||||
|
</g>
|
||||||
|
</sc-if>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div style="position:absolute;bottom:12px;right:12px;display:flex;gap:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:999px;box-shadow:var(--shadow-sm);padding:4px">
|
||||||
|
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomOut }}" title="Zoom out"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg></button>
|
||||||
|
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomFit }}" title="Fit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg></button>
|
||||||
|
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomIn }}" title="Zoom in"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);display:flex;flex-direction:column;min-height:0;overflow:hidden">
|
||||||
|
<div style="display:flex;gap:4px;padding:10px 10px 0">
|
||||||
|
<sc-for list="{{ tabsR }}" as="t">
|
||||||
|
<button onClick="{{ t.onClick }}" style="{{ t.style }}">{{ t.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px">
|
||||||
|
<sc-if value="{{ tabPlot }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<sc-if value="{{ insp }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-if value="{{ insp.isObj }}">
|
||||||
|
<input class="input" style="border-radius:999px;font-weight:700" value="{{ insp.name }}" onChange="{{ insp.onRename }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span class="tag tag-neutral" style="border-radius:999px">{{ insp.kindLabel }}</span>
|
||||||
|
<span style="font-size:13px;color:var(--p-ink-soft);font-weight:600">{{ insp.sizeText }}</span>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ insp.rosterText }}"><div style="font-size:12.5px;color:var(--p-ink-soft);line-height:1.5">{{ insp.rosterText }}</div></sc-if>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<sc-if value="{{ insp.plantable }}"><button class="btn btn-primary" style="border-radius:999px;flex:1" onClick="{{ insp.onOpen }}">Plant this</button></sc-if>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ insp.onRotate }}" title="Rotate 90°"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg></button>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ insp.onDelete }}" title="Remove"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ insp.isPlop }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="18" height="18"><circle cx="9" cy="9" r="8" fill="{{ insp.plantColor }}"></circle></svg>
|
||||||
|
<span style="font-family:var(--font-heading);font-size:17px">{{ insp.plantName }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--p-ink-soft)">{{ insp.countText }} · {{ insp.plopSize }} patch</div>
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ insp.onDelete }}">Pull it out</button>
|
||||||
|
</sc-if>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ summary }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<h5 style="margin:2px 0 0">This garden</h5>
|
||||||
|
<div style="font-size:13px;color:var(--p-ink-soft);line-height:1.6">{{ summary.countsText }}</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:7px">
|
||||||
|
<sc-for list="{{ summary.roster }}" as="r" hint-placeholder-count="3">
|
||||||
|
<div style="display:flex;align-items:center;gap:9px">
|
||||||
|
<svg width="13" height="13" style="flex:none"><circle cx="6.5" cy="6.5" r="6.5" fill="{{ r.color }}"></circle></svg>
|
||||||
|
<span style="font-size:13px;font-weight:600">{{ r.name }}</span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute);margin-left:auto">{{ r.where }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5;margin-top:auto;padding-top:10px">Select anything on the plan to edit it here. Double-click a bed to plant it.</div>
|
||||||
|
</sc-if>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ tabJournal }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-for list="{{ entriesR }}" as="e" hint-placeholder-count="3">
|
||||||
|
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:12px 14px">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;margin-bottom:5px">
|
||||||
|
<span style="font-size:11.5px;font-weight:700;color:var(--color-accent-2-700)">{{ e.obj }}</span>
|
||||||
|
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ e.d }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;line-height:1.5">{{ e.txt }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:auto;padding-top:6px">
|
||||||
|
<input class="input" style="border-radius:999px" placeholder="{{ jrPlaceholder }}" value="{{ jr }}" onChange="{{ onJr }}" onKeyDown="{{ onJrKey }}">
|
||||||
|
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none" onClick="{{ onAddJr }}" title="Log it">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ tabHistory }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:12.5px;color:var(--p-ink-mute);line-height:1.5">Every change — yours or the assistant's — is one undoable step.</div>
|
||||||
|
<sc-for list="{{ historyR }}" as="h" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex;align-items:center;gap:9px;background:var(--color-bg);border:1px solid var(--color-divider);border-radius:999px;padding:8px 14px">
|
||||||
|
<span style="width:7px;height:7px;border-radius:99px;background:var(--color-accent-400);flex:none"></span>
|
||||||
|
<span style="font-size:13px;font-weight:600">{{ h.label }}</span>
|
||||||
|
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ h.when }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ historyEmpty }}"><div style="font-size:13px;color:var(--p-ink-mute)">Nothing yet this session — go move something.</div></sc-if>
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px;margin-top:auto" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}">Undo the last step</button>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ tabChat }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-for list="{{ msgsR }}" as="m" hint-placeholder-count="2">
|
||||||
|
<div style="{{ m.style }}">{{ m.txt }}</div>
|
||||||
|
</sc-for>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:auto;padding-top:6px">
|
||||||
|
<input class="input" style="border-radius:999px" placeholder="Ask about your garden…" value="{{ chat }}" onChange="{{ onChat }}" onKeyDown="{{ onChatKey }}">
|
||||||
|
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none" onClick="{{ onSend }}" title="Send">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ isMobile }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div data-screen-label="Editor — phone" style="height:100%;display:flex;flex-direction:column">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;flex:none">
|
||||||
|
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px;flex:none;width:38px;height:38px" onClick="{{ onBack }}" title="Back to the plan">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
|
||||||
|
</button>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ notFocus }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<a href="Pansy Gardens.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px;flex:none;width:38px;height:38px;text-decoration:none" title="Your gardens">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
|
||||||
|
</a>
|
||||||
|
</sc-if>
|
||||||
|
<span style="font-family:var(--font-heading);font-size:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">{{ mTitle }}</span>
|
||||||
|
<span style="margin-left:auto;display:flex;align-items:center;gap:8px;flex:none">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:38px;height:38px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick="{{ onCycleSeason }}" class="tag tag-accent-2" style="border-radius:999px;border:none;cursor:pointer;font-family:var(--font-body)" title="Switch season">{{ seasonShort }}</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:38px;height:38px" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}" title="Undo">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ banner }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:6px 14px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:600;flex:none">{{ banner }}</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="flex:1;min-height:0;position:relative">
|
||||||
|
<svg ref="{{ svgRef }}" width="100%" height="100%" style="{{ svgStyle }}" onPointerDown="{{ onCanvasDown }}" onPointerMove="{{ onCanvasMove }}" onPointerUp="{{ onCanvasUp }}" onPointerCancel="{{ onCanvasUp }}" onDragOver="{{ onDragOver }}" onDrop="{{ onDrop }}">
|
||||||
|
<g style="{{ viewStyle }}">
|
||||||
|
<rect x="0" y="0" width="{{ gw }}" height="{{ gh }}" rx="18" fill="var(--p-field)" stroke="var(--color-accent-2-500)" stroke-width="{{ borderW }}"></rect>
|
||||||
|
<path d="{{ gridMinor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.06" fill="none"></path>
|
||||||
|
<path d="{{ gridMajor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.12" fill="none"></path>
|
||||||
|
<sc-for list="{{ objectsR }}" as="o" hint-placeholder-count="4">
|
||||||
|
<g transform="{{ o.transform }}" opacity="{{ o.opacity }}" onPointerDown="{{ o.onDown }}" onDoubleClick="{{ o.onDbl }}">
|
||||||
|
<sc-if value="{{ o.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ o.x0 }}" y="{{ o.y0 }}" width="{{ o.w }}" height="{{ o.h }}" rx="{{ o.rr }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ o.isCircle }}" hint-placeholder-val="{{ false }}"><circle r="{{ o.r }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></circle></sc-if>
|
||||||
|
</g>
|
||||||
|
</sc-for>
|
||||||
|
<sc-for list="{{ plopsR }}" as="p" hint-placeholder-count="0">
|
||||||
|
<g transform="{{ p.transform }}" opacity="{{ p.opacity }}" onPointerDown="{{ p.onDown }}">
|
||||||
|
<circle r="{{ p.r }}" fill="{{ p.fill }}" stroke="{{ p.stroke }}" stroke-width="{{ p.sw }}"></circle>
|
||||||
|
</g>
|
||||||
|
</sc-for>
|
||||||
|
<g style="pointer-events:none">{{ labelsLayer }}</g>
|
||||||
|
<sc-if value="{{ selO }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<g transform="{{ selO.transform }}" style="pointer-events:none">
|
||||||
|
<sc-if value="{{ selO.isRect }}"><rect x="{{ selO.x0 }}" y="{{ selO.y0 }}" width="{{ selO.w }}" height="{{ selO.h }}" rx="{{ selO.rr }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ selO.isCircle }}"><circle r="{{ selO.r }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></circle></sc-if>
|
||||||
|
</g>
|
||||||
|
</sc-if>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<button class="btn btn-icon" style="position:absolute;bottom:12px;right:12px;border-radius:999px;width:40px;height:40px;background:var(--color-surface);border:1px solid var(--color-divider);box-shadow:var(--shadow-sm)" onClick="{{ zoomFit }}" title="Fit the garden">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ mPeek }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="flex:none;max-height:45%;display:flex;flex-direction:column;background:var(--color-neutral-100);border-top:1px solid var(--color-divider);border-radius:22px 22px 0 0;box-shadow:var(--shadow-lg)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;padding:10px 14px 2px">
|
||||||
|
<span style="font-family:var(--font-heading);font-size:15px">{{ mPeekTitle }}</span>
|
||||||
|
<button class="btn btn-icon" style="border-radius:999px;margin-left:auto;width:32px;height:32px" style-hover="background:var(--color-accent-100)" onClick="{{ onPeekClose }}" title="Close">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:8px 16px 16px;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<sc-if value="{{ mPeekInsp }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-if value="{{ insp.isObj }}">
|
||||||
|
<input class="input" style="border-radius:999px;font-weight:700;font-size:16px" value="{{ insp.name }}" onChange="{{ insp.onRename }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span class="tag tag-neutral" style="border-radius:999px">{{ insp.kindLabel }}</span>
|
||||||
|
<span style="font-size:13px;color:var(--p-ink-soft);font-weight:600">{{ insp.sizeText }}</span>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ insp.rosterText }}"><div style="font-size:12.5px;color:var(--p-ink-soft);line-height:1.5">{{ insp.rosterText }}</div></sc-if>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<sc-if value="{{ insp.plantable }}"><button class="btn btn-primary" style="border-radius:999px;flex:1;min-height:44px" onClick="{{ insp.onOpen }}">Plant this</button></sc-if>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:44px;height:44px" onClick="{{ insp.onRotate }}" title="Rotate 90°"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg></button>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:44px;height:44px" onClick="{{ insp.onDelete }}" title="Remove"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ insp.isPlop }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="18" height="18"><circle cx="9" cy="9" r="8" fill="{{ insp.plantColor }}"></circle></svg>
|
||||||
|
<span style="font-family:var(--font-heading);font-size:17px">{{ insp.plantName }}</span>
|
||||||
|
<span style="font-size:12.5px;color:var(--p-ink-soft);margin-left:auto">{{ insp.countText }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px;min-height:44px" onClick="{{ insp.onDelete }}">Pull it out</button>
|
||||||
|
</sc-if>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ mPeekJournal }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<input class="input" style="border-radius:999px;font-size:16px" placeholder="{{ jrPlaceholder }}" value="{{ jr }}" onChange="{{ onJr }}" onKeyDown="{{ onJrKey }}">
|
||||||
|
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none;width:44px;height:44px" onClick="{{ onAddJr }}" title="Log it">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<sc-for list="{{ entriesR }}" as="e" hint-placeholder-count="3">
|
||||||
|
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:11px 13px">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
|
||||||
|
<span style="font-size:11.5px;font-weight:700;color:var(--color-accent-2-700)">{{ e.obj }}</span>
|
||||||
|
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ e.d }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13.5px;line-height:1.5">{{ e.txt }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ mPeekChat }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-for list="{{ msgsR }}" as="m" hint-placeholder-count="2">
|
||||||
|
<div style="{{ m.style }}">{{ m.txt }}</div>
|
||||||
|
</sc-for>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<input class="input" style="border-radius:999px;font-size:16px" placeholder="Ask about your garden…" value="{{ chat }}" onChange="{{ onChat }}" onKeyDown="{{ onChatKey }}">
|
||||||
|
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none;width:44px;height:44px" onClick="{{ onSend }}" title="Send">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ mStrip }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="flex:none;display:flex;gap:8px;overflow-x:auto;padding:10px 12px;border-top:1px solid var(--color-divider);background:var(--color-bg)">
|
||||||
|
<sc-if value="{{ mStripDone }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px;flex:none;min-height:44px" onClick="{{ onBack }}">Done</button>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ mStripKinds }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-for list="{{ kindsR }}" as="k" hint-placeholder-count="7">
|
||||||
|
<button onClick="{{ k.onClick }}" style="{{ k.mStyle }}">
|
||||||
|
<svg width="30" height="24" viewBox="-20 -13 40 26" style="flex:none">
|
||||||
|
<sc-if value="{{ k.isRect }}"><rect x="{{ k.mx0 }}" y="{{ k.my0 }}" width="{{ k.mw }}" height="{{ k.mh }}" rx="3" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ k.isCircle }}"><circle r="{{ k.mr }}" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></circle></sc-if>
|
||||||
|
</svg>
|
||||||
|
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ k.label }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ mStripPlants }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<sc-for list="{{ plantsR }}" as="c" hint-placeholder-count="8">
|
||||||
|
<button onClick="{{ c.onClick }}" style="{{ c.mStyle }}">
|
||||||
|
<svg width="15" height="15" style="flex:none"><circle cx="7.5" cy="7.5" r="7.5" fill="{{ c.color }}"></circle></svg>
|
||||||
|
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ c.name }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<div style="flex:none;display:flex;gap:4px;padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:var(--color-surface);border-top:1px solid var(--color-divider)">
|
||||||
|
<sc-for list="{{ modesR }}" as="md">
|
||||||
|
<button onClick="{{ md.onClick }}" style="{{ md.style }}">
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ md.d1 }}"></path><path d="{{ md.d2 }}"></path><path d="{{ md.d3 }}"></path></svg>
|
||||||
|
<span style="font-size:11px;font-weight:700">{{ md.label }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"layout": {"editor": "enum", "options": ["auto", "phone", "desktop"], "default": "auto", "tsType": "'auto' | 'phone' | 'desktop'", "section": "Layout"}, "showGrid": {"editor": "boolean", "default": true, "tsType": "boolean", "section": "Canvas"}, "markers": {"editor": "enum", "options": ["monogram", "dot"], "default": "monogram", "tsType": "'monogram' | 'dot'", "section": "Canvas"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.svgRef = React.createRef(); this.rootRef = React.createRef();
|
||||||
|
this.GW = 1219; this.GH = 853; this.nid = 100; this.pts = new Map();
|
||||||
|
this.PLANTS = [
|
||||||
|
{ id: 'garlic', name: 'Garlic', letter: 'G', color: '#97a97c', spacing: 15 },
|
||||||
|
{ id: 'tomato', name: 'Tomato', letter: 'T', color: '#c8553d', spacing: 60 },
|
||||||
|
{ id: 'cucumber', name: 'Cucumber', letter: 'C', color: '#6f8f4f', spacing: 30 },
|
||||||
|
{ id: 'watermelon', name: 'Watermelon', letter: 'W', color: '#46683c', spacing: 90 },
|
||||||
|
{ id: 'basil', name: 'Basil', letter: 'B', color: '#5f8f45', spacing: 25 },
|
||||||
|
{ id: 'pepper', name: 'Pepper', letter: 'P', color: '#b2622d', spacing: 45 },
|
||||||
|
{ id: 'marigold', name: 'Marigold', letter: 'Ma', color: '#d9912f', spacing: 20 },
|
||||||
|
{ id: 'melon', name: 'Melon', letter: 'Me', color: '#c2913a', spacing: 90 },
|
||||||
|
];
|
||||||
|
this.KINDS = [
|
||||||
|
{ kind: 'bed', label: 'Bed', shape: 'rect', w: 91, h: 183, plantable: true },
|
||||||
|
{ kind: 'grow_bag', label: 'Grow bag', shape: 'circle', w: 40, h: 40, plantable: true },
|
||||||
|
{ kind: 'container', label: 'Container', shape: 'circle', w: 60, h: 60, plantable: true },
|
||||||
|
{ kind: 'in_ground', label: 'In-ground', shape: 'rect', w: 200, h: 200, plantable: true },
|
||||||
|
{ kind: 'tree', label: 'Tree', shape: 'circle', w: 300, h: 300, plantable: false },
|
||||||
|
{ kind: 'path', label: 'Path', shape: 'rect', w: 100, h: 300, plantable: false },
|
||||||
|
{ kind: 'structure', label: 'Structure', shape: 'rect', w: 200, h: 200, plantable: false },
|
||||||
|
];
|
||||||
|
const o = (id, kind, name, shape, x, y, w, h) => ({ id, kind, name, shape, x, y, w, h, rot: 0, plantable: this.KINDS.find(k => k.kind === kind).plantable });
|
||||||
|
const objects = [
|
||||||
|
o(10, 'path', 'Walkway', 'rect', 560, 285, 1010, 75),
|
||||||
|
o(1, 'bed', 'Bed 1', 'rect', 110, 140, 91, 183), o(2, 'bed', 'Bed 2', 'rect', 320, 140, 91, 183),
|
||||||
|
o(3, 'bed', 'Bed 3', 'rect', 530, 140, 91, 183), o(4, 'bed', 'Bed 4', 'rect', 740, 140, 91, 183),
|
||||||
|
o(5, 'bed', 'Bed 5', 'rect', 950, 140, 91, 183),
|
||||||
|
o(6, 'bed', 'Long bed A', 'rect', 190, 392, 244, 61), o(7, 'bed', 'Long bed B', 'rect', 190, 502, 244, 61),
|
||||||
|
o(8, 'bed', 'Long bed C', 'rect', 190, 612, 244, 61), o(9, 'bed', 'Long bed D', 'rect', 190, 722, 244, 61),
|
||||||
|
o(11, 'grow_bag', 'Bag 1', 'circle', 1090, 110, 40, 40), o(12, 'grow_bag', 'Bag 2', 'circle', 1090, 165, 40, 40),
|
||||||
|
o(13, 'grow_bag', 'Bag 3', 'circle', 1090, 220, 40, 40),
|
||||||
|
o(14, 'container', 'Bucket 1', 'circle', 1160, 110, 30, 30), o(15, 'container', 'Bucket 2', 'circle', 1160, 165, 30, 30),
|
||||||
|
];
|
||||||
|
let plops = [];
|
||||||
|
const grid = (objId, plantId, w, h, sp) => {
|
||||||
|
const cols = Math.max(1, Math.floor(w / sp)), rows = Math.max(1, Math.floor(h / sp));
|
||||||
|
for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++)
|
||||||
|
plops.push({ id: this.nid++, objId, plantId, lx: (i - (cols - 1) / 2) * sp, ly: (j - (rows - 1) / 2) * sp, r: sp / 2 });
|
||||||
|
};
|
||||||
|
grid(6, 'garlic', 244, 61, 15); grid(1, 'tomato', 91, 183, 60); grid(3, 'watermelon', 91, 183, 90);
|
||||||
|
grid(4, 'basil', 91, 183, 25); grid(7, 'pepper', 244, 61, 45); grid(8, 'marigold', 244, 61, 20);
|
||||||
|
plops.push({ id: this.nid++, objId: 2, plantId: 'cucumber', lx: 0, ly: -48, r: 38 }, { id: this.nid++, objId: 2, plantId: 'cucumber', lx: 0, ly: 48, r: 38 });
|
||||||
|
[11, 12, 13].forEach(b => plops.push({ id: this.nid++, objId: b, plantId: 'melon', lx: 0, ly: 0, r: 15 }));
|
||||||
|
const min = [], maj = [], FT = 30.48;
|
||||||
|
for (let x = FT; x < this.GW; x += FT) (Math.round(x / FT) % 5 ? min : maj).push(`M${x.toFixed(1)} 0V${this.GH}`);
|
||||||
|
for (let y = FT; y < this.GH; y += FT) (Math.round(y / FT) % 5 ? min : maj).push(`M0 ${y.toFixed(1)}H${this.GW}`);
|
||||||
|
this.gridMinor = min.join(''); this.gridMajor = maj.join('');
|
||||||
|
this.state = {
|
||||||
|
vw: 0, tx: 40, ty: 40, s: 0.5, anim: false, sel: null, focus: null, armK: null, armP: null, ghost: null,
|
||||||
|
objects, plops, undo: [], tab: 'plot', mode: 'build', season: '2026', q: '', jr: '', chat: '',
|
||||||
|
entries: [
|
||||||
|
{ d: 'Aug 14', obj: 'Long bed A', txt: 'Garlic tips yellowing on the north end — eased off the watering.' },
|
||||||
|
{ d: 'Aug 9', obj: 'Bed 3', txt: 'First watermelon set! Ordered a sling.' },
|
||||||
|
{ d: 'Aug 2', obj: 'Bag 2', txt: 'Melon vine escaping the bag — trained it along the fence.' },
|
||||||
|
],
|
||||||
|
msgs: [
|
||||||
|
{ who: 'u', txt: 'What can I follow the garlic with?' },
|
||||||
|
{ who: 'a', txt: 'Long bed A frees up mid-July. Bush beans, carrots, or fall brassica starts all fit that window. Want me to sketch beans in?' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
layoutProp() { return this.props.layout ?? 'auto'; }
|
||||||
|
isMobileNow() {
|
||||||
|
const lp = this.layoutProp();
|
||||||
|
return lp === 'phone' || (lp !== 'desktop' && this.state.vw > 0 && this.state.vw < 760);
|
||||||
|
}
|
||||||
|
styleFor(kind) {
|
||||||
|
return {
|
||||||
|
bed: { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' }, in_ground: { fill: 'var(--p-ing-fill)', stroke: 'var(--p-ing-stroke)', dash: '10 7' },
|
||||||
|
path: { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)', dash: '3 8' }, grow_bag: { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' },
|
||||||
|
container: { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' }, tree: { fill: 'var(--p-tree-fill)', stroke: 'var(--p-tree-stroke)', dash: '12 8' },
|
||||||
|
structure: { fill: 'var(--p-str-fill)', stroke: 'var(--p-str-stroke)' },
|
||||||
|
}[kind] || { fill: '#ddd', stroke: '#999' };
|
||||||
|
}
|
||||||
|
plant(id) { return this.PLANTS.find(p => p.id === id); }
|
||||||
|
obj(id) { return this.state.objects.find(o => o.id === id); }
|
||||||
|
fmt(cm) { const i = Math.round(cm / 2.54); const ft = Math.floor(i / 12), r = i - ft * 12; return ft ? (r ? ft + '\u2032' + r + '\u2033' : ft + '\u2032') : r + '\u2033'; }
|
||||||
|
cnt(p) { const sp = this.plant(p.plantId).spacing; return Math.max(1, Math.round(Math.PI * p.r * p.r / (sp * sp))); }
|
||||||
|
get readOnly() { return this.state.season === '2025'; }
|
||||||
|
componentDidMount() {
|
||||||
|
const measure = () => {
|
||||||
|
const w = this.rootRef.current ? this.rootRef.current.clientWidth : 0;
|
||||||
|
const wasM = this.isMobileNow();
|
||||||
|
this.setState({ vw: w }, () => {
|
||||||
|
const el = this.svgRef.current;
|
||||||
|
const big = el && (Math.abs(el.clientWidth - (this._fitW || 0)) > 60 || Math.abs(el.clientHeight - (this._fitH || 0)) > 60);
|
||||||
|
if (this.isMobileNow() !== wasM || big) this.zoomFit();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
this.ro = new ResizeObserver(() => measure());
|
||||||
|
this.rootRef.current && this.ro.observe(this.rootRef.current);
|
||||||
|
setTimeout(() => this.zoomFit(), 30);
|
||||||
|
this.wheelHooked = null;
|
||||||
|
this.hookWheel();
|
||||||
|
this.keyN = (e) => {
|
||||||
|
if (/input|textarea/i.test(e.target.tagName)) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const st = this.state;
|
||||||
|
if (st.armK || st.armP) this.setState({ armK: null, armP: null, ghost: null });
|
||||||
|
else if (st.sel) this.setState({ sel: null });
|
||||||
|
else if (st.focus) this.back();
|
||||||
|
}
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && this.state.sel) this.deleteSel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', this.keyN);
|
||||||
|
this.themeMount();
|
||||||
|
}
|
||||||
|
componentDidUpdate() { this.hookWheel(); }
|
||||||
|
hookWheel() {
|
||||||
|
const el = this.svgRef.current;
|
||||||
|
if (!el || this.wheelHooked === el) return;
|
||||||
|
this.wheelN = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const r = el.getBoundingClientRect(), { tx, ty, s } = this.state;
|
||||||
|
const ns = Math.min(8, Math.max(0.12, s * Math.exp(-e.deltaY * 0.0016)));
|
||||||
|
const px = e.clientX - r.left, py = e.clientY - r.top;
|
||||||
|
this.setState({ s: ns, tx: px - (px - tx) / s * ns, ty: py - (py - ty) / s * ns, anim: false });
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', this.wheelN, { passive: false });
|
||||||
|
this.wheelHooked = el;
|
||||||
|
}
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.ro && this.ro.disconnect();
|
||||||
|
this._unwatchTheme && this._unwatchTheme();
|
||||||
|
window.removeEventListener('keydown', this.keyN);
|
||||||
|
}
|
||||||
|
world(e) { const r = this.svgRef.current.getBoundingClientRect(); return { x: (e.clientX - r.left - this.state.tx) / this.state.s, y: (e.clientY - r.top - this.state.ty) / this.state.s }; }
|
||||||
|
snap(v) { return Math.round(v / 7.62) * 7.62; }
|
||||||
|
thresh(e) { return e && e.pointerType === 'touch' ? 7 : 3; }
|
||||||
|
pushUndo(label, before) {
|
||||||
|
const b = before || { objects: this.state.objects, plops: this.state.plops };
|
||||||
|
this.setState(st => ({ undo: [...st.undo.slice(-29), { label, when: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), objects: b.objects, plops: b.plops }] }));
|
||||||
|
}
|
||||||
|
onUndo = () => { const u = this.state.undo; if (!u.length) return; const last = u[u.length - 1]; this.setState({ objects: last.objects, plops: last.plops, undo: u.slice(0, -1), sel: null, ghost: null }); };
|
||||||
|
unanim() { clearTimeout(this._at); this._at = setTimeout(() => this.setState({ anim: false }), 520); }
|
||||||
|
zoomFit = () => {
|
||||||
|
const el = this.svgRef.current; if (!el) return;
|
||||||
|
const pad = this.isMobileNow() ? 20 : 40;
|
||||||
|
const s = Math.min((el.clientWidth - pad * 2) / this.GW, (el.clientHeight - pad * 2) / this.GH);
|
||||||
|
if (!isFinite(s) || s <= 0) return;
|
||||||
|
this._fitW = el.clientWidth; this._fitH = el.clientHeight;
|
||||||
|
this.setState({ s, tx: (el.clientWidth - this.GW * s) / 2, ty: (el.clientHeight - this.GH * s) / 2, anim: true }); this.unanim();
|
||||||
|
};
|
||||||
|
zoomBy(f) {
|
||||||
|
const el = this.svgRef.current, { tx, ty, s } = this.state;
|
||||||
|
const ns = Math.min(8, Math.max(0.12, s * f)), px = el.clientWidth / 2, py = el.clientHeight / 2;
|
||||||
|
this.setState({ s: ns, tx: px - (px - tx) / s * ns, ty: py - (py - ty) / s * ns, anim: true }); this.unanim();
|
||||||
|
}
|
||||||
|
zoomIn = () => this.zoomBy(1.45); zoomOut = () => this.zoomBy(1 / 1.45);
|
||||||
|
focusObj(o) {
|
||||||
|
const el = this.svgRef.current, m = this.isMobileNow();
|
||||||
|
const bw = o.rot % 180 ? o.h : o.w, bh = o.rot % 180 ? o.w : o.h;
|
||||||
|
const s = Math.min(6, Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))));
|
||||||
|
this.setState({ focus: o.id, sel: m ? null : { t: 'obj', id: o.id }, armK: null, ghost: null, tab: 'plot', mode: m ? 'plants' : this.state.mode, anim: true, s, tx: el.clientWidth / 2 - o.x * s, ty: el.clientHeight / 2 - o.y * s + (m ? 0 : 10) }); this.unanim();
|
||||||
|
}
|
||||||
|
back = () => { this.setState({ focus: null, armP: null, sel: null, q: '', mode: this.isMobileNow() ? 'build' : this.state.mode }); this.zoomFit(); };
|
||||||
|
toLocal(o, w) { const a = -o.rot * Math.PI / 180, dx = w.x - o.x, dy = w.y - o.y; return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) }; }
|
||||||
|
objAt(w) {
|
||||||
|
const os = this.state.objects;
|
||||||
|
for (let i = os.length - 1; i >= 0; i--) {
|
||||||
|
const o = os[i], l = this.toLocal(o, w);
|
||||||
|
if (o.shape === 'circle' ? Math.hypot(l.x, l.y) <= o.w / 2 : Math.abs(l.x) <= o.w / 2 && Math.abs(l.y) <= o.h / 2) return o;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
placeObj(w) {
|
||||||
|
if (this.readOnly) return;
|
||||||
|
const kd = this.KINDS.find(k => k.kind === this.state.armK);
|
||||||
|
this.pushUndo('Added a ' + kd.label.toLowerCase());
|
||||||
|
const id = this.nid++;
|
||||||
|
const n = this.state.objects.filter(x => x.kind === kd.kind).length + 1;
|
||||||
|
const no = { id, kind: kd.kind, name: kd.label + ' ' + n, shape: kd.shape, x: this.snap(w.x), y: this.snap(w.y), w: kd.w, h: kd.h, rot: 0, plantable: kd.plantable };
|
||||||
|
this.setState(st => ({ objects: [...st.objects, no], armK: null, ghost: null, sel: { t: 'obj', id } }));
|
||||||
|
}
|
||||||
|
placePlop(objId, l) {
|
||||||
|
if (this.readOnly) return;
|
||||||
|
const o = this.obj(objId), pl = this.plant(this.state.armP || this._dropPlant);
|
||||||
|
if (!o || !pl) return;
|
||||||
|
const r = pl.spacing / 2;
|
||||||
|
const cx = Math.max(-(o.w / 2 - r / 2), Math.min(o.w / 2 - r / 2, l.x)), cy = Math.max(-(o.h / 2 - r / 2), Math.min(o.h / 2 - r / 2, l.y));
|
||||||
|
this.pushUndo('Planted ' + pl.name.toLowerCase() + ' in ' + o.name);
|
||||||
|
const id = this.nid++;
|
||||||
|
this.setState(st => ({ plops: [...st.plops, { id, objId, plantId: pl.id, lx: cx, ly: cy, r }], sel: this.isMobileNow() ? st.sel : { t: 'plop', id } }));
|
||||||
|
}
|
||||||
|
pinchStart() {
|
||||||
|
const [a, b] = [...this.pts.values()];
|
||||||
|
this.drag = null;
|
||||||
|
this.pinch = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: this.state.s, tx0: this.state.tx, ty0: this.state.ty };
|
||||||
|
}
|
||||||
|
onCanvasDown = (e) => {
|
||||||
|
const r = this.svgRef.current.getBoundingClientRect();
|
||||||
|
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||||
|
try { this.svgRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
if (this.pts.size === 2) { this.pinchStart(); return; }
|
||||||
|
const st = this.state, w = this.world(e);
|
||||||
|
if (st.armK) { this.placeObj(w); return; }
|
||||||
|
if (st.armP) {
|
||||||
|
const o = st.focus ? this.obj(st.focus) : this.objAt(w);
|
||||||
|
if (o && o.plantable) this.placePlop(o.id, this.toLocal(o, w));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.drag) this.drag = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.tx, ty: st.ty, moved: false };
|
||||||
|
};
|
||||||
|
objDown(o) {
|
||||||
|
return (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const r = this.svgRef.current.getBoundingClientRect();
|
||||||
|
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||||
|
try { this.svgRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
if (this.pts.size === 2) { this.pinchStart(); return; }
|
||||||
|
const st = this.state;
|
||||||
|
if (st.armK) { this.placeObj(this.world(e)); return; }
|
||||||
|
if (st.armP) { if (o.plantable) this.placePlop(o.id, this.toLocal(o, this.world(e))); return; }
|
||||||
|
if (st.focus && o.id !== st.focus) return;
|
||||||
|
if (this.readOnly) { this.setState({ sel: { t: 'obj', id: o.id } }); return; }
|
||||||
|
const w = this.world(e);
|
||||||
|
this.drag = { t: 'obj', id: o.id, ox: o.x, oy: o.y, sx: w.x, sy: w.y, moved: false, th: this.thresh(e), before: { objects: st.objects, plops: st.plops } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
plopDown(p) {
|
||||||
|
return (e) => {
|
||||||
|
const st = this.state;
|
||||||
|
if (st.armK || st.armP) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if (st.focus !== p.objId) { this.objDown(this.obj(p.objId))(e); return; }
|
||||||
|
const r = this.svgRef.current.getBoundingClientRect();
|
||||||
|
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||||
|
if (this.readOnly) { this.setState({ sel: { t: 'plop', id: p.id } }); return; }
|
||||||
|
const w = this.world(e), o = this.obj(p.objId), l = this.toLocal(o, w);
|
||||||
|
this.drag = { t: 'plop', id: p.id, ox: p.lx, oy: p.ly, sx: l.x, sy: l.y, moved: false, th: this.thresh(e), before: { objects: st.objects, plops: st.plops } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onCanvasMove = (e) => {
|
||||||
|
const r = this.svgRef.current.getBoundingClientRect();
|
||||||
|
if (this.pts.has(e.pointerId)) this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||||
|
if (this.pinch && this.pts.size >= 2) {
|
||||||
|
const [a, b] = [...this.pts.values()], p = this.pinch;
|
||||||
|
const d1 = Math.hypot(a.x - b.x, a.y - b.y), cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
|
||||||
|
const ns = Math.min(8, Math.max(0.12, p.s0 * (d1 / Math.max(1, p.d0))));
|
||||||
|
const wx = (p.cx0 - p.tx0) / p.s0, wy = (p.cy0 - p.ty0) / p.s0;
|
||||||
|
this.setState({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns, anim: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const st = this.state;
|
||||||
|
if (!this.drag) { if (st.armK && e.pointerType !== 'touch') { const w = this.world(e); this.setState({ ghost: { x: this.snap(w.x), y: this.snap(w.y) } }); } return; }
|
||||||
|
const d = this.drag;
|
||||||
|
if (d.t === 'pan') {
|
||||||
|
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > (d.th || 3)) d.moved = true;
|
||||||
|
this.setState({ tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy, anim: false });
|
||||||
|
} else if (d.t === 'obj') {
|
||||||
|
const w = this.world(e), nx = this.snap(d.ox + w.x - d.sx), ny = this.snap(d.oy + w.y - d.sy);
|
||||||
|
if (Math.hypot(w.x - d.sx, w.y - d.sy) > (d.th || 3)) d.moved = true;
|
||||||
|
this.setState(s2 => ({ objects: s2.objects.map(o => o.id === d.id ? { ...o, x: nx, y: ny } : o) }));
|
||||||
|
} else if (d.t === 'plop') {
|
||||||
|
const p0 = this.state.plops.find(p => p.id === d.id); if (!p0) return;
|
||||||
|
const o = this.obj(p0.objId), l = this.toLocal(o, this.world(e));
|
||||||
|
const nx = Math.max(-(o.w / 2 - p0.r / 2), Math.min(o.w / 2 - p0.r / 2, d.ox + l.x - d.sx));
|
||||||
|
const ny = Math.max(-(o.h / 2 - p0.r / 2), Math.min(o.h / 2 - p0.r / 2, d.oy + l.y - d.sy));
|
||||||
|
if (Math.hypot(l.x - d.sx, l.y - d.sy) > (d.th || 3)) d.moved = true;
|
||||||
|
this.setState(s2 => ({ plops: s2.plops.map(p => p.id === d.id ? { ...p, lx: nx, ly: ny } : p) }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onCanvasUp = (e) => {
|
||||||
|
this.pts.delete(e.pointerId);
|
||||||
|
if (this.pinch) { if (this.pts.size < 2) this.pinch = null; return; }
|
||||||
|
const d = this.drag; this.drag = null;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.t === 'pan') { if (!d.moved) this.setState({ sel: null }); return; }
|
||||||
|
if (!d.moved) { this.setState({ sel: { t: d.t, id: d.id }, tab: 'plot' }); return; }
|
||||||
|
this.pushUndo(d.t === 'obj' ? 'Moved ' + (this.obj(d.id) || {}).name : 'Moved a planting', d.before);
|
||||||
|
};
|
||||||
|
deleteSel = () => {
|
||||||
|
if (this.readOnly) return;
|
||||||
|
const { sel } = this.state; if (!sel) return;
|
||||||
|
this.pushUndo(sel.t === 'obj' ? 'Removed ' + (this.obj(sel.id) || {}).name : 'Pulled a planting');
|
||||||
|
if (sel.t === 'obj') this.setState(st => ({ objects: st.objects.filter(o => o.id !== sel.id), plops: st.plops.filter(p => p.objId !== sel.id), sel: null, focus: st.focus === sel.id ? null : st.focus }));
|
||||||
|
else this.setState(st => ({ plops: st.plops.filter(p => p.id !== sel.id), sel: null }));
|
||||||
|
};
|
||||||
|
onDragOver = (e) => e.preventDefault();
|
||||||
|
onDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = e.dataTransfer.getData('text/plain'); if (!data) return;
|
||||||
|
const [t, id] = data.split(':'), w = this.world(e);
|
||||||
|
if (t === 'kind') this.setState({ armK: id }, () => this.placeObj(w));
|
||||||
|
if (t === 'plant') { const o = this.objAt(w); if (o && o.plantable) { this._dropPlant = id; this.placePlop(o.id, this.toLocal(o, w)); this._dropPlant = null; } }
|
||||||
|
};
|
||||||
|
addJournal = () => {
|
||||||
|
const txt = this.state.jr.trim(); if (!txt) return;
|
||||||
|
const sel = this.state.sel, target = sel && sel.t === 'obj' && this.obj(sel.id) ? this.obj(sel.id).name : (this.state.focus ? this.obj(this.state.focus).name : 'Garden');
|
||||||
|
this.setState(st => ({ entries: [{ d: 'Aug 22', obj: target, txt }, ...st.entries], jr: '' }));
|
||||||
|
};
|
||||||
|
sendChat = () => {
|
||||||
|
const txt = this.state.chat.trim(); if (!txt) return;
|
||||||
|
this.setState(st => ({ msgs: [...st.msgs, { who: 'u', txt }], chat: '' }));
|
||||||
|
setTimeout(() => this.setState(st => ({ msgs: [...st.msgs, { who: 'a', txt: '(demo) In the real app I act on your live garden — and each of my turns lands as one undoable step in History.' }] })), 550);
|
||||||
|
};
|
||||||
|
themeMount() {
|
||||||
|
const PT = window.PansyTheme; if (!PT) return;
|
||||||
|
this.setState({ themePref: PT.get() });
|
||||||
|
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||||
|
}
|
||||||
|
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||||
|
themeVals() {
|
||||||
|
const p = this.state.themePref || 'system';
|
||||||
|
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||||
|
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||||
|
}
|
||||||
|
renderVals() {
|
||||||
|
const st = this.state, s = st.s;
|
||||||
|
const isMobile = this.isMobileNow(), isDesktop = !isMobile;
|
||||||
|
const showGrid = this.props.showGrid ?? true;
|
||||||
|
const markers = this.props.markers ?? 'monogram';
|
||||||
|
const focused = st.focus ? this.obj(st.focus) : null;
|
||||||
|
const objectsR = st.objects.map(o => {
|
||||||
|
const sty = this.styleFor(o.kind), isSel = st.sel && st.sel.t === 'obj' && st.sel.id === o.id;
|
||||||
|
return {
|
||||||
|
key: o.id, isRect: o.shape === 'rect', isCircle: o.shape === 'circle',
|
||||||
|
x0: -o.w / 2, y0: -o.h / 2, w: o.w, h: o.h, rr: Math.min(14, o.w * 0.14), r: o.w / 2,
|
||||||
|
fill: sty.fill, stroke: isSel ? 'var(--color-accent)' : sty.stroke, sw: (isSel ? 3.5 : 2.5) / s, dash: sty.dash,
|
||||||
|
transform: `translate(${o.x} ${o.y}) rotate(${o.rot})`,
|
||||||
|
opacity: st.focus && o.id !== st.focus ? 0.3 : 1,
|
||||||
|
onDown: this.objDown(o), onDbl: o.plantable ? () => this.focusObj(o) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const plopsR = st.plops.map(p => {
|
||||||
|
const o = this.obj(p.objId); if (!o) return null;
|
||||||
|
const pl = this.plant(p.plantId), a = o.rot * Math.PI / 180;
|
||||||
|
const wx = o.x + p.lx * Math.cos(a) - p.ly * Math.sin(a), wy = o.y + p.lx * Math.sin(a) + p.ly * Math.cos(a);
|
||||||
|
const isSel = st.sel && st.sel.t === 'plop' && st.sel.id === p.id;
|
||||||
|
return {
|
||||||
|
key: p.id, transform: `translate(${wx} ${wy})`, r: p.r, fill: pl.color,
|
||||||
|
stroke: isSel ? '#fffaf1' : 'none', sw: 2.5 / s,
|
||||||
|
opacity: st.focus && p.objId !== st.focus ? 0.22 : 0.94,
|
||||||
|
gStyle: { cursor: st.focus === p.objId ? 'grab' : 'inherit' },
|
||||||
|
onDown: this.plopDown(p),
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
const T = (props2, str) => React.createElement('text', props2, str);
|
||||||
|
const labels = [];
|
||||||
|
st.objects.forEach(o => {
|
||||||
|
if (!o.name || Math.max(o.w, o.h) * s <= 54 || (st.focus && st.focus !== o.id) || s <= 0.28) return;
|
||||||
|
const a = o.rot * Math.PI / 180, bh = (Math.abs(o.w * Math.sin(a)) + Math.abs(o.h * Math.cos(a))) / 2;
|
||||||
|
labels.push(o.plantable
|
||||||
|
? T({ key: 'ol' + o.id, x: o.x, y: o.y - bh - 9 / s, textAnchor: 'middle', fontSize: 13 / s, fill: 'var(--p-ink-soft)', style: { fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' } }, o.name)
|
||||||
|
: T({ key: 'ol' + o.id, x: o.x, y: o.y, textAnchor: 'middle', dominantBaseline: 'central', fontSize: 13 / s, fill: 'var(--p-ink-mute)', style: { fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' } }, o.name));
|
||||||
|
});
|
||||||
|
if (markers === 'monogram') st.plops.forEach(p => {
|
||||||
|
const o = this.obj(p.objId); if (!o || (st.focus && p.objId !== st.focus)) return;
|
||||||
|
const pl = this.plant(p.plantId), a = o.rot * Math.PI / 180;
|
||||||
|
const wx = o.x + p.lx * Math.cos(a) - p.ly * Math.sin(a), wy = o.y + p.lx * Math.sin(a) + p.ly * Math.cos(a);
|
||||||
|
if (p.r * s >= 9) labels.push(T({ key: 'pl' + p.id, x: wx, y: wy, textAnchor: 'middle', dominantBaseline: 'central', fontSize: p.r * 1.05, fill: '#fffaf1', style: { fontFamily: 'var(--font-heading)' } }, pl.letter));
|
||||||
|
if (p.r * s >= 34) labels.push(T({ key: 'pn' + p.id, x: wx, y: wy + p.r + 13 / s, textAnchor: 'middle', fontSize: 11 / s, fill: 'var(--p-ink-strong)', style: { fontFamily: 'var(--font-body)', fontWeight: 600 } }, pl.name));
|
||||||
|
});
|
||||||
|
let selO = null, insp = null;
|
||||||
|
if (st.sel && st.sel.t === 'obj') {
|
||||||
|
const o = this.obj(st.sel.id);
|
||||||
|
if (o) {
|
||||||
|
const a = o.rot * Math.PI / 180, bh = (Math.abs(o.w * Math.sin(a)) + Math.abs(o.h * Math.cos(a))) / 2;
|
||||||
|
labels.push(T({ key: 'sz', x: o.x, y: o.y + bh + 22 / s, textAnchor: 'middle', fontSize: 12.5 / s, fill: 'var(--color-accent-700)', style: { fontFamily: 'var(--font-body)', fontWeight: 700 } }, this.fmt(o.w) + ' × ' + this.fmt(o.h)));
|
||||||
|
selO = {
|
||||||
|
transform: `translate(${o.x} ${o.y}) rotate(${o.rot})`, isRect: o.shape === 'rect', isCircle: o.shape === 'circle',
|
||||||
|
x0: -o.w / 2 - 7 / s, y0: -o.h / 2 - 7 / s, w: o.w + 14 / s, h: o.h + 14 / s, r: o.w / 2 + 7 / s, rr: 16,
|
||||||
|
sw: 1.8 / s, dash: `${8 / s} ${6 / s}`,
|
||||||
|
};
|
||||||
|
const roster = {};
|
||||||
|
st.plops.filter(p => p.objId === o.id).forEach(p => { const n = this.plant(p.plantId).name; roster[n] = (roster[n] || 0) + this.cnt(p); });
|
||||||
|
const names = Object.entries(roster).map(([n, c]) => c + ' ' + n.toLowerCase()).join(' · ');
|
||||||
|
insp = {
|
||||||
|
isObj: true, name: o.name, kindLabel: this.KINDS.find(k => k.kind === o.kind).label,
|
||||||
|
sizeText: this.fmt(o.w) + ' × ' + this.fmt(o.h), plantable: o.plantable && st.focus !== o.id,
|
||||||
|
rosterText: names ? 'Growing: ' + names : (o.plantable ? 'Nothing planted yet.' : ''),
|
||||||
|
onRename: (e) => this.setState(s2 => ({ objects: s2.objects.map(x => x.id === o.id ? { ...x, name: e.target.value } : x) })),
|
||||||
|
onRotate: () => { if (this.readOnly) return; this.pushUndo('Rotated ' + o.name); this.setState(s2 => ({ objects: s2.objects.map(x => x.id === o.id ? { ...x, rot: (x.rot + 90) % 360 } : x) })); },
|
||||||
|
onDelete: this.deleteSel, onOpen: () => this.focusObj(o),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (st.sel && st.sel.t === 'plop') {
|
||||||
|
const p = st.plops.find(x => x.id === st.sel.id);
|
||||||
|
if (p) {
|
||||||
|
const pl = this.plant(p.plantId), c = this.cnt(p);
|
||||||
|
insp = { isPlop: true, plantName: pl.name, plantColor: pl.color, countText: c + (c === 1 ? ' plant' : ' plants'), plopSize: this.fmt(p.r * 2), onDelete: this.deleteSel };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let summary = null;
|
||||||
|
if (!insp) {
|
||||||
|
const roster = {};
|
||||||
|
st.plops.forEach(p => {
|
||||||
|
const pl = this.plant(p.plantId), o = this.obj(p.objId); if (!o) return;
|
||||||
|
if (!roster[pl.id]) roster[pl.id] = { name: pl.name, color: pl.color, n: 0, beds: new Set() };
|
||||||
|
roster[pl.id].n += this.cnt(p); roster[pl.id].beds.add(o.name);
|
||||||
|
});
|
||||||
|
const beds = st.objects.filter(o => o.kind === 'bed').length, bags = st.objects.filter(o => o.kind === 'grow_bag').length, bkt = st.objects.filter(o => o.kind === 'container').length;
|
||||||
|
summary = {
|
||||||
|
countsText: `${beds} beds · ${bags} grow bags · ${bkt} buckets · ${st.plops.length} plantings`,
|
||||||
|
roster: Object.values(roster).map((r, i) => ({ key: i, name: r.name, color: r.color, where: r.n + ' in ' + [...r.beds][0] + ([...r.beds].length > 1 ? ' +' + ([...r.beds].length - 1) : '') })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const kindsR = this.KINDS.map(k => {
|
||||||
|
const sty = this.styleFor(k.kind), f = 24 / Math.max(k.w, k.h), armed = st.armK === k.kind;
|
||||||
|
return {
|
||||||
|
key: k.kind, label: k.label, sizeText: this.fmt(k.w) + ' × ' + this.fmt(k.h), isRect: k.shape === 'rect', isCircle: k.shape === 'circle',
|
||||||
|
mx0: -k.w * f / 2, my0: -k.h * f / 2, mw: k.w * f, mh: k.h * f, mr: k.w * f / 2,
|
||||||
|
fill: sty.fill, stroke: sty.stroke, mdash: sty.dash ? '3 3' : undefined,
|
||||||
|
btnStyle: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 10px', cursor: 'pointer', textAlign: 'left', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'transparent', border: armed ? '1px solid var(--color-accent-400)' : '1px solid transparent' },
|
||||||
|
mStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', minHeight: '44px', flex: 'none', cursor: 'pointer', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-neutral-100)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||||
|
onClick: () => this.setState({ armK: armed ? null : k.kind, armP: null, sel: null, ghost: null }),
|
||||||
|
onDrag: (e) => e.dataTransfer.setData('text/plain', 'kind:' + k.kind),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const ql = st.q.trim().toLowerCase();
|
||||||
|
const plantsR = this.PLANTS.filter(pl => !ql || pl.name.toLowerCase().includes(ql)).map(pl => {
|
||||||
|
const armed = st.armP === pl.id;
|
||||||
|
return {
|
||||||
|
key: pl.id, name: pl.name, color: pl.color, spacingText: this.fmt(pl.spacing) + ' apart',
|
||||||
|
btnStyle: { display: 'flex', alignItems: 'center', gap: '9px', padding: '8px 12px', cursor: 'pointer', textAlign: 'left', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-bg)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||||
|
mStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', minHeight: '44px', flex: 'none', cursor: 'pointer', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-neutral-100)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||||
|
onClick: () => this.setState({ armP: armed ? null : pl.id, armK: null, sel: null }),
|
||||||
|
onDrag: (e) => e.dataTransfer.setData('text/plain', 'plant:' + pl.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let ghostR = null;
|
||||||
|
if (st.ghost && st.armK) {
|
||||||
|
const kd = this.KINDS.find(k => k.kind === st.armK), sty = this.styleFor(kd.kind);
|
||||||
|
ghostR = { transform: `translate(${st.ghost.x} ${st.ghost.y})`, isRect: kd.shape === 'rect', isCircle: kd.shape === 'circle', x0: -kd.w / 2, y0: -kd.h / 2, w: kd.w, h: kd.h, r: kd.w / 2, fill: sty.fill, sw: 2 / s, dash: `${7 / s} ${5 / s}` };
|
||||||
|
}
|
||||||
|
const tabs = [['plot', 'Plot'], ['journal', 'Journal'], ['history', 'History'], ['chat', 'Assistant']];
|
||||||
|
const tabsR = tabs.map(([id, label]) => ({
|
||||||
|
key: id, label,
|
||||||
|
style: { flex: 1, padding: '8px 4px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.tab === id ? 'var(--color-accent-200)' : 'transparent', color: st.tab === id ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
|
||||||
|
onClick: () => this.setState({ tab: id }),
|
||||||
|
}));
|
||||||
|
const seasonsR = ['2025', '2026', '2027 plan'].map(sn => ({
|
||||||
|
key: sn, label: sn,
|
||||||
|
style: { padding: '5px 13px', cursor: 'pointer', border: 'none', borderRadius: '999px', whiteSpace: 'nowrap', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.season === sn ? 'var(--color-neutral-100)' : 'transparent', color: st.season === sn ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: st.season === sn ? 'var(--shadow-sm)' : 'none' },
|
||||||
|
onClick: () => this.setState({ season: sn }),
|
||||||
|
}));
|
||||||
|
const msgsR = st.msgs.map((m, i) => ({
|
||||||
|
key: i, txt: m.txt,
|
||||||
|
style: m.who === 'u'
|
||||||
|
? { alignSelf: 'flex-end', maxWidth: '85%', background: 'var(--color-accent-200)', color: 'var(--color-accent-900)', borderRadius: '18px 18px 4px 18px', padding: '9px 13px', fontSize: '13px', lineHeight: 1.45 }
|
||||||
|
: { alignSelf: 'flex-start', maxWidth: '90%', background: 'var(--color-bg)', border: '1px solid var(--color-divider)', borderRadius: '18px 18px 18px 4px', padding: '9px 13px', fontSize: '13px', lineHeight: 1.45 },
|
||||||
|
}));
|
||||||
|
const banner = st.season === '2025' ? '2025 is a past season — read-only.'
|
||||||
|
: st.season === '2027 plan' ? 'Editing the 2027 plan — a separate copy. 2026 stays untouched.' : null;
|
||||||
|
const modes = [
|
||||||
|
['build', 'Build', 'M2 22v-5l5-5 5 5-5 5z', 'M9.5 14.5 16 8', 'm17 2 5 5-.5.5a3.53 3.53 0 0 1-5 0v0a3.53 3.53 0 0 1 0-5L17 2'],
|
||||||
|
['plants', 'Plants', 'M7 20h10', 'M10 20c5.5-2.5.8-6.4 3-10', 'M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z'],
|
||||||
|
['journal', 'Journal', 'M2 6h4', 'M2 10h4M2 14h4M2 18h4', 'M8 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z'],
|
||||||
|
['chat', 'Assistant', 'M7.9 20A9 9 0 1 0 4 16.1L2 22Z', '', ''],
|
||||||
|
];
|
||||||
|
const modesR = modes.map(([id, label, d1, d2, d3]) => {
|
||||||
|
const active = st.mode === id && !st.sel;
|
||||||
|
return {
|
||||||
|
key: id, label, d1, d2, d3,
|
||||||
|
style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', padding: '8px 4px', minHeight: '52px', cursor: 'pointer', border: 'none', borderRadius: '16px', fontFamily: 'var(--font-body)', background: active ? 'var(--color-accent-200)' : 'transparent', color: active ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
|
||||||
|
onClick: () => this.setState(s2 => ({ mode: (s2.mode === id && (id === 'journal' || id === 'chat')) ? 'build' : id, sel: null, armK: null, armP: id === 'plants' ? s2.armP : null })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const mPeekInsp = isMobile && !!insp;
|
||||||
|
const mPeekJournal = isMobile && !insp && st.mode === 'journal';
|
||||||
|
const mPeekChat = isMobile && !insp && st.mode === 'chat';
|
||||||
|
const mPeek = mPeekInsp || mPeekJournal || mPeekChat;
|
||||||
|
const mStripKinds = isMobile && !mPeek && st.mode === 'build';
|
||||||
|
const mStripPlants = isMobile && !mPeek && st.mode === 'plants';
|
||||||
|
return {
|
||||||
|
...this.themeVals(),
|
||||||
|
rootRef: this.rootRef, svgRef: this.svgRef, isMobile, isDesktop,
|
||||||
|
gw: this.GW, gh: this.GH, gridMinor: showGrid ? this.gridMinor : '', gridMajor: showGrid ? this.gridMajor : '',
|
||||||
|
borderW: 3 / s, hairW: 1 / s, labelsLayer: labels,
|
||||||
|
svgStyle: { display: 'block', touchAction: 'none', cursor: st.armK || st.armP ? 'crosshair' : 'default' },
|
||||||
|
viewStyle: { transform: `translate(${st.tx}px, ${st.ty}px) scale(${s})`, transformOrigin: '0 0', transition: st.anim ? 'transform .48s cubic-bezier(.22,.85,.3,1)' : 'none' },
|
||||||
|
objectsR, plopsR, selO, ghostR, kindsR, plantsR, insp, summary,
|
||||||
|
onCanvasDown: this.onCanvasDown, onCanvasMove: this.onCanvasMove, onCanvasUp: this.onCanvasUp,
|
||||||
|
onDragOver: this.onDragOver, onDrop: this.onDrop,
|
||||||
|
notFocus: !st.focus, focusName: focused ? focused.name : null, onBack: this.back,
|
||||||
|
q: st.q, onQ: (e) => this.setState({ q: e.target.value }),
|
||||||
|
zoomIn: this.zoomIn, zoomOut: this.zoomOut, zoomFit: this.zoomFit,
|
||||||
|
onUndo: this.onUndo, undoDisabled: !st.undo.length,
|
||||||
|
tabsR, seasonsR, banner,
|
||||||
|
tabPlot: st.tab === 'plot', tabJournal: st.tab === 'journal', tabHistory: st.tab === 'history', tabChat: st.tab === 'chat',
|
||||||
|
entriesR: st.entries.map((e, i) => ({ key: i, ...e })),
|
||||||
|
jr: st.jr, onJr: (e) => this.setState({ jr: e.target.value }), onAddJr: this.addJournal,
|
||||||
|
onJrKey: (e) => { if (e.key === 'Enter') this.addJournal(); },
|
||||||
|
jrPlaceholder: focused ? 'Note something about ' + focused.name + '…' : 'Note something…',
|
||||||
|
historyR: [...st.undo].reverse().map((h, i) => ({ key: i, label: h.label, when: h.when })),
|
||||||
|
historyEmpty: !st.undo.length,
|
||||||
|
msgsR, chat: st.chat, onChat: (e) => this.setState({ chat: e.target.value }), onSend: this.sendChat,
|
||||||
|
onChatKey: (e) => { if (e.key === 'Enter') this.sendChat(); },
|
||||||
|
mTitle: focused ? focused.name : 'Home Garden',
|
||||||
|
seasonShort: st.season === '2027 plan' ? '27 plan' : st.season.slice(2),
|
||||||
|
onCycleSeason: () => { const order = ['2025', '2026', '2027 plan']; this.setState(s2 => ({ season: order[(order.indexOf(s2.season) + 1) % 3] })); },
|
||||||
|
mPeek, mPeekInsp, mPeekJournal, mPeekChat,
|
||||||
|
mPeekTitle: mPeekInsp ? (insp.isObj ? 'Selected' : 'Planting') : mPeekJournal ? 'Journal' : 'Assistant',
|
||||||
|
onPeekClose: () => this.setState(s2 => ({ sel: null, mode: (s2.mode === 'journal' || s2.mode === 'chat') ? 'build' : s2.mode })),
|
||||||
|
mStrip: mStripKinds || mStripPlants, mStripKinds, mStripPlants, mStripDone: mStripPlants && !!st.focus,
|
||||||
|
modesR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||||
|
<script src="pansy-theme.js"></script>
|
||||||
|
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||||
|
html,body{margin:0;min-height:100%}
|
||||||
|
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div data-screen-label="Gardens" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||||
|
pansy
|
||||||
|
</span>
|
||||||
|
<a href="Pansy Gardens.dc.html" aria-current="page">Gardens</a>
|
||||||
|
<a href="Pansy Plants.dc.html">Plants</a>
|
||||||
|
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||||
|
</button>
|
||||||
|
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||||
|
</a>
|
||||||
|
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div style="max-width:1080px;margin:0 auto;padding:28px 24px 56px">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:20px">
|
||||||
|
<h2 style="margin:0">Gardens</h2>
|
||||||
|
<span style="font-size:14px;color:var(--p-ink-mute);font-weight:600">Every plot you tend — and the ones you're scheming</span>
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px;margin-left:auto" onClick="{{ onNew }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||||
|
New garden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px">
|
||||||
|
<sc-for list="{{ cards }}" as="g" hint-placeholder-count="3">
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column" style-hover="box-shadow:var(--shadow-md)">
|
||||||
|
<a href="Pansy Editor.dc.html" style="display:block;background:var(--p-field);border-bottom:1px solid var(--color-divider);text-decoration:none">
|
||||||
|
<svg viewBox="{{ g.vb }}" style="display:block;width:100%;height:150px">
|
||||||
|
<sc-for list="{{ g.thumb }}" as="t" hint-placeholder-count="4">
|
||||||
|
<sc-if value="{{ t.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ t.x }}" y="{{ t.y }}" width="{{ t.w }}" height="{{ t.h }}" rx="{{ t.rr }}" fill="{{ t.fill }}" stroke="{{ t.stroke }}" stroke-width="{{ t.sw }}" stroke-dasharray="{{ t.dash }}"></rect></sc-if>
|
||||||
|
<sc-if value="{{ t.isCircle }}" hint-placeholder-val="{{ false }}"><circle cx="{{ t.cx }}" cy="{{ t.cy }}" r="{{ t.r }}" fill="{{ t.fill }}" stroke="{{ t.stroke }}" stroke-width="{{ t.sw }}"></circle></sc-if>
|
||||||
|
</sc-for>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:8px;flex:1">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="font-family:var(--font-heading);font-size:18px">{{ g.name }}</span>
|
||||||
|
<sc-if value="{{ g.planTag }}" hint-placeholder-val="{{ false }}"><span class="tag tag-accent" style="border-radius:999px">{{ g.planTag }}</span></sc-if>
|
||||||
|
<span style="font-size:12.5px;color:var(--p-ink-mute);font-weight:600;margin-left:auto">{{ g.sizeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--p-ink-soft);line-height:1.5">{{ g.meta }}</div>
|
||||||
|
<sc-if value="{{ g.sharedText }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:12px;color:var(--color-accent-2-700);font-weight:600">{{ g.sharedText }}</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:auto;padding-top:8px">
|
||||||
|
<a href="Pansy Editor.dc.html" class="btn btn-primary" style="border-radius:999px;flex:1;text-decoration:none">Open</a>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Share" onClick="{{ g.onShare }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><path d="m8.6 10.6 6.8-3.9"></path><path d="m8.6 13.4 6.8 3.9"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Copy — plan a season from it" onClick="{{ g.onCopy }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Delete" onClick="{{ g.onDelete }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ dlgNew }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div class="dialog-backdrop" style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||||
|
<div class="dialog" style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(420px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||||
|
<h3 style="margin:0;font-size:22px">A new garden</h3>
|
||||||
|
<div class="field"><label>Name</label><input class="input" style="border-radius:999px" value="{{ nName }}" onChange="{{ onNName }}" placeholder="Back forty"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Width (ft)</label><input class="input" style="border-radius:999px" type="number" value="{{ nW }}" onChange="{{ onNW }}"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Depth (ft)</label><input class="input" style="border-radius:999px" type="number" value="{{ nH }}" onChange="{{ onNH }}"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--p-ink-mute)">Stored in centimeters under the hood — feet are just how you talk.</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Never mind</button>
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onCreate }}">Break ground</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ dlgShare }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div class="dialog-backdrop" style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||||
|
<div class="dialog" style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(440px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||||
|
<h3 style="margin:0;font-size:22px">Share {{ shareName }}</h3>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input class="input" style="border-radius:999px" placeholder="[email protected]" value="{{ shEmail }}" onChange="{{ onShEmail }}">
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px;flex:none" onClick="{{ onInvite }}">Invite</button>
|
||||||
|
</div>
|
||||||
|
<sc-for list="{{ shares }}" as="sh" hint-placeholder-count="1">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;background:var(--color-bg);border:1px solid var(--color-divider);border-radius:999px;padding:8px 8px 8px 16px">
|
||||||
|
<span style="font-size:13px;font-weight:600">{{ sh.email }}</span>
|
||||||
|
<button onClick="{{ sh.onRole }}" class="tag tag-accent-2" style="border-radius:999px;border:none;cursor:pointer;margin-left:auto;font-family:var(--font-body)" title="Click to switch role">{{ sh.role }}</button>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<div class="hr" style="margin:2px 0"></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<span style="font-size:13px;font-weight:600">Read-only link</span>
|
||||||
|
<button onClick="{{ onToggleLink }}" style="{{ linkToggleStyle }}">{{ linkToggleLabel }}</button>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ linkOn }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:12px;color:var(--p-ink-soft);background:var(--color-bg);border:1px dashed var(--color-divider);border-radius:999px;padding:8px 14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">pansy.example.com/p/gd_7Kx2mQ…</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex;justify-content:flex-end">
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const bedS = { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' };
|
||||||
|
const th = (isRect, x, y, w, h, sty, dash) => isRect
|
||||||
|
? { isRect: true, x: x - w / 2, y: y - h / 2, w, h, rr: Math.min(14, w * 0.14), fill: sty.fill, stroke: sty.stroke, sw: 5, dash }
|
||||||
|
: { isCircle: true, cx: x, cy: y, r: w / 2, fill: sty.fill, stroke: sty.stroke, sw: 5 };
|
||||||
|
const dot = (x, y, r, c) => ({ isCircle: true, cx: x, cy: y, r, fill: c, stroke: 'none', sw: 0 });
|
||||||
|
const homeThumb = [
|
||||||
|
{ isRect: true, x: 8, y: 8, w: 1203, h: 837, rr: 18, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 8 },
|
||||||
|
th(true, 560, 285, 1010, 75, { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)' }, '6 10'),
|
||||||
|
th(true, 110, 140, 91, 183, bedS), th(true, 320, 140, 91, 183, bedS), th(true, 530, 140, 91, 183, bedS), th(true, 740, 140, 91, 183, bedS), th(true, 950, 140, 91, 183, bedS),
|
||||||
|
th(true, 190, 392, 244, 61, bedS), th(true, 190, 502, 244, 61, bedS), th(true, 190, 612, 244, 61, bedS), th(true, 190, 722, 244, 61, bedS),
|
||||||
|
th(false, 1090, 110, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 1090, 165, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 1090, 220, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }),
|
||||||
|
dot(110, 90, 26, '#c8553d'), dot(110, 150, 26, '#c8553d'), dot(110, 200, 26, '#c8553d'),
|
||||||
|
dot(300, 110, 32, '#6f8f4f'), dot(340, 180, 32, '#6f8f4f'),
|
||||||
|
dot(510, 105, 38, '#46683c'), dot(550, 180, 38, '#46683c'),
|
||||||
|
dot(740, 140, 38, '#5f8f45'),
|
||||||
|
{ isRect: true, x: 80, y: 374, w: 220, h: 36, rr: 18, fill: '#97a97c', stroke: 'none', sw: 0 },
|
||||||
|
{ isRect: true, x: 80, y: 484, w: 220, h: 36, rr: 18, fill: '#b2622d', stroke: 'none', sw: 0 },
|
||||||
|
{ isRect: true, x: 80, y: 594, w: 220, h: 36, rr: 18, fill: '#d9912f', stroke: 'none', sw: 0 },
|
||||||
|
dot(1090, 110, 13, '#c2913a'), dot(1090, 165, 13, '#c2913a'), dot(1090, 220, 13, '#c2913a'),
|
||||||
|
];
|
||||||
|
const balconyThumb = [
|
||||||
|
{ isRect: true, x: 4, y: 4, w: 358, h: 175, rr: 12, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 4 },
|
||||||
|
th(false, 70, 90, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 130, 90, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }),
|
||||||
|
th(false, 195, 90, 55, 0, { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' }),
|
||||||
|
th(true, 295, 92, 100, 50, bedS),
|
||||||
|
dot(70, 90, 12, '#c8553d'), dot(130, 90, 12, '#5f8f45'), dot(195, 90, 14, '#b2622d'),
|
||||||
|
];
|
||||||
|
this.nid = 10;
|
||||||
|
this.state = {
|
||||||
|
cards: [
|
||||||
|
{ id: 1, name: 'Home Garden', vb: '0 0 1219 853', thumb: homeThumb, sizeText: '40\u2032 \u00d7 28\u2032', meta: '9 beds \u00b7 3 bags \u00b7 2 buckets \u00b7 136 plantings \u00b7 tended since 2024', sharedText: 'Shared with lauren@ \u00b7 editor', planTag: null },
|
||||||
|
{ id: 2, name: 'Home Garden \u2014 2027', vb: '0 0 1219 853', thumb: homeThumb, sizeText: '40\u2032 \u00d7 28\u2032', meta: 'A copy to scheme next season in \u2014 rearrange freely, 2026 stays put.', sharedText: null, planTag: 'plan' },
|
||||||
|
{ id: 3, name: 'Balcony', vb: '0 0 366 183', thumb: balconyThumb, sizeText: '12\u2032 \u00d7 6\u2032', meta: '2 bags \u00b7 1 bucket \u00b7 1 rail bed \u00b7 the overflow department', sharedText: null, planTag: null },
|
||||||
|
],
|
||||||
|
dlg: null, nName: '', nW: 20, nH: 12, shEmail: '', shareId: null, linkOn: true,
|
||||||
|
shares: [{ email: '[email protected]', role: 'editor' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
componentDidMount() { this.themeMount(); }
|
||||||
|
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||||
|
themeMount() {
|
||||||
|
const PT = window.PansyTheme; if (!PT) return;
|
||||||
|
this.setState({ themePref: PT.get() });
|
||||||
|
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||||
|
}
|
||||||
|
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||||
|
themeVals() {
|
||||||
|
const p = this.state.themePref || 'system';
|
||||||
|
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||||
|
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||||
|
}
|
||||||
|
renderVals() {
|
||||||
|
const st = this.state;
|
||||||
|
const stop = (e) => e.stopPropagation();
|
||||||
|
return {
|
||||||
|
...this.themeVals(),
|
||||||
|
cards: st.cards.map(g => ({
|
||||||
|
...g, key: g.id,
|
||||||
|
thumb: g.thumb.map((t, i) => ({ key: i, ...t })),
|
||||||
|
onShare: () => this.setState({ dlg: 'share', shareId: g.id }),
|
||||||
|
onCopy: () => this.setState(s2 => {
|
||||||
|
const n = { ...g, id: this.nid++, name: g.name.replace(/ — \d{4}$/, '') + ' — copy', planTag: 'plan', sharedText: null, meta: 'A fresh copy — objects and active plantings came along; shares didn\u2019t.' };
|
||||||
|
return { cards: [...s2.cards, n] };
|
||||||
|
}),
|
||||||
|
onDelete: () => this.setState(s2 => ({ cards: s2.cards.filter(c => c.id !== g.id) })),
|
||||||
|
})),
|
||||||
|
dlgNew: st.dlg === 'new', dlgShare: st.dlg === 'share',
|
||||||
|
shareName: (st.cards.find(c => c.id === st.shareId) || {}).name || '',
|
||||||
|
onNew: () => this.setState({ dlg: 'new', nName: '', nW: 20, nH: 12 }),
|
||||||
|
onCloseDlg: () => this.setState({ dlg: null }),
|
||||||
|
stop,
|
||||||
|
nName: st.nName, onNName: (e) => this.setState({ nName: e.target.value }),
|
||||||
|
nW: st.nW, onNW: (e) => this.setState({ nW: e.target.value }),
|
||||||
|
nH: st.nH, onNH: (e) => this.setState({ nH: e.target.value }),
|
||||||
|
onCreate: () => this.setState(s2 => ({
|
||||||
|
dlg: null,
|
||||||
|
cards: [...s2.cards, {
|
||||||
|
id: this.nid++, name: s2.nName.trim() || 'New garden', vb: '0 0 366 183',
|
||||||
|
thumb: [{ key: 0, isRect: true, x: 4, y: 4, w: 358, h: 175, rr: 12, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 4 }],
|
||||||
|
sizeText: s2.nW + '\u2032 \u00d7 ' + s2.nH + '\u2032', meta: 'Bare ground \u2014 drag your first bed on.', sharedText: null, planTag: null,
|
||||||
|
}],
|
||||||
|
})),
|
||||||
|
shEmail: st.shEmail, onShEmail: (e) => this.setState({ shEmail: e.target.value }),
|
||||||
|
onInvite: () => { const em = st.shEmail.trim(); if (!em) return; this.setState(s2 => ({ shares: [...s2.shares, { email: em, role: 'viewer' }], shEmail: '' })); },
|
||||||
|
shares: st.shares.map((sh, i) => ({
|
||||||
|
key: i, email: sh.email, role: sh.role,
|
||||||
|
onRole: () => this.setState(s2 => ({ shares: s2.shares.map((x, j) => j === i ? { ...x, role: x.role === 'viewer' ? 'editor' : 'viewer' } : x) })),
|
||||||
|
})),
|
||||||
|
linkOn: st.linkOn,
|
||||||
|
onToggleLink: () => this.setState(s2 => ({ linkOn: !s2.linkOn })),
|
||||||
|
linkToggleLabel: st.linkOn ? 'On' : 'Off',
|
||||||
|
linkToggleStyle: { marginLeft: 'auto', cursor: 'pointer', border: 'none', borderRadius: '999px', padding: '5px 16px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.linkOn ? 'var(--color-accent-2-300)' : 'var(--color-neutral-300)', color: st.linkOn ? 'var(--color-accent-2-800)' : 'var(--p-ink-soft)' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||||
|
<script src="pansy-theme.js"></script>
|
||||||
|
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||||
|
html,body{margin:0;min-height:100%}
|
||||||
|
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div data-screen-label="Login" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);display:flex;align-items:center;justify-content:center;padding:24px;position:relative;overflow:hidden">
|
||||||
|
<div style="position:absolute;width:520px;height:520px;border-radius:999px;background:var(--color-accent-2-200);top:-180px;right:-140px;opacity:0.55"></div>
|
||||||
|
<div style="position:absolute;width:340px;height:340px;border-radius:999px;background:var(--color-accent-200);bottom:-140px;left:-100px;opacity:0.5"></div>
|
||||||
|
<button class="btn btn-icon btn-secondary" style="position:absolute;top:18px;right:18px;border-radius:999px;background:var(--color-neutral-100)" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||||
|
</button>
|
||||||
|
<div style="width:min(400px,100%);display:flex;flex-direction:column;gap:22px;position:relative">
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center">
|
||||||
|
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||||
|
<h1 style="margin:0;font-size:40px">pansy</h1>
|
||||||
|
<p style="margin:0;font-size:14.5px;color:var(--p-ink-soft)">Plan the plot. Keep the notes. Grow the thing.</p>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);box-shadow:var(--shadow-md);padding:26px;display:flex;flex-direction:column;gap:14px">
|
||||||
|
<div class="field"><label>Email</label><input class="input" style="border-radius:999px;font-size:16px" type="email" placeholder="[email protected]"></div>
|
||||||
|
<div class="field"><label>Password</label><input class="input" style="border-radius:999px;font-size:16px" type="password" placeholder="••••••••••"></div>
|
||||||
|
<a href="Pansy Gardens.dc.html" class="btn btn-primary btn-block" style="border-radius:999px;text-decoration:none;margin-top:2px">Into the garden</a>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin:2px 0">
|
||||||
|
<span style="flex:1;height:1px;background:var(--color-divider)"></span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute)">or</span>
|
||||||
|
<span style="flex:1;height:1px;background:var(--color-divider)"></span>
|
||||||
|
</div>
|
||||||
|
<a href="Pansy Gardens.dc.html" class="btn btn-secondary btn-block" style="border-radius:999px;text-decoration:none;gap:8px">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||||
|
Sign in with Authentik
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;text-align:center;font-size:13px;color:var(--p-ink-soft)">New here? <a href="Pansy Gardens.dc.html" style="font-weight:700">Create an account</a> — the first one becomes admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { themePref: 'system' };
|
||||||
|
componentDidMount() {
|
||||||
|
const PT = window.PansyTheme; if (!PT) return;
|
||||||
|
this.setState({ themePref: PT.get() });
|
||||||
|
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||||
|
}
|
||||||
|
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||||
|
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||||
|
renderVals() {
|
||||||
|
const p = this.state.themePref || 'system';
|
||||||
|
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||||
|
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||||
|
<script src="pansy-theme.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||||
|
html,body{margin:0;min-height:100%}
|
||||||
|
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div data-screen-label="Phone preview" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:28px">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:12px">
|
||||||
|
<h3 style="margin:0">pansy in your pocket</h3>
|
||||||
|
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">same editor, one column — pinch to zoom, tap a bed, plant from the strip</span>
|
||||||
|
</div>
|
||||||
|
<x-import component-from-global-scope="IOSDevice" from="./ios-frame.jsx" hint-size="430px,880px">
|
||||||
|
<div style="height:100%;padding-top:60px;box-sizing:border-box">
|
||||||
|
<dc-import name="Pansy Editor" layout="phone" hint-size="100%,100%" style="width:100%;height:100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</x-import>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||||
|
<script src="pansy-theme.js"></script>
|
||||||
|
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||||
|
html,body{margin:0;min-height:100%}
|
||||||
|
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div data-screen-label="Plants" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||||
|
pansy
|
||||||
|
</span>
|
||||||
|
<a href="Pansy Gardens.dc.html">Gardens</a>
|
||||||
|
<a href="Pansy Plants.dc.html" aria-current="page">Plants</a>
|
||||||
|
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||||
|
</button>
|
||||||
|
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||||
|
</a>
|
||||||
|
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div style="max-width:1080px;margin:0 auto;padding:28px 24px 56px">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:18px">
|
||||||
|
<h2 style="margin:0">Plants</h2>
|
||||||
|
<span style="font-size:14px;color:var(--p-ink-mute);font-weight:600">The built-ins plus everything you've added</span>
|
||||||
|
<span style="margin-left:auto;display:flex;gap:8px">
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onScan }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>
|
||||||
|
Scan a packet
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onAdd }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||||
|
Add a plant
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:20px">
|
||||||
|
<input class="input" style="border-radius:999px;max-width:260px" placeholder="Find a plant…" value="{{ q }}" onChange="{{ onQ }}">
|
||||||
|
<sc-for list="{{ cats }}" as="c">
|
||||||
|
<button onClick="{{ c.onClick }}" style="{{ c.style }}">{{ c.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px">
|
||||||
|
<sc-for list="{{ cardsR }}" as="p" hint-placeholder-count="8">
|
||||||
|
<div onClick="{{ p.onClick }}" style="{{ p.cardStyle }}" style-hover="box-shadow:var(--shadow-md)">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<svg width="40" height="40" style="flex:none"><circle cx="20" cy="20" r="20" fill="{{ p.color }}"></circle></svg>
|
||||||
|
<span style="position:absolute;width:40px;text-align:center;color:#fffaf1;font-family:var(--font-heading);font-size:17px;pointer-events:none">{{ p.letter }}</span>
|
||||||
|
<span style="display:flex;flex-direction:column;gap:1px;min-width:0">
|
||||||
|
<span style="font-family:var(--font-heading);font-size:16.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ p.name }}</span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute);font-weight:600">{{ p.sub }}</span>
|
||||||
|
</span>
|
||||||
|
<sc-if value="{{ p.builtin }}" hint-placeholder-val="{{ false }}"><span class="tag tag-neutral" style="border-radius:999px;margin-left:auto;flex:none">built-in</span></sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12.5px;color:var(--p-ink-soft);margin-top:10px">{{ p.lotText }}</div>
|
||||||
|
<sc-if value="{{ p.open }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
|
||||||
|
<sc-for list="{{ p.lots }}" as="l" hint-placeholder-count="1">
|
||||||
|
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:10px 13px">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<span style="font-size:12.5px;font-weight:700">{{ l.vendor }}</span>
|
||||||
|
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">packed for {{ l.year }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--p-ink-soft);margin-top:3px">{{ l.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ p.noLots }}"><div style="font-size:12.5px;color:var(--p-ink-mute)">No seed lots yet — scan a packet to add one.</div></sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ dlgScan }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||||
|
<div style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(560px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||||
|
<h3 style="margin:0;font-size:22px">Scan a seed packet</h3>
|
||||||
|
<sc-if value="{{ scanStep0 }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="border:2px dashed var(--color-neutral-400);border-radius:var(--radius-lg);padding:36px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center">
|
||||||
|
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>
|
||||||
|
<div style="font-size:14px;font-weight:600">Photograph the packet — front is enough</div>
|
||||||
|
<div style="font-size:12.5px;color:var(--p-ink-mute);max-width:36ch">The vision model reads it into fields. It only reads; nothing is saved until you confirm.</div>
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px;margin-top:4px" onClick="{{ onScanGo }}">Use a sample photo</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ scanStep1 }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<div class="field"><label>Variety</label><input class="input" style="border-radius:999px" value="Garlic — Music"></div>
|
||||||
|
<div class="field"><label>Vendor</label><input class="input" style="border-radius:999px" value="Keene Organics"></div>
|
||||||
|
<div class="field"><label>Packed for</label><input class="input" style="border-radius:999px" value="2026"></div>
|
||||||
|
<div class="field"><label>Quantity</label><input class="input" style="border-radius:999px" value="50 cloves"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12.5px;font-weight:700;color:var(--p-ink-soft);margin-top:2px">Match it to your catalog — nothing is auto-created:</div>
|
||||||
|
<sc-for list="{{ matches }}" as="m">
|
||||||
|
<button onClick="{{ m.onClick }}" style="{{ m.style }}">
|
||||||
|
<span style="font-size:13.5px;font-weight:700">{{ m.label }}</span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute);margin-left:auto">{{ m.sub }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px">
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Cancel</button>
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onConfirmScan }}">Add the lot</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ dlgAdd }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||||
|
<div style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(420px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||||
|
<h3 style="margin:0;font-size:22px">A new plant</h3>
|
||||||
|
<div class="field"><label>Name</label><input class="input" style="border-radius:999px" value="{{ aName }}" onChange="{{ onAName }}" placeholder="Delicata squash"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Category</label>
|
||||||
|
<select class="input" style="border-radius:999px" value="{{ aCat }}" onChange="{{ onACat }}">
|
||||||
|
<option value="vegetable">Vegetable</option><option value="herb">Herb</option><option value="flower">Flower</option><option value="fruit">Fruit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1"><label>Spacing (in)</label><input class="input" style="border-radius:999px" type="number" value="{{ aSp }}" onChange="{{ onASp }}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Marker color</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<sc-for list="{{ swatches }}" as="sw">
|
||||||
|
<button onClick="{{ sw.onClick }}" style="{{ sw.style }}" title="{{ sw.hex }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Never mind</button>
|
||||||
|
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onCreatePlant }}">Add it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
q: '', cat: 'all', openId: null, dlg: null, scanStep: 0, matchSel: 1,
|
||||||
|
aName: '', aCat: 'vegetable', aSp: 12, aColor: '#97a97c',
|
||||||
|
plants: [
|
||||||
|
{ id: 1, name: 'Garlic', letter: 'G', color: '#97a97c', cat: 'vegetable', spacing: '6″', days: 240, builtin: true, lots: [{ vendor: 'Johnny\u2019s \u2014 Music', year: 2025, detail: '50 cloves \u00b7 ~14 left after this season\u2019s beds' }] },
|
||||||
|
{ id: 2, name: 'Tomato — Cherokee Purple', letter: 'T', color: '#c8553d', cat: 'vegetable', spacing: '24″', days: 80, builtin: false, lots: [{ vendor: 'Baker Creek', year: 2026, detail: '25 seeds \u00b7 95% germination \u00b7 ~22 left' }] },
|
||||||
|
{ id: 3, name: 'Cucumber — Marketmore', letter: 'C', color: '#6f8f4f', cat: 'vegetable', spacing: '12″', days: 65, builtin: false, lots: [] },
|
||||||
|
{ id: 4, name: 'Watermelon — Sugar Baby', letter: 'W', color: '#46683c', cat: 'fruit', spacing: '36″', days: 78, builtin: false, lots: [{ vendor: 'Ferry-Morse', year: 2025, detail: '20 seeds \u00b7 ~16 left' }] },
|
||||||
|
{ id: 5, name: 'Basil — Genovese', letter: 'B', color: '#5f8f45', cat: 'herb', spacing: '10″', days: 60, builtin: true, lots: [] },
|
||||||
|
{ id: 6, name: 'Pepper — Jalapeño', letter: 'P', color: '#b2622d', cat: 'vegetable', spacing: '18″', days: 75, builtin: false, lots: [] },
|
||||||
|
{ id: 7, name: 'Marigold', letter: 'Ma', color: '#d9912f', cat: 'flower', spacing: '8″', days: 50, builtin: true, lots: [] },
|
||||||
|
{ id: 8, name: 'Melon — Hale\u2019s Best', letter: 'Me', color: '#c2913a', cat: 'fruit', spacing: '36″', days: 85, builtin: false, lots: [] },
|
||||||
|
{ id: 9, name: 'Carrot — Danvers', letter: 'Cr', color: '#d07a2e', cat: 'vegetable', spacing: '3″', days: 70, builtin: true, lots: [] },
|
||||||
|
{ id: 10, name: 'Bush bean — Provider', letter: 'Bn', color: '#7c9a55', cat: 'vegetable', spacing: '6″', days: 55, builtin: true, lots: [] },
|
||||||
|
{ id: 11, name: 'Zinnia', letter: 'Z', color: '#c65a4e', cat: 'flower', spacing: '10″', days: 60, builtin: true, lots: [] },
|
||||||
|
{ id: 12, name: 'Thyme', letter: 'Th', color: '#6d7f5a', cat: 'herb', spacing: '8″', days: 90, builtin: true, lots: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
this.nid = 100;
|
||||||
|
}
|
||||||
|
componentDidMount() { this.themeMount(); }
|
||||||
|
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||||
|
themeMount() {
|
||||||
|
const PT = window.PansyTheme; if (!PT) return;
|
||||||
|
this.setState({ themePref: PT.get() });
|
||||||
|
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||||
|
}
|
||||||
|
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||||
|
themeVals() {
|
||||||
|
const p = this.state.themePref || 'system';
|
||||||
|
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||||
|
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||||
|
}
|
||||||
|
renderVals() {
|
||||||
|
const st = this.state;
|
||||||
|
const ql = st.q.trim().toLowerCase();
|
||||||
|
const catList = [['all', 'All'], ['vegetable', 'Vegetables'], ['herb', 'Herbs'], ['flower', 'Flowers'], ['fruit', 'Fruit']];
|
||||||
|
const shown = st.plants.filter(p => (st.cat === 'all' || p.cat === st.cat) && (!ql || p.name.toLowerCase().includes(ql)));
|
||||||
|
return {
|
||||||
|
...this.themeVals(),
|
||||||
|
q: st.q, onQ: (e) => this.setState({ q: e.target.value }),
|
||||||
|
cats: catList.map(([id, label]) => ({
|
||||||
|
key: id, label,
|
||||||
|
style: { cursor: 'pointer', border: '1px solid ' + (st.cat === id ? 'var(--color-accent-400)' : 'var(--color-divider)'), borderRadius: '999px', padding: '7px 16px', fontFamily: 'var(--font-body)', fontSize: '13px', fontWeight: 700, background: st.cat === id ? 'var(--color-accent-200)' : 'transparent', color: st.cat === id ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
|
||||||
|
onClick: () => this.setState({ cat: id }),
|
||||||
|
})),
|
||||||
|
cardsR: shown.map(p => ({
|
||||||
|
key: p.id, name: p.name, letter: p.letter, color: p.color, builtin: p.builtin,
|
||||||
|
sub: p.cat[0].toUpperCase() + p.cat.slice(1) + ' · ' + p.spacing + ' spacing · ' + p.days + ' days',
|
||||||
|
lotText: p.lots.length ? p.lots.length + (p.lots.length === 1 ? ' seed lot' : ' seed lots') + ' · click to see' : 'No seed lots · click to expand',
|
||||||
|
open: st.openId === p.id,
|
||||||
|
lots: p.lots.map((l, i) => ({ key: i, ...l })),
|
||||||
|
noLots: !p.lots.length,
|
||||||
|
cardStyle: { background: 'var(--color-neutral-100)', border: '1px solid ' + (st.openId === p.id ? 'var(--color-accent-400)' : 'var(--color-divider)'), borderRadius: 'var(--radius-lg)', padding: '16px 18px', cursor: 'pointer', position: 'relative' },
|
||||||
|
onClick: () => this.setState(s2 => ({ openId: s2.openId === p.id ? null : p.id })),
|
||||||
|
})),
|
||||||
|
dlgScan: st.dlg === 'scan', dlgAdd: st.dlg === 'add',
|
||||||
|
scanStep0: st.scanStep === 0, scanStep1: st.scanStep === 1,
|
||||||
|
onScan: () => this.setState({ dlg: 'scan', scanStep: 0, matchSel: 1 }),
|
||||||
|
onAdd: () => this.setState({ dlg: 'add', aName: '', aCat: 'vegetable', aSp: 12, aColor: '#97a97c' }),
|
||||||
|
onCloseDlg: () => this.setState({ dlg: null }),
|
||||||
|
stop: (e) => e.stopPropagation(),
|
||||||
|
onScanGo: () => this.setState({ scanStep: 1 }),
|
||||||
|
matches: [
|
||||||
|
{ label: 'Garlic (built-in)', sub: 'existing plant \u2014 lot attaches to it' },
|
||||||
|
{ label: 'Garlic \u2014 Music (yours)', sub: 'best match \u00b7 spacing 6\u2033' },
|
||||||
|
{ label: 'Create a new plant', sub: 'from the extracted fields' },
|
||||||
|
].map((m, i) => ({
|
||||||
|
key: i, ...m,
|
||||||
|
style: { display: 'flex', alignItems: 'center', gap: '8px', textAlign: 'left', cursor: 'pointer', borderRadius: '999px', padding: '10px 16px', fontFamily: 'var(--font-body)', background: st.matchSel === i ? 'var(--color-accent-200)' : 'var(--color-bg)', border: st.matchSel === i ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||||
|
onClick: () => this.setState({ matchSel: i }),
|
||||||
|
})),
|
||||||
|
onConfirmScan: () => this.setState(s2 => ({
|
||||||
|
dlg: null,
|
||||||
|
plants: s2.plants.map(p => p.id === 1 ? { ...p, lots: [...p.lots, { vendor: 'Keene Organics \u2014 Music', year: 2026, detail: '50 cloves \u00b7 untouched' }] } : p),
|
||||||
|
openId: 1,
|
||||||
|
})),
|
||||||
|
aName: st.aName, onAName: (e) => this.setState({ aName: e.target.value }),
|
||||||
|
aCat: st.aCat, onACat: (e) => this.setState({ aCat: e.target.value }),
|
||||||
|
aSp: st.aSp, onASp: (e) => this.setState({ aSp: e.target.value }),
|
||||||
|
swatches: ['#97a97c', '#c8553d', '#5f8f45', '#d9912f', '#b2622d', '#6d7f5a'].map(hex => ({
|
||||||
|
key: hex, hex,
|
||||||
|
style: { width: '30px', height: '30px', borderRadius: '999px', cursor: 'pointer', background: hex, border: st.aColor === hex ? '3px solid var(--color-accent)' : '3px solid transparent' },
|
||||||
|
onClick: () => this.setState({ aColor: hex }),
|
||||||
|
})),
|
||||||
|
onCreatePlant: () => this.setState(s2 => ({
|
||||||
|
dlg: null,
|
||||||
|
plants: [...s2.plants, { id: this.nid++, name: s2.aName.trim() || 'New plant', letter: (s2.aName.trim()[0] || 'N').toUpperCase(), color: s2.aColor, cat: s2.aCat, spacing: s2.aSp + '″', days: 70, builtin: false, lots: [] }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||||
|
<script src="pansy-theme.js"></script>
|
||||||
|
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||||
|
<style>
|
||||||
|
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||||
|
html,body{margin:0;min-height:100%}
|
||||||
|
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div data-screen-label="Settings" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||||
|
pansy
|
||||||
|
</span>
|
||||||
|
<a href="Pansy Gardens.dc.html">Gardens</a>
|
||||||
|
<a href="Pansy Plants.dc.html">Plants</a>
|
||||||
|
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||||
|
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||||
|
</button>
|
||||||
|
<a href="Pansy Settings.dc.html" aria-current="page" class="btn btn-icon btn-secondary" style="border-radius:999px;background:var(--color-accent-100)" title="Settings">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||||
|
</a>
|
||||||
|
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div style="max-width:720px;margin:0 auto;padding:28px 24px 56px;display:flex;flex-direction:column;gap:18px">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:12px">
|
||||||
|
<h2 style="margin:0">Settings</h2>
|
||||||
|
<span class="tag tag-accent-2" style="border-radius:999px">admin</span>
|
||||||
|
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">Instance-wide · everyone on this pansy</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||||
|
<h5 style="margin:0">Appearance</h5>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
|
||||||
|
<span style="font-size:13.5px;font-weight:600">Theme</span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute)">System follows your device</span>
|
||||||
|
</span>
|
||||||
|
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
|
||||||
|
<sc-for list="{{ themeOpts }}" as="t"><button onClick="{{ t.onClick }}" style="{{ t.style }}">{{ t.label }}</button></sc-for>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||||
|
<h5 style="margin:0">Who gets in</h5>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<span style="font-size:13.5px;font-weight:600;flex:1;min-width:160px">Self-service signup</span>
|
||||||
|
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
|
||||||
|
<sc-for list="{{ regOpts }}" as="r"><button onClick="{{ r.onClick }}" style="{{ r.style }}">{{ r.label }}</button></sc-for>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
|
||||||
|
<span style="font-size:13.5px;font-weight:600">Local passwords</span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute)">Turn off for pure-Authentik sign-in</span>
|
||||||
|
</span>
|
||||||
|
<button onClick="{{ onLocalAuth }}" style="{{ localAuthStyle }}">{{ localAuthLabel }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>OIDC issuer</label>
|
||||||
|
<input class="input" style="border-radius:999px" value="https://auth.dudenhoeffer.casa/application/o/pansy/" readOnly>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5">Client ID and secret live in the environment, not here. The first account ever registered became admin — that's you.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
|
<h5 style="margin:0">Garden assistant</h5>
|
||||||
|
<span class="tag tag-accent-2" style="border-radius:999px">{{ agentStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
|
||||||
|
<span style="font-size:13.5px;font-weight:600">Assistant</span>
|
||||||
|
<span style="font-size:12px;color:var(--p-ink-mute)">Swaps live — no restart</span>
|
||||||
|
</span>
|
||||||
|
<button onClick="{{ onAgent }}" style="{{ agentStyle }}">{{ agentLabel }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Chat model — blank inherits the environment default</label>
|
||||||
|
<input class="input" style="border-radius:999px" value="{{ model }}" onChange="{{ onModel }}" placeholder="ollama-cloud/glm-5.2:cloud">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Vision model — reads seed packets; must be vision-capable</label>
|
||||||
|
<input class="input" style="border-radius:999px" value="{{ vModel }}" onChange="{{ onVModel }}" placeholder="empty — packet scanning off">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5">The API key stays in the environment on purpose — a secret in the database would ride along in every backup. Precedence: this page → env → default.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||||
|
<h5 style="margin:0">You</h5>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<div class="field" style="flex:1;min-width:180px"><label>Display name</label><input class="input" style="border-radius:999px" value="{{ dName }}" onChange="{{ onDName }}"></div>
|
||||||
|
<div class="field" style="flex:1;min-width:180px"><label>Email</label><input class="input" style="border-radius:999px" value="[email protected]" readOnly></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end">
|
||||||
|
<a href="Pansy Login.dc.html" class="btn btn-ghost" style="border-radius:999px;text-decoration:none">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { reg: 'closed', localAuth: true, agent: true, model: 'ollama-cloud/glm-5.2:cloud', vModel: 'ollama-cloud/qwen3.5-vl:cloud', dName: 'Steve Dudenhoeffer' };
|
||||||
|
componentDidMount() { this.themeMount(); }
|
||||||
|
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||||
|
themeMount() {
|
||||||
|
const PT = window.PansyTheme; if (!PT) return;
|
||||||
|
this.setState({ themePref: PT.get() });
|
||||||
|
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||||
|
}
|
||||||
|
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||||
|
themeVals() {
|
||||||
|
const p = this.state.themePref || 'system';
|
||||||
|
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||||
|
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||||
|
}
|
||||||
|
renderVals() {
|
||||||
|
const st = this.state;
|
||||||
|
const toggle = (on) => ({ cursor: 'pointer', border: 'none', borderRadius: '999px', padding: '6px 18px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: on ? 'var(--color-accent-2-300)' : 'var(--color-neutral-300)', color: on ? 'var(--color-accent-2-800)' : 'var(--p-ink-soft)' });
|
||||||
|
return {
|
||||||
|
...this.themeVals(),
|
||||||
|
themeOpts: ['system', 'light', 'dark'].map(v => ({
|
||||||
|
key: v, label: v[0].toUpperCase() + v.slice(1),
|
||||||
|
style: { padding: '5px 16px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: (st.themePref || 'system') === v ? 'var(--color-neutral-100)' : 'transparent', color: (st.themePref || 'system') === v ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: (st.themePref || 'system') === v ? 'var(--shadow-sm)' : 'none' },
|
||||||
|
onClick: () => { const PT = window.PansyTheme; if (!PT) return; PT.set(v); PT.apply(PT.isDark(v)); this.setState({ themePref: v }); },
|
||||||
|
})),
|
||||||
|
regOpts: [['open', 'Open'], ['closed', 'Closed']].map(([id, label]) => ({
|
||||||
|
key: id, label,
|
||||||
|
style: { padding: '5px 16px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.reg === id ? 'var(--color-neutral-100)' : 'transparent', color: st.reg === id ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: st.reg === id ? 'var(--shadow-sm)' : 'none' },
|
||||||
|
onClick: () => this.setState({ reg: id }),
|
||||||
|
})),
|
||||||
|
onLocalAuth: () => this.setState(s => ({ localAuth: !s.localAuth })),
|
||||||
|
localAuthStyle: toggle(st.localAuth), localAuthLabel: st.localAuth ? 'On' : 'Off',
|
||||||
|
onAgent: () => this.setState(s => ({ agent: !s.agent })),
|
||||||
|
agentStyle: toggle(st.agent), agentLabel: st.agent ? 'On' : 'Off',
|
||||||
|
agentStatus: st.agent ? 'key present · glm-5.2 live' : 'off — key still in env',
|
||||||
|
model: st.model, onModel: (e) => this.setState({ model: e.target.value }),
|
||||||
|
vModel: st.vModel, onVModel: (e) => this.setState({ vModel: e.target.value }),
|
||||||
|
dName: st.dName, onDName: (e) => this.setState({ dName: e.target.value }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Handoff: Pansy UI redesign
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A full UI/UX replacement for **pansy**, the self-hosted garden planner (Go backend + JSON API, see repo `DESIGN.md`). This package covers every screen: the garden editor (desktop workspace + phone layout), gardens list, plants catalog with seed lots and packet scanning, instance settings, and login — plus a light/dark theme system defaulting to the OS preference.
|
||||||
|
|
||||||
|
## About the design files
|
||||||
|
The `.dc.html` files in this bundle are **design references created in HTML** — working prototypes showing intended look and behavior, not production code to copy. The task is to **recreate these designs in the pansy codebase**. The existing frontend (React 19 + TS + Vite + Tailwind 4) is explicitly up for replacement: keep it, or choose anything that renders SVG and talks JSON. Everything here was deliberately built with framework-agnostic primitives (plain SVG canvas, pointer events, CSS custom properties) so it ports anywhere. The Go backend and its API are the fixed contract — this design maps 1:1 onto the existing endpoints.
|
||||||
|
|
||||||
|
The prototypes carry in-memory demo data (the real 9-bed garden: five 3'×6' beds, four 2'×8' beds, bags, buckets). Wherever demo state exists, the matching API call is named below.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
**High-fidelity.** Colors, typography, spacing, radii, and interactions are final. Recreate pixel-perfectly. The visual system is "Organic": warm cream ground, terracotta + sage accents, Caprasimo display over Figtree body, everything over-rounded (pills, 16–28px radii). Fonts are Google Fonts: `Caprasimo:wght@400` and `Figtree:wght@400;600;700`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
| File | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `Pansy Editor.dc.html` | The editor — BOTH desktop and phone layouts in one file (breakpoint 760px on container width) |
|
||||||
|
| `Pansy Gardens.dc.html` | Gardens list + new-garden and share dialogs |
|
||||||
|
| `Pansy Plants.dc.html` | Plant catalog + seed lots + scan-packet and add-plant dialogs |
|
||||||
|
| `Pansy Settings.dc.html` | Instance settings (admin) + appearance |
|
||||||
|
| `Pansy Login.dc.html` | Login |
|
||||||
|
| `Pansy Phone Preview.dc.html` | The editor mounted in an iPhone frame (`ios-frame.jsx`) — preview aid only, do not implement |
|
||||||
|
| `pansy-theme.js` | The theme mechanism: dark-mode token overrides + system-pref logic. Port this pattern directly |
|
||||||
|
| `_ds/organic-…/styles.css` | The design-token stylesheet (CSS custom properties + component classes). The single source of visual truth |
|
||||||
|
|
||||||
|
Each `.dc.html` is template + logic in one file; read the markup for exact inline styles and the `class Component` script for behavior math.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
`screenshots/` holds visual ground truth for every screen in both modes: `editor-desktop`, `editor-phone` (in the device frame), `gardens`, `plants`, `settings`, `login` — each as `-light.png` and `-dark.png`. When a spec and a screenshot disagree, flag it rather than guessing; the live HTML file is the tiebreaker.
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
### Core (light) — from `styles.css`
|
||||||
|
- Ground `--color-bg: #f5ead8` · surface `#ebddc5` · text `#201e1d`
|
||||||
|
- Accent (terracotta) `#c67139` with 100–900 ramp (`#fff2eb → #402310`); accent-2 (sage) `#7a8a5e` with ramp (`#f0fae1 → #272e1b`)
|
||||||
|
- Neutral ramp `#f9f4ed → #2e2b25`
|
||||||
|
- Divider: `color-mix(in srgb, #201e1d 16%, transparent)`
|
||||||
|
- Radii: sm 8 / md 16 / lg 28 / pills `999px` (all controls are pills)
|
||||||
|
- Shadows: `--shadow-sm/md/lg` (ink-tinted; see styles.css)
|
||||||
|
- Type: h-font Caprasimo 400; body Figtree; base 15px/1.55. Page titles h2 32px, card titles ~16–19px Caprasimo, UI labels 12.5–13.5px Figtree 600–700, metadata 11.5–12.5px at `--p-ink-mute`
|
||||||
|
|
||||||
|
### Pansy canvas + ink tokens (light values; declared per-page in `:root`)
|
||||||
|
```
|
||||||
|
--p-field:#efe3c9 garden field fill --p-grid-ink:#201e1d (grid lines @ .06/.12 opacity)
|
||||||
|
--p-ink-strong:#474238 primary secondary text --p-ink-soft:#645c50 --p-ink-mute:#82796a
|
||||||
|
--p-bed-fill:#e3d0ac / --p-bed-stroke:#b5a37f raised bed
|
||||||
|
--p-ing-fill:#d6bf98 / #b1a07c (dash 10 7) in-ground plot
|
||||||
|
--p-path-fill:#ece1cb / #cabfa6 (dash 3 8) path
|
||||||
|
--p-bag-fill:#d8c5a2 / #a99677 grow bag
|
||||||
|
--p-bkt-fill:#cfc3ad / #9a8d76 container/bucket
|
||||||
|
--p-tree-fill:#dce9c6 / #8fa073 (dash 12 8) tree canopy
|
||||||
|
--p-str-fill:#d9cfbc / #a3947c structure
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark mode
|
||||||
|
`pansy-theme.js` holds the full override map — dark is implemented **only** by overriding these custom properties on `<html>` (plus `color-scheme`). Key values: bg `#252220`, surface `#33302a`, text `#f1e9da`, cards `#2e2b25`, field `#2b2823`, grid ink flips to `#f5ead8`, accent lifts to `#d67f48`, tag/tint pairs flip (e.g. accent-2-100 → `#2d3520` with accent-2-800 → `#e1eecc`). Plant marker colors do NOT change between modes.
|
||||||
|
- Preference: `'system' | 'light' | 'dark'`, persisted (`localStorage['pansy-theme']` in the prototype; per-user setting in production). Default **system**, live-updates on `prefers-color-scheme` change.
|
||||||
|
- UI: icon button in every nav cycling system→light→dark (monitor/sun/moon, Lucide, stroke 2.75) + a System/Light/Dark segmented control in Settings → Appearance.
|
||||||
|
|
||||||
|
### Plant markers (demo palette)
|
||||||
|
Solid circle in the plant's color + 1–2 letter monogram in Caprasimo `#fffaf1`: Garlic `#97a97c` G · Tomato `#c8553d` T · Cucumber `#6f8f4f` C · Watermelon `#46683c` W · Basil `#5f8f45` B · Pepper `#b2622d` P · Marigold `#d9912f` Ma · Melon `#c2913a` Me. In production the color comes from `plants.color`; derive the monogram from the name. This replaces the old emoji icons.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
### 1. Editor — desktop (≥760px container)
|
||||||
|
Three-region workspace under a nav bar; every region is a `--color-neutral-100` card, `--radius-lg`, 1px divider border, in a 14px-gap grid: `216px | minmax(0,1fr) | 336px`.
|
||||||
|
|
||||||
|
- **Nav**: brand (sprout icon, sage, stroke 2.75 + "pansy" Caprasimo 18), links Gardens/Plants (active = accent), right cluster: theme toggle, settings gear, avatar circle (32px, sage-300 bg).
|
||||||
|
- **Left card — Toolkit**: 7 draggable object kinds (Bed 3'×6', Grow bag 1'4", Container 2', In-ground 6'7", Tree 9'10", Path 3'3"×9'10", Structure 6'7"), each a pill row: mini shape swatch (SVG, true proportions, kind's fill/stroke) + bold 13px label + 11px size in ink-mute. Click arms (accent-200 bg + accent-400 border, crosshair cursor, click canvas to place); drag-drop onto canvas also places. When a bed is **focused** the card swaps to: back chevron + bed name (Caprasimo 16), search input, plant list rows (14px color dot + name + spacing), same arm/drag behavior.
|
||||||
|
- **Center card — canvas**: header row (18px padding): "Home Garden" Caprasimo 19 + size `40′ × 28′` ink-mute + focus crumb (`/ Bed 2` accent-700) + right cluster (wraps at narrow widths): season segmented pill control [2025 | 2026 | 2027 plan] on neutral-200 track (active = neutral-100 pill + shadow-sm) and an Undo pill button (disabled at 45% opacity when stack empty). A status banner strip (accent-2-200 bg / accent-2-800 text, 13px) appears under the header when season ≠ 2026: 2025 = read-only; 2027 plan = "separate copy" note.
|
||||||
|
- **Canvas (SVG)**: field = rounded rect (rx 18) `--p-field`, stroke accent-2-500 3px/scale; 1ft grid minor / 5ft major lines. Objects positioned by center + rotation (`translate(x y) rotate(deg)`), stroke width 2.5/scale (3.5 accent when selected). Bed names in 13px/scale above plantable objects; centered inside non-plantable ones. Plops (planting patches) = circles in plant color, opacity .94, monogram shown when `r·scale ≥ 9px`, name below when `≥ 34px` (semantic zoom). Selection = dashed accent outline offset 7/scale + size label (`3′ × 6′`) under the object. Ghost preview follows cursor when armed, 55% opacity dashed.
|
||||||
|
- **Zoom pill** floating bottom-right of canvas: − / fit / +.
|
||||||
|
- **Right card — rail** with 4 pill tabs: **Plot** (garden summary: counts line + full plant roster with dots and "64 in Long bed A"-style locations; or, with a selection, the inspector: name input, kind tag + size, "Growing:" roster, actions [Plant this · rotate 90° · delete]; plop selection: plant swatch + count + patch size + "Pull it out"), **Journal** (entry cards: object tag accent-2-700 + date, 13px body; input + add pill, Enter submits, attaches to selection/focus/garden), **History** ("Every change — yours or the assistant's — is one undoable step." + newest-first op pills with timestamps + "Undo the last step"), **Assistant** (chat bubbles: user right-aligned accent-200/accent-900 radius 18/18/4/18, assistant left bg/divider 18/18/18/4; input + send).
|
||||||
|
|
||||||
|
### 2. Editor — phone (<760px container)
|
||||||
|
Same file, same state, different chrome (matches repo DESIGN.md #99/#101):
|
||||||
|
- **Header** 10px padding: back circle button (38px; exits focus first, else → Gardens), garden/bed name Caprasimo 17 (ellipsis), right: theme button, season chip (tap cycles seasons, shows "26"/"27 plan"), undo circle button.
|
||||||
|
- **Canvas** fills the middle; floating fit button bottom-right. **Pinch to zoom** (two-pointer), one-finger pan/drag, tap = select, touch drag threshold 7px (mouse 3px).
|
||||||
|
- **Peek panel** (inspector on selection, or Journal/Assistant modes): docked between canvas and mode bar, max-height 45%, radius 22px top corners, shadow-lg, title + close X; canvas stays visible above it. Inputs inside are 16px font (prevents iOS zoom). All hit targets ≥ 44px.
|
||||||
|
- **Tool strip** (Build or Plants mode, hidden while peek is open): horizontal scroll row of pill chips (min-height 44) above the mode bar. In focus + Plants mode the first chip is a primary "Done".
|
||||||
|
- **Mode bar**, always visible, surface bg, 4 items (icon 19px + 11px label): Build, Plants, Journal, Assistant; active = accent-200 pill. Bottom padding `calc(6px + env(safe-area-inset-bottom))`.
|
||||||
|
- Focusing a bed on phone auto-switches to Plants mode; plants can also be tapped straight into any bed without focusing.
|
||||||
|
|
||||||
|
### 3. Gardens list
|
||||||
|
Nav + `max-width 1080px` page. Title row: "Gardens" h2 + tagline ink-mute + "New garden" primary pill. Card grid `repeat(auto-fill, minmax(300px,1fr))`, 18px gap. Each card: SVG plot thumbnail (150px band, `--p-field` bg, real object layout + plant-colored dots/pills), name Caprasimo 18 (+ optional `plan` accent tag), size right-aligned, 13px meta line, optional "Shared with lauren@ · editor" in accent-2-700, footer: Open (primary pill, flex 1) + share / copy / delete icon buttons. Dialogs: **New garden** (name, width/height in ft — note: API stores cm; "Break ground" primary) and **Share** (invite by email + role chips toggling viewer/editor on tap, read-only-link toggle + dashed link pill). Deep-copy card behavior mirrors `POST /gardens/:id/copy`.
|
||||||
|
|
||||||
|
### 4. Plants catalog
|
||||||
|
Title row + two actions: "Scan a packet" (secondary, camera icon) and "Add a plant" (primary). Filter row: search input (260px) + category pill chips (All/Vegetables/Herbs/Flowers/Fruit), live filtering. Card grid `minmax(240px,1fr)`: 40px color swatch with Caprasimo monogram, name Caprasimo 16.5 (ellipsis), sub line `Category · spacing · days`, `built-in` neutral tag for seeded plants, seed-lot summary line; clicking expands the card (accent border) to show lot cards (vendor bold + "packed for YYYY" + detail line with derived remaining) — `remaining` is **derived** server-side, never stored.
|
||||||
|
**Scan-packet dialog** (the #81 flow): step 1 = dashed drop zone + camera icon + copy "The vision model reads it into fields. It only reads; nothing is saved until you confirm." Step 2 = extracted fields grid (Variety/Vendor/Packed for/Quantity) + ranked match list as radio pills ("Garlic (built-in)" / "Garlic — Music (yours) · best match" / "Create a new plant") + "Add the lot" primary. **Never auto-create — the user confirms the match.**
|
||||||
|
**Add-plant dialog**: name, category select, spacing (in), marker color swatch row (6 curated swatches, accent ring on selection).
|
||||||
|
|
||||||
|
### 5. Settings (admin)
|
||||||
|
`max-width 720px` column of cards: **Appearance** (theme seg), **Who gets in** (signup Open/Closed seg, Local passwords toggle pill with "pure-Authentik" note, read-only OIDC issuer input, first-user-is-admin note), **Garden assistant** (live status tag e.g. "key present · glm-5.2 live", on/off toggle, chat model + vision model inputs with inherit-from-env placeholders, key-stays-in-env note — precedence Settings → env → default), **You** (display name, email read-only, Sign out ghost). Maps to `GET/PATCH /settings` + `/capabilities`.
|
||||||
|
|
||||||
|
### 6. Login
|
||||||
|
Centered 400px column over two soft blurred accent circles (decoration, 50–55% opacity): sprout 44px + "pansy" Caprasimo 40 + tagline "Plan the plot. Keep the notes. Grow the thing." Card: email + password fields (16px font), "Into the garden" primary block pill, "or" divider, "Sign in with Authentik" secondary block (lock icon; label from `PANSY_OIDC_BUTTON_LABEL`; hide per `GET /auth/providers`), footer "New here? Create an account — the first one becomes admin."
|
||||||
|
|
||||||
|
## Interactions & behavior (editor core)
|
||||||
|
|
||||||
|
**Viewport**: world = garden cm, screen = `translate(tx,ty) scale(s)` on one SVG group (s = px/cm, clamp 0.12–8). Wheel zoom to cursor (`s · e^(−deltaY·0.0016)`, non-passive listener). Pinch: scale by finger-distance ratio, keep world point under the centroid fixed. Fit: `s = min((w−2p)/GW, (h−2p)/GH)`, pad 40 desktop / 20 phone, centered. Camera animates on fit/focus with `transform .48s cubic-bezier(.22,.85,.3,1)`; drags/zooms are transition-free. Refit when container size changes >60px (ResizeObserver) or mobile/desktop flips.
|
||||||
|
|
||||||
|
**Placement & drag**: object drags snap center to a 3in (7.62cm) grid; drag commits ONE change (PATCH on drop, not per frame). Plops live in the parent object's local frame (rotate/move the bed moves its plants); drag clamps so a patch may overhang the bed edge by up to r/2 (spacing rule from DESIGN.md). Placing a plop: radius = spacing/2, count derived `max(1, round(πr²/spacing²))`. Click vs drag disambiguated by movement threshold. Escape ladder: disarm → deselect → unfocus. Delete/Backspace deletes selection (never while typing in inputs).
|
||||||
|
|
||||||
|
**Focus (plant a bed)**: double-click (desktop) or inspector "Plant this" — camera zooms to the bed (≈2.1× margin desktop, 1.35× phone, cap s=6), siblings dim to 30% opacity (their plops 22%), palette/strip swaps to plants. Same canvas, no separate view; mirror to `?focus=objectId`.
|
||||||
|
|
||||||
|
**Undo/History**: every operation (add, move, rotate, delete, plant, pull, clear) is one undoable step with a human label ("Planted garlic in Long bed A"). Maps directly to the change-set API (`GET /gardens/:id/history`, `POST /change-sets/:id/revert`); the prototype's snapshot stack is a stand-in.
|
||||||
|
|
||||||
|
**Seasons**: the seg switches `?year=` on `GET /gardens/:id/full`. Past seasons render read-only (edit handlers guard + banner). "2027 plan" opens the copied plan garden (from `/copy`) — visually identical editing with a persistent banner.
|
||||||
|
|
||||||
|
**Read-only viewers** (share role viewer / public token): same guard path as the 2025 season — canvas renders, all mutation entry points disabled.
|
||||||
|
|
||||||
|
## State management (suggested shape)
|
||||||
|
- Server state: `GET /gardens/:id/full` (garden + objects + plantings + plants) keyed per garden+year; optimistic mutations with `version` guard — on 409 roll back and refetch.
|
||||||
|
- Ephemeral UI state: `{tx, ty, s}`, `selection {type: 'object'|'plop', id} | null`, `focusId | null`, `armedKind | null`, `armedPlant | null`, season/year, rail tab, phone mode ('build'|'plants'|'journal'|'chat'), theme pref.
|
||||||
|
- Journal (`/gardens/:id/journal`) and assistant thread (`/gardens/:id/agent/history`, `POST /agent/chat` SSE) load per tab/peek.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
No binary assets. Icons are Lucide (https://lucide.dev) inlined at **stroke-width 2.75**, round caps/joins: sprout (brand + Plants mode), shovel (Build), notebook (Journal), message-circle (Assistant), settings, undo-2, rotate-cw, trash-2, plus, minus, maximize (fit), chevron-left/right, x, camera, share-2, copy, lock, monitor/sun/moon (theme), search, send. Object-kind "icons" are not glyphs — they're mini SVG swatches of the kind's actual shape, fill, and stroke.
|
||||||
|
|
||||||
|
## Implementation notes for pansy specifically
|
||||||
|
- Keep rendering as **plain SVG** — the prototypes prove tens of objects + low-hundreds of plops need nothing heavier; native hit-testing does all picking.
|
||||||
|
- The editor's desktop/phone split is **container-width-driven (760px)**, one component tree, two chromes — don't build two apps.
|
||||||
|
- Semantic zoom thresholds that felt right: monogram at `r·s ≥ 9px`, plop name at `≥ 34px`, object labels when `max(w,h)·s > 54px`.
|
||||||
|
- Imperial display is presentation-only: cm → nearest inch, shown as `3′` / `1′6″` / `8″`; API stays metric.
|
||||||
|
- Theme: implement exactly as `pansy-theme.js` does — token overrides on the root element, `color-scheme` set, system watcher. No second stylesheet, no class swapping on every node.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* @ds-bundle: {"format":4,"namespace":"Organic_organi","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
|
||||||
|
const __ds_ns = (window.Organic_organi = window.Organic_organi || {});
|
||||||
|
|
||||||
|
const __ds_scope = {};
|
||||||
|
|
||||||
|
(__ds_ns.__errors = __ds_ns.__errors || []);
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #f5ead8;
|
||||||
|
--color-surface: #ebddc5;
|
||||||
|
--color-text: #201e1d;
|
||||||
|
--color-accent: #c67139;
|
||||||
|
--color-accent-2: #7a8a5e;
|
||||||
|
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||||
|
|
||||||
|
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
|
||||||
|
same step of any role matches the others in visual value. */
|
||||||
|
--color-neutral-100: #f9f4ed;
|
||||||
|
--color-neutral-200: #eee7db;
|
||||||
|
--color-neutral-300: #dcd3c4;
|
||||||
|
--color-neutral-400: #c0b6a5;
|
||||||
|
--color-neutral-500: #a19786;
|
||||||
|
--color-neutral-600: #82796a;
|
||||||
|
--color-neutral-700: #645c50;
|
||||||
|
--color-neutral-800: #474238;
|
||||||
|
--color-neutral-900: #2e2b25;
|
||||||
|
|
||||||
|
--color-accent-100: #fff2eb;
|
||||||
|
--color-accent-200: #ffe1d0;
|
||||||
|
--color-accent-300: #ffc6a5;
|
||||||
|
--color-accent-400: #f6a06b;
|
||||||
|
--color-accent-500: #d67f48;
|
||||||
|
--color-accent-600: #b2622d;
|
||||||
|
--color-accent-700: #8c491a;
|
||||||
|
--color-accent-800: #643312;
|
||||||
|
--color-accent-900: #402310;
|
||||||
|
|
||||||
|
--color-accent-2-100: #f0fae1;
|
||||||
|
--color-accent-2-200: #e1eecc;
|
||||||
|
--color-accent-2-300: #ccdbb2;
|
||||||
|
--color-accent-2-400: #aebf92;
|
||||||
|
--color-accent-2-500: #8fa073;
|
||||||
|
--color-accent-2-600: #728157;
|
||||||
|
--color-accent-2-700: #56633f;
|
||||||
|
--color-accent-2-800: #3d472b;
|
||||||
|
--color-accent-2-900: #272e1b;
|
||||||
|
|
||||||
|
--font-heading: "Caprasimo", system-ui, sans-serif;
|
||||||
|
--font-heading-weight: 400;
|
||||||
|
--font-body: "Figtree", system-ui, sans-serif;
|
||||||
|
|
||||||
|
--space-1: 4.4px;
|
||||||
|
--space-2: 8.8px;
|
||||||
|
--space-3: 13.2px;
|
||||||
|
--space-4: 17.6px;
|
||||||
|
--space-6: 26.4px;
|
||||||
|
--space-8: 35.2px;
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 28px;
|
||||||
|
|
||||||
|
/* Elevation — derived from the ground: soft ink-tinted shadows on a
|
||||||
|
light theme, a hairline edge + ambient darkness on a dark one. */
|
||||||
|
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
|
||||||
|
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
|
||||||
|
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
|
||||||
|
|
||||||
|
.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════════
|
||||||
|
Components — built with the tokens above. Plain CSS
|
||||||
|
on plain HTML: no JavaScript, no build step. Each class is documented in
|
||||||
|
readme.md and demonstrated in foundations/ and components/.
|
||||||
|
══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||||
|
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
h1 { font-size: 42px; }
|
||||||
|
h2 { font-size: 32px; }
|
||||||
|
h3 { font-size: 25px; }
|
||||||
|
h4 { font-size: 20px; }
|
||||||
|
h5 { font-size: 16px; }
|
||||||
|
h6 { font-size: 13px; }
|
||||||
|
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
p { margin: 0 0 var(--space-3); }
|
||||||
|
a { color: var(--color-accent); text-underline-offset: 3px; }
|
||||||
|
img { display: block; max-width: 100%; }
|
||||||
|
figure { margin: 0; }
|
||||||
|
figcaption {
|
||||||
|
font-size: 11px; margin-top: var(--space-1);
|
||||||
|
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||||
|
}
|
||||||
|
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||||
|
:focus { outline: none; }
|
||||||
|
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
|
||||||
|
|
||||||
|
/* — rules — */
|
||||||
|
.hr {
|
||||||
|
height: 1px; border: 0; margin: var(--space-4) 0;
|
||||||
|
background: var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — buttons — */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
cursor: pointer; text-decoration: none;
|
||||||
|
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||||
|
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
|
||||||
|
the pair sits side by side in sign-up rows */
|
||||||
|
background: transparent; border: 1px solid transparent;
|
||||||
|
padding: var(--space-2) calc(var(--space-3) * 1.2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.btn svg { display: block; }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
|
||||||
|
.btn-primary:hover { background: var(--color-accent-600); }
|
||||||
|
.btn-primary:active { background: var(--color-accent-700); }
|
||||||
|
.btn-secondary { border-color: var(--color-divider); }
|
||||||
|
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||||
|
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
|
||||||
|
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
|
||||||
|
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||||
|
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
|
||||||
|
.btn-icon { width: 36px; height: 36px; padding: 0; }
|
||||||
|
.btn-block { width: 100%; margin-top: var(--space-2); }
|
||||||
|
|
||||||
|
/* — forms — */
|
||||||
|
.field > label {
|
||||||
|
display: block; font-size: 12px; margin-bottom: 5px;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 70%, transparent);
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
|
||||||
|
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
|
||||||
|
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
|
||||||
|
textarea.input { min-height: 90px; resize: vertical; }
|
||||||
|
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
|
||||||
|
.radio input, .seg-opt input {
|
||||||
|
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.radio .dot {
|
||||||
|
width: 16px; height: 16px; flex: none; border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
.radio:hover .dot { border-color: var(--color-accent); }
|
||||||
|
.radio input:checked + .dot {
|
||||||
|
border-color: var(--color-accent); background: var(--color-accent);
|
||||||
|
box-shadow: inset 0 0 0 4px var(--color-bg);
|
||||||
|
}
|
||||||
|
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
.seg {
|
||||||
|
display: inline-flex; overflow: hidden;
|
||||||
|
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.seg-opt {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 12px; font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
|
||||||
|
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
|
||||||
|
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||||
|
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* — cards — */
|
||||||
|
.card {
|
||||||
|
display: flex; flex-direction: column; gap: var(--space-2);
|
||||||
|
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||||
|
font-size: 17px; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
|
||||||
|
.card-meta {
|
||||||
|
display: flex; align-items: center; gap: 6px; font-size: 11px;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 50%, transparent);
|
||||||
|
}
|
||||||
|
.elev-sm { box-shadow: var(--shadow-sm); }
|
||||||
|
.elev-md { box-shadow: var(--shadow-md); }
|
||||||
|
.elev-lg { box-shadow: var(--shadow-lg); }
|
||||||
|
|
||||||
|
/* — tags — */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex; align-items: center; font-size: 11px;
|
||||||
|
letter-spacing: 0.02em; padding: 3px 10px;
|
||||||
|
border-radius: calc(var(--radius-md) * 0.75);
|
||||||
|
}
|
||||||
|
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
|
||||||
|
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
|
||||||
|
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
|
||||||
|
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
|
||||||
|
|
||||||
|
/* — navigation — */
|
||||||
|
.nav {
|
||||||
|
display: flex; align-items: center; gap: var(--space-4);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||||
|
font-size: 18px; margin-right: auto;
|
||||||
|
}
|
||||||
|
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
|
||||||
|
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
|
||||||
|
|
||||||
|
/* — tables — */
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.table th {
|
||||||
|
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 60%, transparent);
|
||||||
|
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
.table td {
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||||
|
}
|
||||||
|
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
|
||||||
|
|
||||||
|
/* — dialog — */
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed; inset: 0; display: grid; place-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
|
||||||
|
padding: var(--space-4); border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface); box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.dialog-body { font-size: 14px; opacity: 0.85; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
|
||||||
|
|
||||||
|
/* — rounded frame: everything softens, small controls go pill — */
|
||||||
|
.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
|
||||||
|
.btn, .tag, .seg, .input { border-radius: 999px; }
|
||||||
|
.input { padding-inline: 14px; }
|
||||||
|
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||||
|
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
|
||||||
|
|
||||||
|
/* BEGIN USAGE */
|
||||||
|
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||||
|
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||||
|
// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
|
||||||
|
//
|
||||||
|
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
|
||||||
|
// and home indicator (props: title, dark, keyboard):
|
||||||
|
//
|
||||||
|
// <IOSDevice title="Settings">
|
||||||
|
// ...your screen content...
|
||||||
|
// </IOSDevice>
|
||||||
|
// <IOSDevice dark title="Search" keyboard>…</IOSDevice>
|
||||||
|
/* END USAGE */
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Status bar
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||||
|
const c = dark ? '#fff' : '#000';
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||||
|
position: 'relative', zIndex: 20, width: '100%',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||||
|
fontSize: 17, lineHeight: '22px', color: c,
|
||||||
|
}}>{time}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||||
|
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||||
|
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||||
|
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||||
|
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||||
|
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||||
|
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||||
|
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||||
|
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||||
|
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||||
|
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||||
|
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Liquid glass pill — blur + tint + shine
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function IOSGlassPill({ children, dark = false, style = {} }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: 44, minWidth: 44, borderRadius: 9999,
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: dark
|
||||||
|
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
|
||||||
|
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
{/* blur + tint */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||||
|
backdropFilter: 'blur(12px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||||
|
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
|
||||||
|
}} />
|
||||||
|
{/* shine */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||||
|
boxShadow: dark
|
||||||
|
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
|
||||||
|
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||||
|
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||||
|
}} />
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Navigation bar — glass pills + large title
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
|
||||||
|
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
|
||||||
|
const text = dark ? '#fff' : '#000';
|
||||||
|
const pillIcon = (content) => (
|
||||||
|
<IOSGlassPill dark={dark}>
|
||||||
|
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</IOSGlassPill>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
|
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '0 16px',
|
||||||
|
}}>
|
||||||
|
{/* back chevron */}
|
||||||
|
{pillIcon(
|
||||||
|
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
|
||||||
|
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* trailing ellipsis */}
|
||||||
|
{trailingIcon && pillIcon(
|
||||||
|
<svg width="22" height="6" viewBox="0 0 22 6">
|
||||||
|
<circle cx="3" cy="3" r="2.5" fill={muted}/>
|
||||||
|
<circle cx="11" cy="3" r="2.5" fill={muted}/>
|
||||||
|
<circle cx="19" cy="3" r="2.5" fill={muted}/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* large title */}
|
||||||
|
<div style={{
|
||||||
|
padding: '0 16px',
|
||||||
|
fontFamily: '-apple-system, system-ui',
|
||||||
|
fontSize: 34, fontWeight: 700, lineHeight: '41px',
|
||||||
|
color: text, letterSpacing: 0.4,
|
||||||
|
}}>{title}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Grouped list (inset card, r:26) + row (52px)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||||
|
const text = dark ? '#fff' : '#000';
|
||||||
|
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||||
|
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||||
|
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||||
|
padding: '0 16px', position: 'relative',
|
||||||
|
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||||
|
letterSpacing: -0.43,
|
||||||
|
}}>
|
||||||
|
{icon && (
|
||||||
|
<div style={{
|
||||||
|
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||||
|
marginRight: 12, flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||||
|
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||||
|
{chevron && (
|
||||||
|
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||||
|
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!isLast && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, right: 0,
|
||||||
|
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IOSList({ header, children, dark = false }) {
|
||||||
|
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||||
|
const bg = dark ? '#1C1C1E' : '#fff';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{header && (
|
||||||
|
<div style={{
|
||||||
|
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||||
|
color: hc, textTransform: 'uppercase',
|
||||||
|
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||||
|
}}>{header}</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
background: bg, borderRadius: 26,
|
||||||
|
margin: '0 16px', overflow: 'hidden',
|
||||||
|
}}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Device frame
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function IOSDevice({
|
||||||
|
children, width = 402, height = 874, dark = false,
|
||||||
|
title, keyboard = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
// data-om-starter: inert presence marker — Claude Design's starter-usage
|
||||||
|
// probe reads it; it renders nothing. Keep it on this root element.
|
||||||
|
<div data-om-starter="ios-frame" style={{
|
||||||
|
width, height, borderRadius: 48, overflow: 'hidden',
|
||||||
|
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||||
|
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
|
||||||
|
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||||
|
WebkitFontSmoothing: 'antialiased',
|
||||||
|
}}>
|
||||||
|
{/* dynamic island */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||||
|
}} />
|
||||||
|
{/* status bar (absolute) */}
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||||
|
<IOSStatusBar dark={dark} />
|
||||||
|
</div>
|
||||||
|
{/* nav + content */}
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||||
|
{keyboard && <IOSKeyboard dark={dark} />}
|
||||||
|
</div>
|
||||||
|
{/* home indicator — always on top */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||||
|
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||||
|
paddingBottom: 8, pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 139, height: 5, borderRadius: 100,
|
||||||
|
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Keyboard — iOS 26 liquid glass
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function IOSKeyboard({ dark = false }) {
|
||||||
|
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||||
|
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||||
|
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||||
|
|
||||||
|
// special-key icons
|
||||||
|
const icons = {
|
||||||
|
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||||
|
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||||
|
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||||
|
<div key={k} style={{
|
||||||
|
height: 42, borderRadius: 8.5,
|
||||||
|
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||||
|
background: ret ? '#08f' : keyBg,
|
||||||
|
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||||
|
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||||
|
}}>{content}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = (keys, pad = 0) => (
|
||||||
|
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||||
|
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||||
|
padding: '11px 0 2px',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
boxShadow: dark
|
||||||
|
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||||
|
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||||
|
}}>
|
||||||
|
{/* liquid glass bg — same recipe as nav pills */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: 27,
|
||||||
|
backdropFilter: 'blur(12px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||||
|
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: 27,
|
||||||
|
boxShadow: dark
|
||||||
|
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||||
|
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||||
|
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* autocorrect bar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 20, alignItems: 'center',
|
||||||
|
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{['"The"', 'the', 'to'].map((w, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, textAlign: 'center',
|
||||||
|
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||||
|
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||||
|
}}>{w}</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* key layout */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 13,
|
||||||
|
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||||
|
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||||
|
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||||
|
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||||
|
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||||
|
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||||
|
</div>
|
||||||
|
{key(icons.del, { w: 45, k: 'del' })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||||
|
{key('', { flex: true, k: 'space' })}
|
||||||
|
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||||
|
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Pansy theme — light is the stylesheet default; dark overrides the tokens on <html>.
|
||||||
|
// Pref ('system'|'light'|'dark') persists in localStorage and is shared by every page.
|
||||||
|
(function () {
|
||||||
|
const KEY = 'pansy-theme';
|
||||||
|
const DARK = {
|
||||||
|
'--color-bg': '#252220', '--color-surface': '#33302a', '--color-text': '#f1e9da',
|
||||||
|
'--color-divider': 'color-mix(in srgb, #f5ead8 16%, transparent)',
|
||||||
|
'--color-accent': '#d67f48',
|
||||||
|
'--color-neutral-100': '#2e2b25', '--color-neutral-200': '#3a362f', '--color-neutral-300': '#474238',
|
||||||
|
'--color-neutral-400': '#645c50', '--color-neutral-500': '#82796a', '--color-neutral-800': '#dcd3c4',
|
||||||
|
'--color-accent-100': '#3d2c1d', '--color-accent-200': '#59331a', '--color-accent-300': '#8c491a',
|
||||||
|
'--color-accent-400': '#d67f48', '--color-accent-700': '#f6a06b', '--color-accent-800': '#ffd9bd', '--color-accent-900': '#ffe9da',
|
||||||
|
'--color-accent-2-200': '#333d24', '--color-accent-2-100': '#2d3520', '--color-accent-2-300': '#3d472b', '--color-accent-2-500': '#728157',
|
||||||
|
'--color-accent-2-600': '#aebf92', '--color-accent-2-700': '#ccdbb2', '--color-accent-2-800': '#e1eecc',
|
||||||
|
'--shadow-sm': '0 1px 2px rgba(0,0,0,0.4)', '--shadow-md': '0 3px 10px rgba(0,0,0,0.45)', '--shadow-lg': '0 12px 32px rgba(0,0,0,0.55)',
|
||||||
|
'--p-field': '#2b2823', '--p-grid-ink': '#f5ead8',
|
||||||
|
'--p-ink-strong': '#d9d0bf', '--p-ink-soft': '#b3a992', '--p-ink-mute': '#8f8674',
|
||||||
|
'--p-bed-fill': '#4a4131', '--p-bed-stroke': '#6d5f47', '--p-ing-fill': '#3f382c', '--p-ing-stroke': '#5c5343',
|
||||||
|
'--p-path-fill': '#312e28', '--p-path-stroke': '#4d473c', '--p-bag-fill': '#463d2f', '--p-bag-stroke': '#6a5d49',
|
||||||
|
'--p-bkt-fill': '#3e382e', '--p-bkt-stroke': '#5f574a', '--p-tree-fill': '#333a28', '--p-tree-stroke': '#56633f',
|
||||||
|
'--p-str-fill': '#3b362e', '--p-str-stroke': '#5f574a',
|
||||||
|
};
|
||||||
|
const P = {
|
||||||
|
get() { try { return localStorage.getItem(KEY) || 'system'; } catch (e) { return 'system'; } },
|
||||||
|
set(v) { try { localStorage.setItem(KEY, v); } catch (e) {} },
|
||||||
|
sysDark() { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); },
|
||||||
|
isDark(pref) { return pref === 'dark' || (pref === 'system' && P.sysDark()); },
|
||||||
|
apply(dark) {
|
||||||
|
const r = document.documentElement.style;
|
||||||
|
Object.keys(DARK).forEach(k => { dark ? r.setProperty(k, DARK[k]) : r.removeProperty(k); });
|
||||||
|
r.colorScheme = dark ? 'dark' : 'light';
|
||||||
|
},
|
||||||
|
watch(cb) {
|
||||||
|
if (!window.matchMedia) return () => {};
|
||||||
|
const m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
m.addEventListener('change', cb);
|
||||||
|
return () => m.removeEventListener('change', cb);
|
||||||
|
},
|
||||||
|
next(p) { return p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'; },
|
||||||
|
icon(p) {
|
||||||
|
return {
|
||||||
|
system: ['M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z', 'M8 21h8', 'M12 17v4'],
|
||||||
|
light: ['M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8', 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4', ''],
|
||||||
|
dark: ['M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z', '', ''],
|
||||||
|
}[p] || ['', '', ''];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.PansyTheme = P;
|
||||||
|
P.apply(P.isDark(P.get()));
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -60,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("bed: %v", err)
|
t.Fatalf("bed: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
|
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
|
||||||
t.Fatalf("seed garlic: %v", err)
|
t.Fatalf("seed garlic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,40 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
|||||||
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
|
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
|
||||||
"thing is. observedAt defaults to today; set it to backdate.",
|
"thing is. observedAt defaults to today; set it to backdate.",
|
||||||
a.addJournalEntry),
|
a.addJournalEntry),
|
||||||
|
llm.DefineTool("read_journal",
|
||||||
|
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
|
||||||
|
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
|
||||||
|
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
|
||||||
|
"or \"what happened last spring?\".",
|
||||||
|
a.readJournal),
|
||||||
|
llm.DefineTool("update_object",
|
||||||
|
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
|
||||||
|
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
|
||||||
|
"you pass change. Needs the object's current version from describe_garden. Example: "+
|
||||||
|
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
|
||||||
|
"sum.",
|
||||||
|
a.updateObject),
|
||||||
|
llm.DefineTool("delete_object",
|
||||||
|
"Delete an object from a garden entirely, along with its plantings. This is the "+
|
||||||
|
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
|
||||||
|
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
|
||||||
|
a.deleteObject),
|
||||||
|
llm.DefineTool("remove_planting",
|
||||||
|
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
|
||||||
|
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
|
||||||
|
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
|
||||||
|
"basil out of the corner\".",
|
||||||
|
a.removePlanting),
|
||||||
|
llm.DefineTool("list_seed_lots",
|
||||||
|
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
|
||||||
|
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
|
||||||
|
"remaining\" number find_plant reports.",
|
||||||
|
a.listSeedLots),
|
||||||
|
llm.DefineTool("record_seed_lot",
|
||||||
|
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
|
||||||
|
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
|
||||||
|
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
|
||||||
|
a.recordSeedLot),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +163,9 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
|
|||||||
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
|
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
|
||||||
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
|
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode))
|
// nil: the agent runs server-side with no local day, so the fill dates
|
||||||
|
// plops UTC-today like its create_planting does.
|
||||||
|
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||||
@@ -177,3 +213,80 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
|
|||||||
}
|
}
|
||||||
return map[string]int{"cleared": n}, nil
|
return map[string]int{"cleared": n}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *adapter) readJournal(ctx context.Context, args struct {
|
||||||
|
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
|
||||||
|
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
|
||||||
|
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
|
||||||
|
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
|
||||||
|
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
|
||||||
|
}) (any, error) {
|
||||||
|
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
|
||||||
|
if args.From != "" {
|
||||||
|
q.From = &args.From
|
||||||
|
}
|
||||||
|
if args.To != "" {
|
||||||
|
q.To = &args.To
|
||||||
|
}
|
||||||
|
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// hasMore is actionable now: re-call with offset += len(entries) to page.
|
||||||
|
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) updateObject(ctx context.Context, args struct {
|
||||||
|
ObjectID int64 `json:"objectId" description:"object to change"`
|
||||||
|
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
|
||||||
|
Name *string `json:"name" description:"optional new label"`
|
||||||
|
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
|
||||||
|
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
|
||||||
|
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
|
||||||
|
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
|
||||||
|
}) (any, error) {
|
||||||
|
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
|
||||||
|
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
|
||||||
|
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
|
||||||
|
}, args.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) deleteObject(ctx context.Context, args struct {
|
||||||
|
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
|
||||||
|
}) (any, error) {
|
||||||
|
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"deleted": args.ObjectID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) removePlanting(ctx context.Context, args struct {
|
||||||
|
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
|
||||||
|
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
|
||||||
|
}) (any, error) {
|
||||||
|
// Soft-remove via the service, so removed_at is stamped from the same
|
||||||
|
// (injectable) clock clear_object uses rather than the adapter's wall clock.
|
||||||
|
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||||
|
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
|
||||||
|
}) (any, error) {
|
||||||
|
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
|
||||||
|
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
|
||||||
|
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
|
||||||
|
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
|
||||||
|
Vendor string `json:"vendor" description:"optional vendor name"`
|
||||||
|
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
|
||||||
|
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
|
||||||
|
Notes string `json:"notes" description:"optional free-text notes"`
|
||||||
|
}) (any, error) {
|
||||||
|
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
|
||||||
|
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
|
||||||
|
Vendor: args.Vendor, SourceURL: args.SourceURL,
|
||||||
|
PackedForYear: args.PackedForYear, Notes: args.Notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("bed: %v", err)
|
t.Fatalf("bed: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
|
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
|
||||||
t.Fatalf("seed the garlic: %v", err)
|
t.Fatalf("seed the garlic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +347,127 @@ func TestJournalToolWritesADatedObservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it
|
||||||
|
// could only write, resize and delete an object it could only create and move,
|
||||||
|
// pull a single plop instead of clearing the whole bed, and record/read seed
|
||||||
|
// lots. Each is driven through the tool layer the way a model would run it.
|
||||||
|
func TestCorrectiveTools(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
box := NewToolbox(svc, owner)
|
||||||
|
|
||||||
|
var gid int64 // set once the garden exists; the describe closure reads it.
|
||||||
|
call := func(name string, args any) llm.ToolResult {
|
||||||
|
t.Helper()
|
||||||
|
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||||||
|
}
|
||||||
|
describe := func() service.DescribeResult {
|
||||||
|
t.Helper()
|
||||||
|
res := call("describe_garden", map[string]any{"gardenId": gid})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("describe_garden: %s", res.Content)
|
||||||
|
}
|
||||||
|
var d service.DescribeResult
|
||||||
|
if err := json.Unmarshal([]byte(res.Content), &d); err != nil {
|
||||||
|
t.Fatalf("decode describe: %v", err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("garden: %v", err)
|
||||||
|
}
|
||||||
|
gid = g.ID
|
||||||
|
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
|
||||||
|
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||||
|
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update_object: "make that bed 100cm wider" — read the version, pass a new width.
|
||||||
|
d := describe()
|
||||||
|
if r := call("update_object", map[string]any{
|
||||||
|
"objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0,
|
||||||
|
}); r.IsError {
|
||||||
|
t.Fatalf("update_object: %s", r.Content)
|
||||||
|
}
|
||||||
|
if w := describe().Objects[0].WidthCM; w != 500 {
|
||||||
|
t.Errorf("width = %v after update_object, want 500", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// place a plop, then remove_planting it by id+version — one plop, not the bed.
|
||||||
|
if r := call("place_planting", map[string]any{
|
||||||
|
"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30,
|
||||||
|
}); r.IsError {
|
||||||
|
t.Fatalf("place_planting: %s", r.Content)
|
||||||
|
}
|
||||||
|
d = describe()
|
||||||
|
if len(d.Objects[0].Plantings) != 1 {
|
||||||
|
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings))
|
||||||
|
}
|
||||||
|
plop := d.Objects[0].Plantings[0]
|
||||||
|
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
|
||||||
|
t.Fatalf("remove_planting: %s", r.Content)
|
||||||
|
}
|
||||||
|
if n := len(describe().Objects[0].Plantings); n != 0 {
|
||||||
|
t.Errorf("want 0 active plops after remove_planting, got %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add then read the journal — the write/read asymmetry the issue flagged.
|
||||||
|
if r := call("add_journal_entry", map[string]any{
|
||||||
|
"gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01",
|
||||||
|
}); r.IsError {
|
||||||
|
t.Fatalf("add_journal_entry: %s", r.Content)
|
||||||
|
}
|
||||||
|
res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("read_journal: %s", res.Content)
|
||||||
|
}
|
||||||
|
var jr struct {
|
||||||
|
Entries []struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(res.Content), &jr); err != nil {
|
||||||
|
t.Fatalf("decode read_journal: %v (%s)", err, res.Content)
|
||||||
|
}
|
||||||
|
if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" {
|
||||||
|
t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// record then list a seed lot — the detail behind find_plant's "remaining".
|
||||||
|
if r := call("record_seed_lot", map[string]any{
|
||||||
|
"plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's",
|
||||||
|
}); r.IsError {
|
||||||
|
t.Fatalf("record_seed_lot: %s", r.Content)
|
||||||
|
}
|
||||||
|
res = call("list_seed_lots", map[string]any{"plantId": basil.ID})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("list_seed_lots: %s", res.Content)
|
||||||
|
}
|
||||||
|
var lots []struct {
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(res.Content), &lots); err != nil {
|
||||||
|
t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content)
|
||||||
|
}
|
||||||
|
if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" {
|
||||||
|
t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete_object: the counterpart to create_object.
|
||||||
|
if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError {
|
||||||
|
t.Fatalf("delete_object: %s", r.Content)
|
||||||
|
}
|
||||||
|
if n := len(describe().Objects); n != 0 {
|
||||||
|
t.Errorf("want 0 objects after delete_object, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newAgentTestService spins up an in-memory pansy with one registered user.
|
// newAgentTestService spins up an in-memory pansy with one registered user.
|
||||||
func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
||||||
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
||||||
case errors.Is(err, domain.ErrInvalidInput):
|
case errors.Is(err, domain.ErrInvalidInput):
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
|
||||||
default:
|
default:
|
||||||
slog.Error("api: unhandled service error", "error", err)
|
slog.Error("api: unhandled service error", "error", err)
|
||||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
|
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
|
||||||
@@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
|
|||||||
}
|
}
|
||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
|
||||||
|
// sentinel reads "invalid input"; a service that wraps it with a reason —
|
||||||
|
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
|
||||||
|
// — has that reason shown to the person verbatim, minus the sentinel prefix.
|
||||||
|
// So anything wrapped this way is written for the keyboard, not the log (see
|
||||||
|
// the note on domain.ErrInvalidInput).
|
||||||
|
func inputMessage(err error) string {
|
||||||
|
msg := err.Error()
|
||||||
|
base := domain.ErrInvalidInput.Error()
|
||||||
|
if msg == base {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(msg, base+": ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInputMessage: the bare sentinel stays generic; a wrapped reason reaches
|
||||||
|
// the person without the "invalid input: " prefix in front of it.
|
||||||
|
func TestInputMessage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{domain.ErrInvalidInput, "invalid input"},
|
||||||
|
{fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, "nonesuch/model"), `chat model "nonesuch/model": unknown provider`},
|
||||||
|
{fmt.Errorf("loading: %w", domain.ErrInvalidInput), "loading: invalid input"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if !errors.Is(c.err, domain.ErrInvalidInput) {
|
||||||
|
t.Fatalf("%v should still be an ErrInvalidInput", c.err)
|
||||||
|
}
|
||||||
|
if got := inputMessage(c.err); got != c.want {
|
||||||
|
t.Errorf("inputMessage(%v) = %q, want %q", c.err, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ type objectFillRequest struct {
|
|||||||
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
||||||
// is refused by the service (#77).
|
// is refused by the service (#77).
|
||||||
Layout string `json:"layout"`
|
Layout string `json:"layout"`
|
||||||
|
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
|
||||||
|
// local day; omitted, the server uses UTC today — which is tomorrow for an
|
||||||
|
// evening gardener west of Greenwich, so clients that know better say so.
|
||||||
|
PlantedAt *string `json:"plantedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) fillObject(c *gin.Context) {
|
func (h *handlers) fillObject(c *gin.Context) {
|
||||||
@@ -88,9 +92,9 @@ func (h *handlers) fillObject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
||||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
||||||
} else {
|
} else {
|
||||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(c, err)
|
writeServiceError(c, err)
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ type settingsResponse struct {
|
|||||||
// Effective is the configuration actually in force after layering settings
|
// Effective is the configuration actually in force after layering settings
|
||||||
// over the environment.
|
// over the environment.
|
||||||
Effective effectiveView `json:"effective"`
|
Effective effectiveView `json:"effective"`
|
||||||
|
// Auth is the sign-in configuration, read-only (see authView).
|
||||||
|
Auth authView `json:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authView is the environment-driven sign-in configuration the Settings page
|
||||||
|
// shows under "Who gets in": PANSY_REGISTRATION, PANSY_LOCAL_AUTH and the OIDC
|
||||||
|
// issuer. It is reported so an admin can see what is in force without shell
|
||||||
|
// access; none of it is editable at runtime (auth policy deploys with the
|
||||||
|
// environment on purpose — see README). Only the issuer URL is exposed, never
|
||||||
|
// the client id or secret.
|
||||||
|
type authView struct {
|
||||||
|
// Registration is "open" or "closed" — whether local self-service signup is
|
||||||
|
// allowed. OIDC provisioning ignores it (the IdP gates access).
|
||||||
|
Registration string `json:"registration"`
|
||||||
|
// LocalAuth is whether email/password sign-in is offered at all.
|
||||||
|
LocalAuth bool `json:"localAuth"`
|
||||||
|
// OIDC is whether single sign-on is fully configured; OIDCIssuer is the
|
||||||
|
// discovery URL as configured (may be set while OIDC is still incomplete).
|
||||||
|
OIDC bool `json:"oidc"`
|
||||||
|
OIDCIssuer string `json:"oidcIssuer"`
|
||||||
|
OIDCLabel string `json:"oidcLabel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type effectiveView struct {
|
type effectiveView struct {
|
||||||
@@ -79,6 +100,13 @@ func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings)
|
|||||||
VisionModel: vis.Model,
|
VisionModel: vis.Model,
|
||||||
VisionReady: vis.Ready(),
|
VisionReady: vis.Ready(),
|
||||||
},
|
},
|
||||||
|
Auth: authView{
|
||||||
|
Registration: h.cfg.Registration,
|
||||||
|
LocalAuth: h.cfg.LocalAuth,
|
||||||
|
OIDC: h.cfg.OIDCReady(),
|
||||||
|
OIDCIssuer: h.cfg.OIDC.Issuer,
|
||||||
|
OIDCLabel: h.cfg.OIDC.ButtonLabel,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -81,6 +83,25 @@ func TestSettingsInheritFromEnv(t *testing.T) {
|
|||||||
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
||||||
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The read-only sign-in view the Settings page renders under "Who gets in".
|
||||||
|
// These come straight from the environment config, so the shape is what's
|
||||||
|
// asserted: a registration mode, a local-auth flag, and no secret material.
|
||||||
|
auth, ok := body["auth"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("settings response has no auth view: %v", body)
|
||||||
|
}
|
||||||
|
if reg := auth["registration"]; reg != "open" && reg != "closed" {
|
||||||
|
t.Errorf("auth.registration = %v, want open or closed", reg)
|
||||||
|
}
|
||||||
|
if _, isBool := auth["localAuth"].(bool); !isBool {
|
||||||
|
t.Errorf("auth.localAuth = %v, want a bool", auth["localAuth"])
|
||||||
|
}
|
||||||
|
for _, k := range []string{"clientId", "clientSecret", "oidcClientSecret"} {
|
||||||
|
if _, present := auth[k]; present {
|
||||||
|
t.Errorf("auth view exposes %q — secrets must never leave the environment", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
||||||
@@ -140,10 +161,22 @@ func TestSettingsRejectsBadModel(t *testing.T) {
|
|||||||
admin := registerAndCookie(t, r, "[email protected]")
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
v := settingsVersion(t, r, admin)
|
v := settingsVersion(t, r, admin)
|
||||||
|
|
||||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Errorf("bad model: status %d, want 400", w.Code)
|
t.Errorf("bad model: status %d, want 400", w.Code)
|
||||||
}
|
}
|
||||||
|
// The message says which field and which spec, so the page can show a reason
|
||||||
|
// rather than a bare "invalid input".
|
||||||
|
var body struct {
|
||||||
|
Error struct{ Code, Message string } `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode bad-model body: %v", err)
|
||||||
|
}
|
||||||
|
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
|
||||||
|
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
|
||||||
|
}
|
||||||
// agentEnabled must be a bool or null, not a string.
|
// agentEnabled must be a bool or null, not a string.
|
||||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ var (
|
|||||||
ErrShareExists = errors.New("garden already shared with that user")
|
ErrShareExists = errors.New("garden already shared with that user")
|
||||||
|
|
||||||
// ErrInvalidInput means the caller supplied structurally invalid data (empty
|
// ErrInvalidInput means the caller supplied structurally invalid data (empty
|
||||||
// required field, malformed value). Mapped to 400.
|
// required field, malformed value). Mapped to 400. Wrap it with the reason —
|
||||||
|
// fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", ErrInvalidInput) —
|
||||||
|
// and the API shows that reason to the person verbatim, so write it for
|
||||||
|
// them, not for a log; the bare sentinel reads as just "invalid input".
|
||||||
ErrInvalidInput = errors.New("invalid input")
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
// ErrInvalidCredentials means a login attempt failed. It is deliberately
|
// ErrInvalidCredentials means a login attempt failed. It is deliberately
|
||||||
// identical for an unknown email and a wrong password so neither can be
|
// identical for an unknown email and a wrong password so neither can be
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||||
@@ -61,12 +63,15 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
|
|||||||
model := strings.TrimSpace(patch.AgentModel)
|
model := strings.TrimSpace(patch.AgentModel)
|
||||||
vision := strings.TrimSpace(patch.VisionModel)
|
vision := strings.TrimSpace(patch.VisionModel)
|
||||||
// Validate non-empty specs up front. An empty one is the "inherit env"
|
// Validate non-empty specs up front. An empty one is the "inherit env"
|
||||||
// sentinel and needs no check — the env value was validated at boot.
|
// sentinel and needs no check — the env value was validated at boot. The
|
||||||
for _, spec := range []string{model, vision} {
|
// reason rides on the sentinel so the 400 can show it: "unknown provider"
|
||||||
if spec != "" {
|
// is something a person can act on, "invalid input" is not.
|
||||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil {
|
for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} {
|
||||||
return nil, domain.ErrInvalidInput
|
if f.spec == "" {
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, f.spec); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s %q: %v", domain.ErrInvalidInput, f.label, f.spec, specReason(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||||
@@ -166,3 +171,12 @@ func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, Effectiv
|
|||||||
}
|
}
|
||||||
return s.agentOver(st), s.visionOver(st), nil
|
return s.agentOver(st), s.visionOver(st), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// specReason strips agentmodel's own "resolve %q:" wrapping so the message
|
||||||
|
// reads "unknown provider …" rather than repeating the spec twice.
|
||||||
|
func specReason(err error) string {
|
||||||
|
if u := errors.Unwrap(err); u != nil {
|
||||||
|
return u.Error()
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,13 +175,15 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
|
|||||||
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
|
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
|
||||||
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its
|
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its
|
||||||
// plop would sit entirely inside an existing active plop (so re-filling doesn't
|
// plop would sit entirely inside an existing active plop (so re-filling doesn't
|
||||||
// stack duplicates). Returns the plops it created.
|
// stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today
|
||||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
// when nil — the UI always sends its local day, so the default is for API and
|
||||||
|
// agent callers. Returns the plops it created.
|
||||||
|
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
|
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||||
@@ -189,10 +191,13 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
|
|||||||
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
||||||
// maxFillPlops, and inserts the whole batch in one transaction rather than one
|
// maxFillPlops, and inserts the whole batch in one transaction rather than one
|
||||||
// round-trip per plop.
|
// round-trip per plop.
|
||||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||||
if !o.Plantable {
|
if !o.Plantable {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
if !validDatePtr(plantedAt) {
|
||||||
|
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||||||
|
}
|
||||||
layout, ok := validFillLayout(layout)
|
layout, ok := validFillLayout(layout)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
@@ -235,7 +240,10 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
today := s.now().UTC().Format(dateLayout)
|
plantedOn := s.now().UTC().Format(dateLayout)
|
||||||
|
if plantedAt != nil {
|
||||||
|
plantedOn = *plantedAt
|
||||||
|
}
|
||||||
batch := make([]*domain.Planting, 0, len(centers))
|
batch := make([]*domain.Planting, 0, len(centers))
|
||||||
// Only the plops that were ALREADY here can cover a candidate: every plop this
|
// Only the plops that were ALREADY here can cover a candidate: every plop this
|
||||||
// fill makes shares one radius and sits on a distinct lattice point, and a plop
|
// fill makes shares one radius and sits on a distinct lattice point, and a plop
|
||||||
@@ -247,7 +255,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today})
|
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn})
|
||||||
}
|
}
|
||||||
created, err := s.store.CreatePlantings(ctx, batch)
|
created, err := s.store.CreatePlantings(ctx, batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -376,7 +384,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
|||||||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||||||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||||||
// hold the object's geometry. It resolves the name against the object, then fills.
|
// hold the object's geometry. It resolves the name against the object, then fills.
|
||||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -385,7 +393,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
|
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||||||
@@ -471,7 +479,11 @@ type DescribeObject struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||||||
|
// ID + Version are included so an agent can address a single plop — remove it or
|
||||||
|
// move it — the same way DescribeObject.Version lets it edit an object.
|
||||||
type DescribePlanting struct {
|
type DescribePlanting struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
PlantID int64 `json:"plantId"`
|
PlantID int64 `json:"plantId"`
|
||||||
Plant string `json:"plant"`
|
Plant string `json:"plant"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -518,6 +530,8 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
|
|||||||
count = *pl.Count
|
count = *pl.Count
|
||||||
}
|
}
|
||||||
do.Plantings = append(do.Plantings, DescribePlanting{
|
do.Plantings = append(do.Plantings, DescribePlanting{
|
||||||
|
ID: pl.ID,
|
||||||
|
Version: pl.Version,
|
||||||
PlantID: pl.PlantID,
|
PlantID: pl.PlantID,
|
||||||
Plant: plantByID[pl.PlantID].Name,
|
Plant: plantByID[pl.PlantID].Name,
|
||||||
Count: count,
|
Count: count,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
|
|||||||
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
|
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
|
||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
|
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
|
|||||||
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
||||||
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
||||||
} {
|
} {
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump)
|
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump, nil)
|
||||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
|
|
||||||
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump)
|
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
|
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
|
||||||
|
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
|||||||
|
|
||||||
// Re-filling the same region skips everything (each candidate sits exactly on
|
// Re-filling the same region skips everything (each candidate sits exactly on
|
||||||
// an existing plop → entirely inside it).
|
// an existing plop → entirely inside it).
|
||||||
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second FillRegion: %v", err)
|
t.Fatalf("second FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -305,14 +305,14 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 10) // spacing 10
|
plant := seedOwnPlant(t, s, owner, 10) // spacing 10
|
||||||
|
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("clump: %v", err)
|
t.Fatalf("clump: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
|
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
|
||||||
t.Fatalf("clear: %v", err)
|
t.Fatalf("clear: %v", err)
|
||||||
}
|
}
|
||||||
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid)
|
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("grid: %v", err)
|
t.Fatalf("grid: %v", err)
|
||||||
}
|
}
|
||||||
@@ -351,7 +351,7 @@ func TestFillRejectsUnknownLayout(t *testing.T) {
|
|||||||
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral")); !errors.Is(err, domain.ErrInvalidInput) {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral"), nil); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
|
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 20)
|
plant := seedOwnPlant(t, s, owner, 20)
|
||||||
|
|
||||||
region, _ := NamedRegion(bed, "ne")
|
region, _ := NamedRegion(bed, "ne")
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -391,7 +391,7 @@ func TestClearObject(t *testing.T) {
|
|||||||
bed := seedBed(t, s, owner, g.ID)
|
bed := seedBed(t, s, owner, g.ID)
|
||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
|
|
||||||
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrForbidden) {
|
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrForbidden) {
|
||||||
t.Errorf("viewer fill = %v, want ErrForbidden", err)
|
t.Errorf("viewer fill = %v, want ErrForbidden", err)
|
||||||
}
|
}
|
||||||
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
|
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
|
||||||
@@ -455,7 +455,7 @@ func TestFillScenario(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("region %q: %v", name, err)
|
t.Fatalf("region %q: %v", name, err)
|
||||||
}
|
}
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump, nil); err != nil {
|
||||||
t.Fatalf("fill %q: %v", name, err)
|
t.Fatalf("fill %q: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,3 +507,34 @@ func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingC
|
|||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFillRegionPlantedAt: a fill dates its plops as told and refuses a date
|
||||||
|
// that isn't one. The UI sends its local day, so an evening fill isn't stamped
|
||||||
|
// with UTC's tomorrow; API and agent callers that omit it still get UTC today.
|
||||||
|
func TestFillRegionPlantedAt(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Dated", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
bed := seedFillBed(t, s, owner, g.ID, 200, 100)
|
||||||
|
plant := seedOwnPlant(t, s, owner, 30)
|
||||||
|
|
||||||
|
day := "2026-04-01"
|
||||||
|
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &day)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fill: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) == 0 {
|
||||||
|
t.Fatal("fill created nothing")
|
||||||
|
}
|
||||||
|
for _, p := range created {
|
||||||
|
if p.PlantedAt == nil || *p.PlantedAt != day {
|
||||||
|
t.Errorf("planting %d plantedAt = %v, want %s", p.ID, p.PlantedAt, day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := "April 1st"
|
||||||
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &bad); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,6 +178,17 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
|||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
|
||||||
|
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at
|
||||||
|
// from the service clock (s.now()), same as ClearObject and the fill path, so the
|
||||||
|
// removal date can't diverge by which caller set it; then delegates to
|
||||||
|
// UpdatePlanting for the editor-role check, version guard and history record.
|
||||||
|
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
|
||||||
|
today := s.now().UTC().Format(dateLayout)
|
||||||
|
return s.UpdatePlanting(ctx, actorID, plantingID,
|
||||||
|
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
|
||||||
|
}
|
||||||
|
|
||||||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||||||
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
||||||
// as a removal to the person who did it, not as an edit.
|
// as a removal to the person who did it, not as an edit.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump)
|
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillNamedRegion: %v", err)
|
t.Fatalf("FillNamedRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||||
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||||
@@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
// A second, different kind of change, so the breakdown has more than one row
|
// A second, different kind of change, so the breakdown has more than one row
|
||||||
@@ -830,7 +830,7 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
|||||||
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
||||||
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
||||||
}, func(ctx context.Context) error {
|
}, func(ctx context.Context) error {
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cancel() // the client disconnects, mid-turn, after the work landed
|
cancel() // the client disconnects, mid-turn, after the work landed
|
||||||
|
|||||||
@@ -99,9 +99,12 @@ func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) {
|
|||||||
lot := seedLot(t, s, owner, plant.ID, 100, nil)
|
lot := seedLot(t, s, owner, plant.ID, 100, nil)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
ten := 10
|
// Dated explicitly: left to default, plantedAt is the real UTC day, and the
|
||||||
|
// removal below has to come after it — a test that only passed before
|
||||||
|
// 2026-08-01 is the kind of clock bomb this avoids.
|
||||||
|
ten, planted := 10, "2026-07-01"
|
||||||
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID,
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantedAt: &planted,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreatePlanting: %v", err)
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
|||||||
@@ -4,9 +4,62 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>%F0%9F%8C%B1</text></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237a8a5e' stroke-width='2.75' stroke-linecap='round' stroke-linejoin='round'><path d='M7 20h10'/><path d='M10 20c5.5-2.5.8-6.4 3-10'/><path d='M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z'/><path d='M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z'/></svg>" />
|
||||||
<title>pansy</title>
|
<title>pansy</title>
|
||||||
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<script>
|
||||||
|
// Pansy theme — light is the stylesheet default; dark overrides the tokens on
|
||||||
|
// <html>. A classic (blocking) script on purpose: it runs before the first
|
||||||
|
// paint, so a dark-mode user never sees a cream flash. The preference
|
||||||
|
// ('system' | 'light' | 'dark') persists in localStorage; src/lib/theme.ts is
|
||||||
|
// the typed React face of this same object (window.PansyTheme) and this is
|
||||||
|
// the ONLY place the dark token values live. Ported from
|
||||||
|
// docs/design_handoff_pansy_ui/pansy-theme.js.
|
||||||
|
(function () {
|
||||||
|
var KEY = 'pansy-theme';
|
||||||
|
var DARK = {
|
||||||
|
'--color-bg': '#252220', '--color-surface': '#33302a', '--color-text': '#f1e9da',
|
||||||
|
'--color-divider': 'color-mix(in srgb, #f5ead8 16%, transparent)',
|
||||||
|
'--color-accent': '#d67f48',
|
||||||
|
'--color-neutral-100': '#2e2b25', '--color-neutral-200': '#3a362f', '--color-neutral-300': '#474238',
|
||||||
|
'--color-neutral-400': '#645c50', '--color-neutral-500': '#82796a', '--color-neutral-800': '#dcd3c4',
|
||||||
|
'--color-accent-100': '#3d2c1d', '--color-accent-200': '#59331a', '--color-accent-300': '#8c491a',
|
||||||
|
'--color-accent-400': '#d67f48', '--color-accent-700': '#f6a06b', '--color-accent-800': '#ffd9bd', '--color-accent-900': '#ffe9da',
|
||||||
|
'--color-accent-2-200': '#333d24', '--color-accent-2-100': '#2d3520', '--color-accent-2-300': '#3d472b', '--color-accent-2-500': '#728157',
|
||||||
|
'--color-accent-2-600': '#aebf92', '--color-accent-2-700': '#ccdbb2', '--color-accent-2-800': '#e1eecc',
|
||||||
|
'--shadow-sm': '0 1px 2px rgba(0,0,0,0.4)', '--shadow-md': '0 3px 10px rgba(0,0,0,0.45)', '--shadow-lg': '0 12px 32px rgba(0,0,0,0.55)',
|
||||||
|
'--p-field': '#2b2823', '--p-grid-ink': '#f5ead8',
|
||||||
|
'--p-ink-strong': '#d9d0bf', '--p-ink-soft': '#b3a992', '--p-ink-mute': '#8f8674',
|
||||||
|
'--p-bed-fill': '#4a4131', '--p-bed-stroke': '#6d5f47', '--p-ing-fill': '#3f382c', '--p-ing-stroke': '#5c5343',
|
||||||
|
'--p-path-fill': '#312e28', '--p-path-stroke': '#4d473c', '--p-bag-fill': '#463d2f', '--p-bag-stroke': '#6a5d49',
|
||||||
|
'--p-bkt-fill': '#3e382e', '--p-bkt-stroke': '#5f574a', '--p-tree-fill': '#333a28', '--p-tree-stroke': '#56633f',
|
||||||
|
'--p-str-fill': '#3b362e', '--p-str-stroke': '#5f574a'
|
||||||
|
};
|
||||||
|
var P = {
|
||||||
|
get: function () { try { return localStorage.getItem(KEY) || 'system'; } catch (e) { return 'system'; } },
|
||||||
|
set: function (v) { try { localStorage.setItem(KEY, v); } catch (e) {} },
|
||||||
|
sysDark: function () { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); },
|
||||||
|
isDark: function (pref) { return pref === 'dark' || (pref === 'system' && P.sysDark()); },
|
||||||
|
apply: function (dark) {
|
||||||
|
var r = document.documentElement.style;
|
||||||
|
Object.keys(DARK).forEach(function (k) { if (dark) r.setProperty(k, DARK[k]); else r.removeProperty(k); });
|
||||||
|
r.colorScheme = dark ? 'dark' : 'light';
|
||||||
|
},
|
||||||
|
watch: function (cb) {
|
||||||
|
if (!window.matchMedia) return function () {};
|
||||||
|
var m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
m.addEventListener('change', cb);
|
||||||
|
return function () { m.removeEventListener('change', cb); };
|
||||||
|
},
|
||||||
|
next: function (p) { return p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'; }
|
||||||
|
};
|
||||||
|
window.PansyTheme = P;
|
||||||
|
P.apply(P.isDark(P.get()));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -18,10 +18,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.95.0",
|
"@tanstack/react-router": "^1.95.0",
|
||||||
"@use-gesture/react": "^10.3.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^9.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { buttonClasses } from '@/components/ui/Button'
|
import { Nav } from '@/components/layout/Nav'
|
||||||
|
import { Icon } from '@/components/ui/Icon'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
/** The router's catch-all for unknown paths. */
|
/** The router's catch-all for unknown paths. */
|
||||||
export function NotFound() {
|
export function NotFound() {
|
||||||
usePageTitle('Not found')
|
usePageTitle('Not found')
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
<div className="min-h-full">
|
||||||
<p className="text-5xl" aria-hidden>
|
<Nav />
|
||||||
🌱
|
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 text-center">
|
||||||
</p>
|
<Icon name="sprout" size={40} className="text-accent-2-500" />
|
||||||
<h1 className="text-lg font-semibold text-fg">Page not found</h1>
|
<h3>Nothing growing here</h3>
|
||||||
<p className="text-sm text-muted">That page doesn't exist — the link may be wrong or the page moved.</p>
|
<p className="text-[13px] text-ink-soft">That page doesn't exist — the link may be wrong or the page moved.</p>
|
||||||
<Link to="/gardens" className={buttonClasses('primary')}>
|
<Link to="/gardens" className="btn btn-primary mt-2 no-underline">
|
||||||
Back to gardens
|
Back to the gardens
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { errorMessage } from '@/lib/api'
|
|||||||
export function RouteError({ error }: { error: Error }) {
|
export function RouteError({ error }: { error: Error }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
<h1 className="text-lg font-semibold text-fg">Something went wrong</h1>
|
<h3>Something went wrong</h3>
|
||||||
<p className="text-sm text-muted">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
<p className="text-[13px] text-ink-soft">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||||
<Button onClick={() => router.invalidate()}>Try again</Button>
|
<Button variant="primary" className="mt-2" onClick={() => router.invalidate()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
/** Centered card layout shared by the login and register pages. */
|
|
||||||
export function AuthCard({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-sm flex-col justify-center">
|
|
||||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-fg">{title}</h1>
|
|
||||||
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
|
||||||
<div className="mt-5">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ThemeButton } from '@/components/layout/ThemeButton'
|
||||||
|
import { Icon } from '@/components/ui/Icon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sign-in/sign-up backdrop: a centered 400px column over two soft accent
|
||||||
|
* circles, the brand mark above the card, the theme toggle in the corner.
|
||||||
|
*/
|
||||||
|
export function AuthScreen({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-bg p-6">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -right-[140px] -top-[180px] h-[520px] w-[520px] rounded-full bg-accent-2-200 opacity-55"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -bottom-[140px] -left-[100px] h-[340px] w-[340px] rounded-full bg-accent-200 opacity-50"
|
||||||
|
/>
|
||||||
|
<ThemeButton className="absolute right-[18px] top-[18px] bg-neutral-100" />
|
||||||
|
<div className="relative flex w-[min(400px,100%)] flex-col gap-[22px]">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<Icon name="sprout" size={44} className="text-accent-2-600" />
|
||||||
|
<h1 className="text-[40px]">pansy</h1>
|
||||||
|
<p className="text-[14.5px] text-ink-soft">Plan the plot. Keep the notes. Grow the thing.</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card the forms sit in. */
|
||||||
|
export function AuthCard({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="panel elev-md flex flex-col gap-3.5 p-[26px]">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "— or —" between the two sign-in methods. */
|
||||||
|
export function OrDivider() {
|
||||||
|
return (
|
||||||
|
<div className="my-0.5 flex items-center gap-3">
|
||||||
|
<span className="h-px flex-1 bg-divider" />
|
||||||
|
<span className="text-xs text-ink-mute">or</span>
|
||||||
|
<span className="h-px flex-1 bg-divider" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { TextField } from '@/components/ui/Field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
|
||||||
|
import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a garden — the way to scheme a season: the copy is a separate
|
||||||
|
* garden you rearrange freely while this one stays put. Beds and everything
|
||||||
|
* currently planted come along; the share link and shares don't. The name is
|
||||||
|
* prefilled as "<name> — <year>" for the next year that doesn't already have a
|
||||||
|
* plan, which is what the editor's season control and the `plan` tag read back
|
||||||
|
* (see lib/plan.ts). On success we land in the copy, since the point of copying
|
||||||
|
* is to start editing it.
|
||||||
|
*/
|
||||||
|
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||||
|
const copy = useCopyGarden()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const gardens = useGardens()
|
||||||
|
const names = (gardens.data ?? []).map((g) => g.name)
|
||||||
|
const base = parsePlanName(garden.name)?.base ?? garden.name
|
||||||
|
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||||
|
const year = nextPlanYear(base, names, from)
|
||||||
|
const [name, setName] = useState(() => planNameFor(base, year))
|
||||||
|
const [touched, setTouched] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// The gardens list can still be loading when this opens; until the person
|
||||||
|
// edits the name, keep the proposal in step with what the list says is free.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!touched) setName(planNameFor(base, year))
|
||||||
|
}, [base, year, touched])
|
||||||
|
// The API allows duplicate names; say so rather than let two gardens read as
|
||||||
|
// the same season's plan.
|
||||||
|
const taken = names.some((n) => n.trim() === name.trim())
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
|
||||||
|
toast.info(`Copied to “${created.name}”.`)
|
||||||
|
onClose()
|
||||||
|
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, 'Could not copy the garden.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Plan a season" onClose={onClose} busy={copy.isPending}>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||||
|
<p className="text-[13px] leading-relaxed text-ink-soft">
|
||||||
|
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in — rearrange freely, the
|
||||||
|
original stays put. Beds and what's planted come along; shares and the public link don't.
|
||||||
|
</p>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTouched(true)
|
||||||
|
setName(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
||||||
|
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" onClick={onClose} disabled={copy.isPending}>
|
||||||
|
Never mind
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={copy.isPending || name.trim() === ''}>
|
||||||
|
{copy.isPending ? 'Copying…' : 'Make the copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
|
||||||
import { TextField } from '@/components/ui/TextField'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Duplicate a garden under a new name. The name is prefilled with the server's
|
|
||||||
* own default so what you see is what you get; the beds and everything currently
|
|
||||||
* planted in them come along, while the source's share link and shares do not.
|
|
||||||
* On success we land in the copy — the point of copying is to start editing it.
|
|
||||||
*/
|
|
||||||
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
|
||||||
const copy = useCopyGarden()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [name, setName] = useState(() => defaultCopyName(garden.name))
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
|
|
||||||
toast.info(`Copied to “${created.name}”.`)
|
|
||||||
onClose()
|
|
||||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
|
|
||||||
} catch (err) {
|
|
||||||
setError(errorMessage(err, 'Could not copy the garden.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Copy garden" onClose={onClose} busy={copy.isPending}>
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Make a copy of <span className="font-medium text-fg">{garden.name}</span> with its beds and
|
|
||||||
everything planted in them. The copy is private — the original's share link and people you've
|
|
||||||
shared it with aren't carried over.
|
|
||||||
</p>
|
|
||||||
<TextField
|
|
||||||
label="Name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={copy.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={copy.isPending || name.trim() === ''}>
|
|
||||||
{copy.isPending ? 'Copying…' : 'Copy garden'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
|
||||||
import { useDeleteGarden, type Garden } from '@/lib/gardens'
|
|
||||||
|
|
||||||
/** Confirmation dialog for deleting a garden (and everything in it). */
|
|
||||||
export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
|
||||||
const deletion = useDeleteGarden()
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Delete garden"
|
|
||||||
confirmLabel="Delete"
|
|
||||||
busyLabel="Deleting…"
|
|
||||||
errorFallback="Could not delete the garden."
|
|
||||||
onConfirm={() => deletion.mutateAsync(garden.id)}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
|
||||||
This can't be undone.
|
|
||||||
</p>
|
|
||||||
</ConfirmModal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { IconButton } from '@/components/ui/Button'
|
||||||
|
import { Tag } from '@/components/ui/Tag'
|
||||||
import type { Garden } from '@/lib/gardens'
|
import type { Garden } from '@/lib/gardens'
|
||||||
import { formatDimensions } from '@/lib/units'
|
import { useGardenFull } from '@/lib/objects'
|
||||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
import { parsePlanName, planYearOf } from '@/lib/plan'
|
||||||
|
import { sharesQueryOptions } from '@/lib/shares'
|
||||||
|
import { formatSize } from '@/lib/units'
|
||||||
|
import { kindPlural } from '@/editor/kinds'
|
||||||
|
import { GardenThumb } from './GardenThumb'
|
||||||
|
|
||||||
|
// The order kinds are counted in on the card's meta line.
|
||||||
|
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One garden as a card: the body links into the editor. The footer differs by
|
* One garden as a card: the plot thumbnail (a link into the editor), name, size,
|
||||||
* role — the owner gets Share / Copy / Edit / Delete; a recipient sees a
|
* a counts line, who it's shared with, and a footer of Open + share / copy /
|
||||||
* "shared · role" badge and a Leave action (garden metadata edit, sharing and
|
* edit / delete. A plan copy shows its base name with a `<year> plan` tag. A
|
||||||
* copying are owner-only).
|
* garden shared WITH you shows its role and a leave action instead of the
|
||||||
* Ownership is the authoritative ownerId==me check, not the my_role hint.
|
* owner's tools.
|
||||||
*/
|
*/
|
||||||
export function GardenCard({
|
export function GardenCard({
|
||||||
garden,
|
garden,
|
||||||
@@ -28,47 +39,79 @@ export function GardenCard({
|
|||||||
onLeave: () => void
|
onLeave: () => void
|
||||||
}) {
|
}) {
|
||||||
const owner = currentUserId != null && garden.ownerId === currentUserId
|
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||||
|
const full = useGardenFull(garden.id)
|
||||||
|
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||||
|
const plan = parsePlanName(garden.name)
|
||||||
|
const planYear = planYearOf(garden.name)
|
||||||
|
// A plan's year is the point of its name, and the first thing truncation
|
||||||
|
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
|
||||||
|
const title = planYear != null && plan ? plan.base : garden.name
|
||||||
|
|
||||||
|
const meta = useMemo(() => {
|
||||||
|
const data = full.data
|
||||||
|
if (!data) return full.isError ? 'Could not load the plot.' : '…'
|
||||||
|
if (data.objects.length === 0) return 'Bare ground — drag your first bed on.'
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const o of data.objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
|
||||||
|
const parts = COUNTED_KINDS.filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!))
|
||||||
|
for (const [k, n] of counts) if (!COUNTED_KINDS.includes(k)) parts.push(kindPlural(k, n))
|
||||||
|
const plops = data.plantings.length
|
||||||
|
parts.push(`${plops} ${plops === 1 ? 'planting' : 'plantings'}`)
|
||||||
|
const since = new Date(garden.createdAt).getFullYear()
|
||||||
|
if (Number.isFinite(since)) parts.push(`tended since ${since}`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}, [full.data, full.isError, garden.createdAt])
|
||||||
|
|
||||||
|
const sharedLine = (() => {
|
||||||
|
if (!owner) return garden.myRole ? `Shared with you · ${garden.myRole}` : 'Shared with you'
|
||||||
|
const list = shares.data ?? []
|
||||||
|
if (list.length === 0) return null
|
||||||
|
const first = list[0]
|
||||||
|
const who = first.email.includes('@') ? first.email.slice(0, first.email.indexOf('@') + 1) : first.displayName
|
||||||
|
return list.length === 1 ? `Shared with ${who} · ${first.role}` : `Shared with ${who} +${list.length - 1}`
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
|
<div className="panel flex flex-col overflow-hidden transition-shadow hover:[box-shadow:var(--shadow-md)]">
|
||||||
<Link
|
<Link
|
||||||
to="/gardens/$gardenId"
|
to="/gardens/$gardenId"
|
||||||
params={{ gardenId: String(garden.id) }}
|
params={{ gardenId: String(garden.id) }}
|
||||||
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="block border-b border-divider bg-field no-underline"
|
||||||
|
aria-label={`Open ${garden.name}`}
|
||||||
>
|
>
|
||||||
|
<GardenThumb widthCm={garden.widthCm} heightCm={garden.heightCm} full={full.data} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
|
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
|
||||||
{!owner && garden.myRole && (
|
{title}
|
||||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
</span>
|
||||||
shared · {garden.myRole}
|
{planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
|
||||||
</span>
|
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
|
||||||
|
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] leading-relaxed text-ink-soft">{meta}</div>
|
||||||
|
{sharedLine && <div className="text-xs font-semibold text-accent-2-700">{sharedLine}</div>}
|
||||||
|
<div className="mt-auto flex gap-2 pt-2">
|
||||||
|
<Link
|
||||||
|
to="/gardens/$gardenId"
|
||||||
|
params={{ gardenId: String(garden.id) }}
|
||||||
|
className="btn btn-primary flex-1 no-underline"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
{owner ? (
|
||||||
|
<>
|
||||||
|
<IconButton label="Share" icon="share-2" onClick={onShare} />
|
||||||
|
<IconButton label="Copy — plan a season from it" icon="copy" onClick={onCopy} />
|
||||||
|
<IconButton label="Edit the garden's name and size" icon="pencil" onClick={onEdit} />
|
||||||
|
<IconButton label="Delete" icon="trash-2" onClick={onDelete} iconClassName="text-accent-700" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<IconButton label="Leave this garden" icon="log-out" onClick={onLeave} iconClassName="text-accent-700" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted">
|
|
||||||
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
|
|
||||||
</p>
|
|
||||||
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
|
|
||||||
</Link>
|
|
||||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
|
||||||
{owner ? (
|
|
||||||
<>
|
|
||||||
<button type="button" onClick={onShare} className={cardActionClass}>
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onCopy} className={cardActionClass}>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={onLeave} className={cardDangerClass}>
|
|
||||||
Leave
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||||
|
import { Seg } from '@/components/ui/Seg'
|
||||||
|
import { Toggle } from '@/components/ui/Toggle'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||||
|
import {
|
||||||
|
cmFromFtIn,
|
||||||
|
convertDimensionField,
|
||||||
|
dimensionField,
|
||||||
|
dimensionInputMode,
|
||||||
|
dimensionUnitLabel,
|
||||||
|
editDimensionField,
|
||||||
|
formatCm,
|
||||||
|
isValidDimensionCm,
|
||||||
|
MIN_GARDEN_GRID_CM,
|
||||||
|
type LengthField,
|
||||||
|
type UnitPref,
|
||||||
|
} from '@/lib/units'
|
||||||
|
|
||||||
|
// A new plot: the design's 20′ × 12′, spoken in feet; the garden grid defaults
|
||||||
|
// to a foot (the server's 1 m for a metric garden).
|
||||||
|
const DEFAULT_W_FT = 20
|
||||||
|
const DEFAULT_H_FT = 12
|
||||||
|
const DEFAULT_GRID_CM = 100
|
||||||
|
|
||||||
|
const unitOptions = [
|
||||||
|
{ value: 'imperial' as const, label: 'ft' },
|
||||||
|
{ value: 'metric' as const, label: 'm' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function entryHint(unit: UnitPref): string {
|
||||||
|
return unit === 'imperial' ? `Sizes read as feet and inches — 8' 6", 8', or 8.5 for feet.` : 'Sizes are in meters, e.g. 2.5.'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
|
||||||
|
* chosen unit and stored as centimeters: each field is a LengthField, so
|
||||||
|
* switching units re-shows the same centimeters and a Save sends exactly what
|
||||||
|
* was loaded unless the person typed over it (re-parsing the display string is
|
||||||
|
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
|
||||||
|
* fresh row.
|
||||||
|
*/
|
||||||
|
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||||
|
const isEdit = !!garden
|
||||||
|
const create = useCreateGarden()
|
||||||
|
const update = useUpdateGarden()
|
||||||
|
const pending = create.isPending || update.isPending
|
||||||
|
|
||||||
|
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
|
||||||
|
const [name, setName] = useState(garden?.name ?? '')
|
||||||
|
const [unit, setUnit] = useState<UnitPref>(initialUnit)
|
||||||
|
const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
|
||||||
|
const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
|
||||||
|
const [gridSize, setGridSize] = useState<LengthField>(() =>
|
||||||
|
dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
|
||||||
|
)
|
||||||
|
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||||
|
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||||
|
const [more, setMore] = useState(isEdit && (!!garden.notes || garden.snapToGrid))
|
||||||
|
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function changeUnit(next: UnitPref) {
|
||||||
|
setWidth((f) => convertDimensionField(f, next))
|
||||||
|
setHeight((f) => convertDimensionField(f, next))
|
||||||
|
setGridSize((f) => convertDimensionField(f, next))
|
||||||
|
setUnit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridSizeCm = gridSize.cm
|
||||||
|
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFormError(null)
|
||||||
|
setConflict(null)
|
||||||
|
if (!name.trim()) {
|
||||||
|
setFormError('Give the garden a name.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const widthCm = width.cm
|
||||||
|
const heightCm = height.cm
|
||||||
|
if (widthCm === null || heightCm === null) {
|
||||||
|
setFormError(entryHint(unit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
||||||
|
setFormError('Width and depth must be between 1 cm and 100 m.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (gridSizeCm === null || !isValidDimensionCm(gridSizeCm)) {
|
||||||
|
setFormError('The grid must be between 1 cm and 100 m.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
|
||||||
|
// Nothing changed: close without a request. A PATCH that writes the same
|
||||||
|
// row still bumps the version and lands an "Edited garden settings" step
|
||||||
|
// in History that undoes nothing.
|
||||||
|
if (isEdit && (Object.keys(input) as (keyof typeof input)[]).every((k) => input[k] === garden[k])) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
|
||||||
|
else await create.mutateAsync(input)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const current = conflictGarden(err)
|
||||||
|
if (current) {
|
||||||
|
setVersion(current.version)
|
||||||
|
setName(current.name)
|
||||||
|
setUnit(current.unitPref)
|
||||||
|
setWidth(dimensionField(current.widthCm, current.unitPref))
|
||||||
|
setHeight(dimensionField(current.heightCm, current.unitPref))
|
||||||
|
setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
|
||||||
|
setSnapToGrid(current.snapToGrid)
|
||||||
|
setNotes(current.notes)
|
||||||
|
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFormError(errorMessage(err, isEdit ? 'Could not save the garden.' : 'Could not create the garden.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = dimensionUnitLabel(unit)
|
||||||
|
const inputMode = dimensionInputMode(unit)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={isEdit ? `Edit ${garden.name}` : 'A new garden'} onClose={onClose} busy={pending}>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||||
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||||
|
<TextField label="Name" name="name" required autoFocus placeholder="Back forty" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<TextField
|
||||||
|
label={`Width (${u})`}
|
||||||
|
name="width"
|
||||||
|
type="text"
|
||||||
|
inputMode={inputMode}
|
||||||
|
required
|
||||||
|
value={width.text}
|
||||||
|
onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={`Depth (${u})`}
|
||||||
|
name="height"
|
||||||
|
type="text"
|
||||||
|
inputMode={inputMode}
|
||||||
|
required
|
||||||
|
value={height.text}
|
||||||
|
onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="field">
|
||||||
|
<label>Units</label>
|
||||||
|
<Seg options={unitOptions} value={unit} onChange={changeUnit} label="Units" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-mute">Stored in centimeters under the hood — {unit === 'imperial' ? 'feet are' : 'meters are'} just how you talk.</div>
|
||||||
|
|
||||||
|
{!more ? (
|
||||||
|
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
||||||
|
Grid & notes…
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
||||||
|
<div className="flex items-end gap-2.5">
|
||||||
|
<TextField
|
||||||
|
label={`Garden grid (${u})`}
|
||||||
|
name="gridSize"
|
||||||
|
type="text"
|
||||||
|
inputMode={inputMode}
|
||||||
|
value={gridSize.text}
|
||||||
|
onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
hint={
|
||||||
|
gridTooFine && gridSizeCm !== null
|
||||||
|
? `${formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid — plant spacing lives on each bed.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="field">
|
||||||
|
<label>Snap objects</label>
|
||||||
|
<Toggle on={snapToGrid} onChange={setSnapToGrid} label="Snap objects to the garden grid" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formError && <Alert>{formError}</Alert>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" onClick={onClose} disabled={pending}>
|
||||||
|
Never mind
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={pending}>
|
||||||
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Break ground'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { Select } from '@/components/ui/Select'
|
|
||||||
import { TextArea } from '@/components/ui/TextArea'
|
|
||||||
import { TextField } from '@/components/ui/TextField'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
|
||||||
import {
|
|
||||||
dimensionUnitLabel,
|
|
||||||
formatCm,
|
|
||||||
dimensionInputMode,
|
|
||||||
formatDimensionInput,
|
|
||||||
isValidDimensionCm,
|
|
||||||
MIN_GARDEN_GRID_CM,
|
|
||||||
parseDimension,
|
|
||||||
type UnitPref,
|
|
||||||
} from '@/lib/units'
|
|
||||||
|
|
||||||
const DEFAULT_METERS = 10 // matches the server's 10 m default
|
|
||||||
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
|
|
||||||
|
|
||||||
// What to say when a field can't be read. Naming the accepted forms beats
|
|
||||||
// "invalid input", which leaves the person guessing which field and which part.
|
|
||||||
function entryHint(unit: UnitPref): string {
|
|
||||||
return unit === 'imperial'
|
|
||||||
? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.`
|
|
||||||
: 'Enter sizes in meters, e.g. 2.5.'
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitOptions = [
|
|
||||||
{ value: 'metric', label: 'Metric (m)' },
|
|
||||||
{ value: 'imperial', label: 'Imperial (ft)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function dimString(cm: number | undefined, unit: UnitPref): string {
|
|
||||||
return cm === undefined ? String(DEFAULT_METERS) : formatDimensionInput(cm, unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create (no garden) or edit (garden given) form. Dimensions are entered in the
|
|
||||||
* selected unit and converted to centimeters for the API; switching units
|
|
||||||
* converts the current values so the physical size is preserved. A 409 rebases
|
|
||||||
* the form onto the server's fresh row.
|
|
||||||
*/
|
|
||||||
export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
|
||||||
const isEdit = !!garden
|
|
||||||
const create = useCreateGarden()
|
|
||||||
const update = useUpdateGarden()
|
|
||||||
const pending = create.isPending || update.isPending
|
|
||||||
|
|
||||||
const [name, setName] = useState(garden?.name ?? '')
|
|
||||||
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
|
|
||||||
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
|
|
||||||
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
|
|
||||||
const [gridSize, setGridSize] = useState(() =>
|
|
||||||
formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'),
|
|
||||||
)
|
|
||||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
|
||||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
|
||||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
function changeUnit(next: UnitPref) {
|
|
||||||
// Re-render each field in the new unit, preserving the physical size. An
|
|
||||||
// unparseable field is left as typed rather than blanked.
|
|
||||||
const convert = (s: string) => {
|
|
||||||
const cm = parseDimension(s, unit)
|
|
||||||
return cm === null ? s : formatDimensionInput(cm, next)
|
|
||||||
}
|
|
||||||
setWidth(convert(width))
|
|
||||||
setHeight(convert(height))
|
|
||||||
// The garden grid is a layout concern, so it lives at the same scale as the
|
|
||||||
// garden's own dimensions — same helpers, same unit label. (The *bed* grid in
|
|
||||||
// the object inspector is a plant-spacing concern and stays at cm/in.)
|
|
||||||
setGridSize(convert(gridSize))
|
|
||||||
setUnit(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converted once per render and used by both the submit handler and the
|
|
||||||
// too-fine hint, so the two can never disagree about what was entered.
|
|
||||||
const gridSizeCm = parseDimension(gridSize, unit)
|
|
||||||
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
|
|
||||||
// someone reaching for plant spacing, which lives on the bed instead — but it
|
|
||||||
// is a legitimate choice for a very small garden, so the save still goes through.
|
|
||||||
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setFormError(null)
|
|
||||||
setConflict(null)
|
|
||||||
|
|
||||||
if (!name.trim()) {
|
|
||||||
setFormError('Enter a name for the garden.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Validate the converted centimeter values against the same bounds the
|
|
||||||
// server enforces, so sub-cm or over-100m sizes fail here with a clear
|
|
||||||
// message instead of a generic server error.
|
|
||||||
const widthCm = parseDimension(width, unit)
|
|
||||||
const heightCm = parseDimension(height, unit)
|
|
||||||
if (widthCm === null || heightCm === null) {
|
|
||||||
setFormError(entryHint(unit))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
|
||||||
setFormError('Width and height must be between 1 cm and 100 m.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (gridSizeCm === null) {
|
|
||||||
setFormError(entryHint(unit))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isValidDimensionCm(gridSizeCm)) {
|
|
||||||
setFormError('Grid size must be between 1 cm and 100 m.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = {
|
|
||||||
name: name.trim(),
|
|
||||||
widthCm,
|
|
||||||
heightCm,
|
|
||||||
unitPref: unit,
|
|
||||||
notes: notes.trim(),
|
|
||||||
gridSizeCm,
|
|
||||||
snapToGrid,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEdit) {
|
|
||||||
await update.mutateAsync({ id: garden.id, ...input, version })
|
|
||||||
} else {
|
|
||||||
await create.mutateAsync(input)
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
const current = conflictGarden(err)
|
|
||||||
if (current) {
|
|
||||||
// Someone else changed this garden: rebase the form onto the fresh row so
|
|
||||||
// a re-save applies against the current version.
|
|
||||||
setVersion(current.version)
|
|
||||||
setName(current.name)
|
|
||||||
setUnit(current.unitPref)
|
|
||||||
setWidth(dimString(current.widthCm, current.unitPref))
|
|
||||||
setHeight(dimString(current.heightCm, current.unitPref))
|
|
||||||
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
|
|
||||||
setSnapToGrid(current.snapToGrid)
|
|
||||||
setNotes(current.notes)
|
|
||||||
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitLabel = dimensionUnitLabel(unit)
|
|
||||||
const inputMode = dimensionInputMode(unit)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
|
||||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
|
||||||
|
|
||||||
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Units"
|
|
||||||
name="unitPref"
|
|
||||||
value={unit}
|
|
||||||
onChange={(e) => changeUnit(e.target.value as UnitPref)}
|
|
||||||
options={unitOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField
|
|
||||||
label={`Width (${unitLabel})`}
|
|
||||||
name="width"
|
|
||||||
type="text"
|
|
||||||
inputMode={inputMode}
|
|
||||||
required
|
|
||||||
value={width}
|
|
||||||
onChange={(e) => setWidth(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={`Height (${unitLabel})`}
|
|
||||||
name="height"
|
|
||||||
type="text"
|
|
||||||
inputMode={inputMode}
|
|
||||||
required
|
|
||||||
value={height}
|
|
||||||
onChange={(e) => setHeight(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<TextField
|
|
||||||
label={`Garden grid (${unitLabel})`}
|
|
||||||
name="gridSize"
|
|
||||||
type="text"
|
|
||||||
inputMode={inputMode}
|
|
||||||
value={gridSize}
|
|
||||||
onChange={(e) => setGridSize(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={snapToGrid}
|
|
||||||
onChange={(e) => setSnapToGrid(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-border"
|
|
||||||
/>
|
|
||||||
Snap objects
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{gridTooFine && (
|
|
||||||
<p className="mt-1 text-xs text-muted">
|
|
||||||
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
|
|
||||||
(Bed grid in the inspector), not here.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
||||||
|
|
||||||
{formError && <Alert>{formError}</Alert>}
|
|
||||||
|
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={pending}>
|
|
||||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create garden'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { localToWorld } from '@/lib/geometry'
|
||||||
|
import type { FullGarden } from '@/lib/objects'
|
||||||
|
import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
|
||||||
|
import { objectStyle, rectRadius } from '@/editor/kinds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plot thumbnail on a garden card: the field, its objects at true layout,
|
||||||
|
* and every active planting as a dot in its plant's color. Pure SVG from the
|
||||||
|
* editor payload — the same data the editor opens with, so the card is an
|
||||||
|
* honest preview and the editor then loads from cache.
|
||||||
|
*/
|
||||||
|
export function GardenThumb({
|
||||||
|
widthCm,
|
||||||
|
heightCm,
|
||||||
|
full,
|
||||||
|
}: {
|
||||||
|
widthCm: number
|
||||||
|
heightCm: number
|
||||||
|
/** Undefined while the payload loads: draws the bare field. */
|
||||||
|
full?: FullGarden
|
||||||
|
}) {
|
||||||
|
const W = Math.max(1, widthCm)
|
||||||
|
const H = Math.max(1, heightCm)
|
||||||
|
const sw = Math.max(2, Math.min(8, Math.round(Math.max(W, H) / 120)))
|
||||||
|
const inset = sw
|
||||||
|
const plantColor = useMemo(() => new Map((full?.plants ?? []).map((p) => [p.id, p.color])), [full?.plants])
|
||||||
|
const objects = useMemo(() => [...(full?.objects ?? [])].sort((a, b) => a.zIndex - b.zIndex), [full?.objects])
|
||||||
|
const byId = useMemo(() => new Map(objects.map((o) => [o.id, o])), [objects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="block h-[150px] w-full" preserveAspectRatio="xMidYMid meet" aria-hidden>
|
||||||
|
<rect
|
||||||
|
x={inset}
|
||||||
|
y={inset}
|
||||||
|
width={W - inset * 2}
|
||||||
|
height={H - inset * 2}
|
||||||
|
rx={Math.min(18, W * 0.03)}
|
||||||
|
fill="var(--p-field)"
|
||||||
|
stroke="var(--p-tree-stroke)"
|
||||||
|
strokeWidth={sw}
|
||||||
|
/>
|
||||||
|
{objects.map((o) => {
|
||||||
|
const st = objectStyle(o)
|
||||||
|
const t = `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
|
||||||
|
return o.shape === 'circle' ? (
|
||||||
|
<ellipse
|
||||||
|
key={o.id}
|
||||||
|
transform={t}
|
||||||
|
rx={o.widthCm / 2}
|
||||||
|
ry={o.heightCm / 2}
|
||||||
|
fill={st.fill}
|
||||||
|
stroke={st.stroke}
|
||||||
|
strokeWidth={sw * 0.6}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<rect
|
||||||
|
key={o.id}
|
||||||
|
transform={t}
|
||||||
|
x={-o.widthCm / 2}
|
||||||
|
y={-o.heightCm / 2}
|
||||||
|
width={o.widthCm}
|
||||||
|
height={o.heightCm}
|
||||||
|
rx={rectRadius(o.widthCm)}
|
||||||
|
fill={st.fill}
|
||||||
|
stroke={st.stroke}
|
||||||
|
strokeWidth={sw * 0.6}
|
||||||
|
strokeDasharray={st.dash}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(full?.plantings ?? []).map((p) => {
|
||||||
|
const o = byId.get(p.objectId)
|
||||||
|
if (!o) return null
|
||||||
|
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||||
|
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? FALLBACK_PLANT_COLOR} />
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
|
||||||
import { useMe } from '@/lib/auth'
|
|
||||||
import type { Garden } from '@/lib/gardens'
|
|
||||||
import { useRemoveShare } from '@/lib/shares'
|
|
||||||
|
|
||||||
/** Confirmation for a recipient leaving a garden shared with them (removes their
|
|
||||||
* own share). */
|
|
||||||
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
|
||||||
const me = useMe()
|
|
||||||
const remove = useRemoveShare(garden.id)
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Leave garden"
|
|
||||||
confirmLabel="Leave"
|
|
||||||
busyLabel="Leaving…"
|
|
||||||
confirmDisabled={!me.data}
|
|
||||||
errorFallback="Could not leave the garden."
|
|
||||||
onConfirm={async () => {
|
|
||||||
// The button is disabled without a current user; throw rather than
|
|
||||||
// silently resolve (which would close the dialog as if it had worked) if
|
|
||||||
// that guard ever drifts.
|
|
||||||
if (!me.data) throw new Error('Not signed in.')
|
|
||||||
await remove.mutateAsync(me.data.id)
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
|
||||||
shares it with you again.
|
|
||||||
</p>
|
|
||||||
</ConfirmModal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button, IconButton } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { Toggle } from '@/components/ui/Toggle'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import type { Garden } from '@/lib/gardens'
|
||||||
|
import {
|
||||||
|
useAddShare,
|
||||||
|
useDisableShareLink,
|
||||||
|
useEnableShareLink,
|
||||||
|
useRemoveShare,
|
||||||
|
useShareLink,
|
||||||
|
useShares,
|
||||||
|
useUpdateShareRole,
|
||||||
|
type ShareRole,
|
||||||
|
} from '@/lib/shares'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner-only: invite an existing account by email (new invites start as
|
||||||
|
* viewers — tap the role chip to flip to editor), remove a share, and manage the
|
||||||
|
* public read-only link. v1 has no invitation emails; an unknown address gets a
|
||||||
|
* friendly "no account with that email".
|
||||||
|
*/
|
||||||
|
export function ShareDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||||
|
const shares = useShares(garden.id)
|
||||||
|
const add = useAddShare(garden.id)
|
||||||
|
const updateRole = useUpdateShareRole(garden.id)
|
||||||
|
const remove = useRemoveShare(garden.id)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const busy = add.isPending || updateRole.isPending || remove.isPending
|
||||||
|
|
||||||
|
async function onInvite(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
const addr = email.trim()
|
||||||
|
if (!addr) return
|
||||||
|
try {
|
||||||
|
await add.mutateAsync({ email: addr, role: 'viewer' })
|
||||||
|
setEmail('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, 'Could not share the garden.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={`Share ${garden.name}`} onClose={onClose} busy={busy} width={440}>
|
||||||
|
<form onSubmit={onInvite} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
aria-label="Invite by email"
|
||||||
|
autoComplete="off"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="primary" className="flex-none" disabled={add.isPending || !email.trim()}>
|
||||||
|
{add.isPending ? 'Inviting…' : 'Invite'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
||||||
|
{shares.isSuccess && shares.data.length === 0 && (
|
||||||
|
<p className="text-[13px] text-ink-mute">Not shared with anyone yet — invites go to existing accounts.</p>
|
||||||
|
)}
|
||||||
|
{shares.data?.map((sh) => (
|
||||||
|
<div key={sh.userId} className="flex items-center gap-2.5 rounded-full border border-divider bg-bg py-1.5 pl-4 pr-1.5">
|
||||||
|
<span className="min-w-0 truncate text-[13px] font-semibold" title={`${sh.displayName} · ${sh.email}`}>
|
||||||
|
{sh.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag tag-accent-2 ml-auto cursor-pointer border-0"
|
||||||
|
title="Tap to switch between viewer and editor"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
const role: ShareRole = sh.role === 'viewer' ? 'editor' : 'viewer'
|
||||||
|
updateRole.mutate({ userId: sh.userId, role }, { onError: onMutationError('Could not change that role.') })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sh.role}
|
||||||
|
</button>
|
||||||
|
<IconButton
|
||||||
|
label={`Remove ${sh.displayName}`}
|
||||||
|
icon="x"
|
||||||
|
iconSize={13}
|
||||||
|
variant="plain"
|
||||||
|
size={30}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="hr my-0.5" />
|
||||||
|
<PublicLinkSection gardenId={garden.id} />
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={onClose}>Done</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public read-only link: a toggle, the link itself (tap to copy), and a
|
||||||
|
* way to issue a fresh one that invalidates the old. */
|
||||||
|
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
||||||
|
const link = useShareLink(gardenId)
|
||||||
|
const enable = useEnableShareLink(gardenId)
|
||||||
|
const disable = useDisableShareLink(gardenId)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const token = link.data?.enabled ? link.data.token : undefined
|
||||||
|
const url = token ? `${window.location.origin}/g/${token}` : ''
|
||||||
|
const busy = link.isPending || enable.isPending || disable.isPending
|
||||||
|
|
||||||
|
const run = (p: Promise<unknown>, fallback: string) => {
|
||||||
|
setError(null)
|
||||||
|
p.catch((err) => setError(errorMessage(err, fallback)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (!url) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {
|
||||||
|
// Clipboard may be unavailable (non-secure context); the text is selectable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="text-[13px] font-semibold">Read-only link</span>
|
||||||
|
<span className="text-xs text-ink-mute">anyone with it can look, no account needed</span>
|
||||||
|
<Toggle
|
||||||
|
className="ml-auto"
|
||||||
|
label="Public read-only link"
|
||||||
|
on={!!link.data?.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(on) =>
|
||||||
|
on ? run(enable.mutateAsync({}), 'Could not create the link.') : run(disable.mutateAsync(), 'Could not turn off the link.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{link.isError && <Alert>Could not load the public link.</Alert>}
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
{url && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copy}
|
||||||
|
title="Copy the link"
|
||||||
|
className="min-w-0 flex-1 cursor-pointer truncate rounded-full border border-dashed border-divider bg-bg px-3.5 py-2 text-left text-xs text-ink-soft hover:border-accent-400"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied to the clipboard' : url}
|
||||||
|
</button>
|
||||||
|
<IconButton label="Copy the link" icon="copy" iconSize={14} onClick={copy} />
|
||||||
|
<IconButton
|
||||||
|
label="Issue a new link (the old one stops working)"
|
||||||
|
icon="refresh-cw"
|
||||||
|
iconSize={14}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { Select } from '@/components/ui/Select'
|
|
||||||
import { TextField } from '@/components/ui/TextField'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { fieldControlClass } from '@/components/ui/field'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
import type { Garden } from '@/lib/gardens'
|
|
||||||
import {
|
|
||||||
useAddShare,
|
|
||||||
useDisableShareLink,
|
|
||||||
useEnableShareLink,
|
|
||||||
useRemoveShare,
|
|
||||||
useShareLink,
|
|
||||||
useShares,
|
|
||||||
useUpdateShareRole,
|
|
||||||
type ShareRole,
|
|
||||||
} from '@/lib/shares'
|
|
||||||
|
|
||||||
const roleOptions = [
|
|
||||||
{ value: 'viewer', label: 'Viewer (read-only)' },
|
|
||||||
{ value: 'editor', label: 'Editor (can edit)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owner-only dialog to manage a garden's shares: invite an existing user by
|
|
||||||
* email as viewer/editor, change a share's role, or remove it. Targets existing
|
|
||||||
* accounts only (v1 has no invitation emails) — an unknown email surfaces a
|
|
||||||
* friendly "no account with that email".
|
|
||||||
*/
|
|
||||||
export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
|
||||||
const shares = useShares(garden.id)
|
|
||||||
const add = useAddShare(garden.id)
|
|
||||||
const updateRole = useUpdateShareRole(garden.id)
|
|
||||||
const remove = useRemoveShare(garden.id)
|
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [role, setRole] = useState<ShareRole>('viewer')
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function onInvite(e: FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
if (!email.trim()) {
|
|
||||||
setError('Enter an email address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await add.mutateAsync({ email: email.trim(), role })
|
|
||||||
setEmail('')
|
|
||||||
} catch (err) {
|
|
||||||
setError(errorMessage(err, 'Could not share the garden.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
|
||||||
<TextField
|
|
||||||
label="Invite by email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<Select
|
|
||||||
label="Role"
|
|
||||||
name="role"
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
|
||||||
options={roleOptions}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button type="submit" disabled={add.isPending}>
|
|
||||||
{add.isPending ? 'Sharing…' : 'Share'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
|
|
||||||
{shares.isPending && <p className="text-sm text-muted">Loading…</p>}
|
|
||||||
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
|
||||||
{shares.isSuccess && shares.data.length === 0 && (
|
|
||||||
<p className="text-sm text-muted">Not shared with anyone yet.</p>
|
|
||||||
)}
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{shares.data?.map((sh) => (
|
|
||||||
<li key={sh.userId} className="flex items-center gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
|
|
||||||
<p className="truncate text-xs text-muted">{sh.email}</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={sh.role}
|
|
||||||
onChange={(e) => {
|
|
||||||
setError(null)
|
|
||||||
updateRole.mutate(
|
|
||||||
{ userId: sh.userId, role: e.target.value as ShareRole },
|
|
||||||
{ onError: onMutationError('Could not change that role.') },
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
aria-label={`Role for ${sh.displayName}`}
|
|
||||||
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
|
||||||
>
|
|
||||||
<option value="viewer">Viewer</option>
|
|
||||||
<option value="editor">Editor</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setError(null)
|
|
||||||
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
|
||||||
}}
|
|
||||||
aria-label={`Remove ${sh.displayName}`}
|
|
||||||
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PublicLinkSection gardenId={garden.id} />
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="ghost" onClick={onClose}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The public read-only link controls: create, copy, regenerate, turn off. */
|
|
||||||
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
|
||||||
const link = useShareLink(gardenId)
|
|
||||||
const enable = useEnableShareLink(gardenId)
|
|
||||||
const disable = useDisableShareLink(gardenId)
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const token = link.data?.enabled ? link.data.token : undefined
|
|
||||||
const url = token ? `${window.location.origin}/g/${token}` : ''
|
|
||||||
const busy = link.isPending || enable.isPending || disable.isPending
|
|
||||||
|
|
||||||
const run = (p: Promise<unknown>, fallback: string) => {
|
|
||||||
setError(null)
|
|
||||||
p.catch((err) => setError(errorMessage(err, fallback)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copy() {
|
|
||||||
if (!url) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url)
|
|
||||||
setCopied(true)
|
|
||||||
window.setTimeout(() => setCopied(false), 1500)
|
|
||||||
} catch {
|
|
||||||
// Clipboard API may be unavailable (e.g. non-secure context); the field is
|
|
||||||
// selectable so the user can still copy manually.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-t border-border pt-4">
|
|
||||||
<h3 className="mb-1 text-sm font-medium text-fg">Public link</h3>
|
|
||||||
<p className="mb-2 text-xs text-muted">
|
|
||||||
Anyone with the link can view this garden read-only — no account needed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{link.isError && <Alert>Could not load the public link.</Alert>}
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
|
|
||||||
{link.isSuccess && !link.data.enabled && (
|
|
||||||
<Button onClick={() => run(enable.mutateAsync({}), 'Could not create the link.')} disabled={busy}>
|
|
||||||
{enable.isPending ? 'Creating…' : 'Create public link'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{link.isSuccess && link.data.enabled && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={url}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
aria-label="Public link URL"
|
|
||||||
className={cn(fieldControlClass, 'min-w-0 flex-1 text-sm')}
|
|
||||||
/>
|
|
||||||
<Button variant="ghost" onClick={copy} disabled={!url}>
|
|
||||||
{copied ? 'Copied' : 'Copy'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
|
||||||
disabled={busy}
|
|
||||||
title="Issue a new link and invalidate the old one"
|
|
||||||
>
|
|
||||||
{enable.isPending ? 'Working…' : 'Regenerate'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
|
||||||
onClick={() => run(disable.mutateAsync(), 'Could not turn off the link.')}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Turn off
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { Icon } from '@/components/ui/Icon'
|
||||||
|
import { useLogout, type User } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
/** The avatar circle in the nav (the user's initial on sage) and its small
|
||||||
|
* sign-out popover. */
|
||||||
|
export function AccountMenu({ user, className }: { user: User; className?: string }) {
|
||||||
|
const logout = useLogout()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
|
// Close on any route change so navigating can't leave the popover stuck open.
|
||||||
|
useEffect(() => setOpen(false), [pathname])
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
try {
|
||||||
|
await logout.mutateAsync()
|
||||||
|
await navigate({ to: '/login' })
|
||||||
|
} catch {
|
||||||
|
// Keep the popover open so "Retry sign out" stays reachable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = user.displayName.trim().charAt(0).toUpperCase() || '·'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`Account: ${user.displayName}`}
|
||||||
|
title={user.displayName}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-accent-2-300 text-sm font-bold text-accent-2-800"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<button type="button" aria-label="Close menu" className="fixed inset-0 z-30 cursor-default" onClick={() => setOpen(false)} />
|
||||||
|
<div role="menu" className="panel elev-md absolute right-0 z-40 mt-2 w-56 !rounded-[18px] p-2">
|
||||||
|
<p className="truncate px-3 py-2 text-xs text-ink-mute">
|
||||||
|
Signed in as <span className="font-semibold text-text">{user.displayName}</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-[11px]">{user.email}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
|
className="btn btn-ghost w-full justify-start gap-2 px-3 text-[13px]"
|
||||||
|
>
|
||||||
|
<Icon name="log-out" size={14} />
|
||||||
|
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,224 +1,24 @@
|
|||||||
import { Suspense, useEffect, useState } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
import { Outlet } from '@tanstack/react-router'
|
||||||
import { Toaster } from '@/components/ui/toast'
|
import { Toaster } from '@/components/ui/toast'
|
||||||
import { useLogout, useMe } from '@/lib/auth'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
|
|
||||||
// Top-level sections. `icon` is only used by the mobile bottom bar; the desktop
|
|
||||||
// top bar shows labels alone. Settings is filtered to admins at render.
|
|
||||||
const sections = [
|
|
||||||
{ to: '/gardens', label: 'Gardens', icon: '🏡', adminOnly: false },
|
|
||||||
{ to: '/plants', label: 'Plants', icon: '🌿', adminOnly: false },
|
|
||||||
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type Section = (typeof sections)[number]
|
|
||||||
|
|
||||||
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
|
||||||
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
|
||||||
// state props — never in the base — to avoid ambiguous overrides.
|
|
||||||
const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors'
|
|
||||||
const navLinkActive = 'bg-border/60 text-fg'
|
|
||||||
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level chrome. Mobile-first: a slim top bar (brand + account) with the
|
* The root layout is deliberately empty chrome: every page renders its own nav
|
||||||
* section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint
|
* (the editor wants a different one on a phone than Gardens does), so the shell
|
||||||
* (`md:`) restores the inline top nav. The editor is a full-screen context, so it
|
* only provides the Suspense boundary for the code-split routes and the toast
|
||||||
* owns the bottom of the screen — the app bottom bar hides there (the brand link
|
* stack. The theme is applied to <html> by the bootstrap in index.html.
|
||||||
* is the way back to the gardens list), leaving exactly one bottom bar per route.
|
|
||||||
*/
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const me = useMe()
|
|
||||||
const user = me.data
|
|
||||||
const matchRoute = useMatchRoute()
|
|
||||||
|
|
||||||
// Full-screen canvas contexts own the bottom of the screen: the garden editor
|
|
||||||
// and the public shared-garden view both render a 100dvh-8rem canvas, so a
|
|
||||||
// signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false
|
|
||||||
// so the '/gardens' list itself still shows the bar.
|
|
||||||
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
|
|
||||||
const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
|
|
||||||
// A canvas route wants the whole width — the garden editor is squeezed by the
|
|
||||||
// max-w-5xl reading measure the other pages use (#107).
|
|
||||||
const canvasRoute = inEditor || inPublicGarden
|
|
||||||
const showBottomNav = !!user && !canvasRoute
|
|
||||||
|
|
||||||
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<>
|
||||||
<header className="sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur">
|
<Suspense fallback={<PageFallback />}>
|
||||||
{/* The bar matches the content width below: constrained on reading pages,
|
<Outlet />
|
||||||
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
|
</Suspense>
|
||||||
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
|
|
||||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
|
||||||
🌱 pansy
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop inline section links. Hidden on mobile, where the bottom bar
|
|
||||||
carries them. */}
|
|
||||||
<div className="hidden flex-1 items-center gap-1 md:flex">
|
|
||||||
{user &&
|
|
||||||
visibleSections.map((s) => (
|
|
||||||
<Link
|
|
||||||
key={s.to}
|
|
||||||
to={s.to}
|
|
||||||
className={navLinkBase}
|
|
||||||
activeProps={{ className: navLinkActive }}
|
|
||||||
inactiveProps={{ className: navLinkInactive }}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spacer so account/sign-out sits right on mobile (the desktop links
|
|
||||||
own flex-1 above). */}
|
|
||||||
<div className="flex-1 md:hidden" />
|
|
||||||
|
|
||||||
{user ? (
|
|
||||||
<AccountMenu displayName={user.displayName} />
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main
|
|
||||||
className={cn(
|
|
||||||
'w-full flex-1 px-4 py-6',
|
|
||||||
// Constrain the reading pages to a comfortable measure; the canvas
|
|
||||||
// routes go edge-to-edge so the garden gets the whole screen.
|
|
||||||
!canvasRoute && 'mx-auto max-w-5xl',
|
|
||||||
// Clear the fixed bottom bar on mobile so content isn't hidden behind
|
|
||||||
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
|
|
||||||
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
|
|
||||||
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading…</p>}>
|
|
||||||
<Outlet />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{showBottomNav && <BottomNav sections={visibleSections} />}
|
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Account control: a compact button that toggles a small sign-out popover. On
|
export function PageFallback({ label = 'Loading…' }: { label?: string }) {
|
||||||
* desktop the display name shows inline; on mobile it lives inside the popover. */
|
return <p className="p-7 text-[13px] font-semibold text-ink-mute">{label}</p>
|
||||||
function AccountMenu({ displayName }: { displayName: string }) {
|
|
||||||
const logout = useLogout()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
|
|
||||||
// Close on any route change, so navigating (bottom nav, browser back) can't
|
|
||||||
// leave the popover — and its full-screen backdrop — stuck open over the page.
|
|
||||||
useEffect(() => setOpen(false), [pathname])
|
|
||||||
|
|
||||||
async function onLogout() {
|
|
||||||
try {
|
|
||||||
await logout.mutateAsync()
|
|
||||||
await navigate({ to: '/login' })
|
|
||||||
} catch {
|
|
||||||
// The logout request failed, so the session is still valid server-side.
|
|
||||||
// Keep the popover OPEN so the button (now "Retry sign out", driven by
|
|
||||||
// logout.isError) stays on screen — closing it would hide the only retry
|
|
||||||
// affordance and pretend nothing went wrong.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initial = displayName.trim().charAt(0).toUpperCase() || '·'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:text-fg"
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">{displayName}</span>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="flex size-8 items-center justify-center rounded-full bg-border/60 text-sm font-semibold text-fg"
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
{/* Full-screen click-away backdrop; reliably closes on an outside tap
|
|
||||||
without a document listener. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Close menu"
|
|
||||||
className="fixed inset-0 z-30 cursor-default"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
className="absolute right-0 z-40 mt-2 w-48 rounded-lg border border-border bg-surface p-1 shadow-lg"
|
|
||||||
>
|
|
||||||
<p className="truncate px-3 py-2 text-xs text-muted">
|
|
||||||
Signed in as <span className="text-fg">{displayName}</span>
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={onLogout}
|
|
||||||
disabled={logout.isPending}
|
|
||||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
|
||||||
className="w-full rounded-md px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
|
|
||||||
function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Sections"
|
|
||||||
className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
|
||||||
>
|
|
||||||
<ul className="mx-auto flex max-w-5xl items-stretch justify-around">
|
|
||||||
{sections.map((s) => (
|
|
||||||
<li key={s.to} className="flex-1">
|
|
||||||
{/* h-14 matches the clearance reserved on <main> above. Color lives
|
|
||||||
only in the state props (per the top-bar convention), never the
|
|
||||||
base, so active/inactive don't fight. */}
|
|
||||||
<Link
|
|
||||||
to={s.to}
|
|
||||||
className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
|
|
||||||
activeProps={{ className: 'text-accent-strong' }}
|
|
||||||
inactiveProps={{ className: 'text-muted hover:text-fg' }}
|
|
||||||
>
|
|
||||||
<span aria-hidden className="text-lg leading-none">
|
|
||||||
{s.icon}
|
|
||||||
</span>
|
|
||||||
{s.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Icon } from '@/components/ui/Icon'
|
||||||
|
import { useMe } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { AccountMenu } from './AccountMenu'
|
||||||
|
import { ThemeButton } from './ThemeButton'
|
||||||
|
|
||||||
|
export type NavSection = 'gardens' | 'plants' | 'settings'
|
||||||
|
|
||||||
|
/** The brand mark: sage sprout + "pansy" in the display face. */
|
||||||
|
export function Brand({ size = 19, textClassName }: { size?: number; textClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2 font-heading text-lg text-text">
|
||||||
|
<Icon name="sprout" size={size} className="text-accent-2-600" />
|
||||||
|
<span className={textClassName}>pansy</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top nav shared by the Gardens, Plants and Settings pages (and the editor
|
||||||
|
* on desktop): brand, centered section links, and the right cluster — theme
|
||||||
|
* toggle, the settings gear for admins, the account avatar.
|
||||||
|
*/
|
||||||
|
export function Nav({ active }: { active?: NavSection }) {
|
||||||
|
const me = useMe()
|
||||||
|
const user = me.data
|
||||||
|
const link = (to: '/gardens' | '/plants', id: NavSection, label: string) => (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
aria-current={active === id ? 'page' : undefined}
|
||||||
|
className={cn('text-sm text-text no-underline hover:text-accent', active === id && 'text-accent')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<nav className="flex flex-none items-center gap-[17.6px] px-[17.6px] py-[13.2px]">
|
||||||
|
<Link to="/gardens" className="mr-auto no-underline">
|
||||||
|
<Brand />
|
||||||
|
</Link>
|
||||||
|
{link('/gardens', 'gardens', 'Gardens')}
|
||||||
|
{link('/plants', 'plants', 'Plants')}
|
||||||
|
<span className="ml-auto flex items-center gap-2.5">
|
||||||
|
<ThemeButton />
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
aria-current={active === 'settings' ? 'page' : undefined}
|
||||||
|
title="Settings"
|
||||||
|
aria-label="Settings"
|
||||||
|
className={cn('btn btn-icon btn-secondary', active === 'settings' && 'bg-accent-100 text-accent')}
|
||||||
|
>
|
||||||
|
<Icon name="settings" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{user && <AccountMenu user={user} />}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { IconButton } from '@/components/ui/Button'
|
||||||
|
import type { IconName } from '@/components/ui/Icon'
|
||||||
|
import { cycleThemePref, useThemePref, type ThemePref } from '@/lib/theme'
|
||||||
|
|
||||||
|
const ICON: Record<ThemePref, IconName> = { system: 'monitor', light: 'sun', dark: 'moon' }
|
||||||
|
|
||||||
|
/** The nav's theme control: one button cycling system → light → dark. */
|
||||||
|
export function ThemeButton({ size, iconSize, className }: { size?: number; iconSize?: number; className?: string }) {
|
||||||
|
const pref = useThemePref()
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
label={`Theme: ${pref}`}
|
||||||
|
icon={ICON[pref]}
|
||||||
|
iconSize={iconSize}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
onClick={cycleThemePref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { CATEGORY_LABELS, PLANT_CATEGORIES, type CategoryFilter } from '@/lib/plants'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Horizontal, scrollable "All + each category" chip row. Shared by the /plants
|
|
||||||
* page and the PlantPicker so both filter the catalog identically.
|
|
||||||
*/
|
|
||||||
export function CategoryChips({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
size = 'md',
|
|
||||||
}: {
|
|
||||||
value: CategoryFilter
|
|
||||||
onChange: (c: CategoryFilter) => void
|
|
||||||
size?: 'sm' | 'md'
|
|
||||||
}) {
|
|
||||||
const chip = (v: CategoryFilter, label: string) => (
|
|
||||||
<button
|
|
||||||
key={v}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(v)}
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded-full px-3 font-medium transition-colors',
|
|
||||||
size === 'sm' ? 'py-1 text-xs' : 'py-1 text-sm',
|
|
||||||
value === v ? 'bg-accent text-accent-contrast' : 'bg-border/50 text-muted hover:text-fg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div className="flex gap-1.5 overflow-x-auto">
|
|
||||||
{chip('all', 'All')}
|
|
||||||
{PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
// Six curated marker colors from the design palette; the seventh well is a
|
||||||
|
// native color input for anything else.
|
||||||
|
export const CURATED_SWATCHES = ['#97a97c', '#c8553d', '#5f8f45', '#d9912f', '#b2622d', '#6d7f5a']
|
||||||
|
|
||||||
|
/** Expand #rgb to #rrggbb so the native color input renders it. */
|
||||||
|
export function expandHex(color: string, fallback = CURATED_SWATCHES[0]): string {
|
||||||
|
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
||||||
|
const [, r, g, b] = color
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`
|
||||||
|
}
|
||||||
|
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorSwatches({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
|
||||||
|
const custom = !CURATED_SWATCHES.includes(value.toLowerCase())
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{CURATED_SWATCHES.map((hex) => (
|
||||||
|
<button
|
||||||
|
key={hex}
|
||||||
|
type="button"
|
||||||
|
title={hex}
|
||||||
|
aria-label={`Marker color ${hex}`}
|
||||||
|
aria-pressed={value.toLowerCase() === hex}
|
||||||
|
onClick={() => onChange(hex)}
|
||||||
|
className={cn('h-[30px] w-[30px] rounded-full border-[3px]', value.toLowerCase() === hex ? 'border-accent' : 'border-transparent')}
|
||||||
|
style={{ background: hex }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label
|
||||||
|
title="Any other color"
|
||||||
|
className={cn(
|
||||||
|
'relative grid h-[30px] w-[30px] cursor-pointer place-items-center overflow-hidden rounded-full border-[3px]',
|
||||||
|
custom ? 'border-accent' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
style={{ background: custom ? value : 'conic-gradient(#c8553d, #d9912f, #97a97c, #5f8f45, #6d7f5a, #b2622d, #c8553d)' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="Custom marker color"
|
||||||
|
value={expandHex(value)}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
|
||||||
import { useDeletePlant, type Plant } from '@/lib/plants'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirmation dialog for deleting a custom plant. A plant still used by
|
|
||||||
* plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
|
|
||||||
* that message inline rather than pretending it worked.
|
|
||||||
*/
|
|
||||||
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
|
|
||||||
const deletion = useDeletePlant()
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Delete plant"
|
|
||||||
confirmLabel="Delete"
|
|
||||||
busyLabel="Deleting…"
|
|
||||||
errorFallback="Could not delete the plant."
|
|
||||||
onConfirm={() => deletion.mutateAsync(plant.id)}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
|
||||||
undone.
|
|
||||||
</p>
|
|
||||||
</ConfirmModal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
|
||||||
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire a lot. Worth confirming because it's the one place cost and germination
|
|
||||||
* data lives — and worth saying plainly that the plantings survive it, since
|
|
||||||
* "will this wipe my garden" is the reasonable fear.
|
|
||||||
*/
|
|
||||||
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
|
||||||
const del = useDeleteSeedLot()
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
title="Retire this seed lot?"
|
|
||||||
confirmLabel="Retire lot"
|
|
||||||
busyLabel="Retiring…"
|
|
||||||
errorFallback="Could not retire the lot."
|
|
||||||
onConfirm={() => del.mutateAsync(lot.id)}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-fg">
|
|
||||||
{formatQuantity(lot.quantity)} {lot.unit}
|
|
||||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
|
||||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
|
||||||
</p>
|
|
||||||
</ConfirmModal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { monogramInk } from '@/lib/monogram'
|
||||||
|
|
||||||
|
/** A plant marker off the canvas: a solid circle in the plant's color with its
|
||||||
|
* 1–2 letter monogram in the display face, paper or ink by the color's
|
||||||
|
* lightness (see monogramInk). Size in px. */
|
||||||
|
export function Monogram({
|
||||||
|
color,
|
||||||
|
letters,
|
||||||
|
size = 40,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
color: string
|
||||||
|
letters: string
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none', className)}
|
||||||
|
style={{ width: size, height: size, background: color, color: monogramInk(color), fontSize: Math.round(size * 0.425) }}
|
||||||
|
>
|
||||||
|
{letters}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The small color dot used in lists and rosters. */
|
||||||
|
export function ColorDot({ color, size = 14, className }: { color: string; size?: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn('inline-block flex-none rounded-full', className)}
|
||||||
|
style={{ width: size, height: size, background: color }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlantIcon } from './PlantIcon'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { LotStateChip, SeedLotList } from './SeedLotList'
|
import { Icon } from '@/components/ui/Icon'
|
||||||
import { SourceLink } from './SourceLink'
|
import { Tag } from '@/components/ui/Tag'
|
||||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
import { cn } from '@/lib/cn'
|
||||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||||
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
import { formatCost, formatQuantity, lotState, safeExternalUrl, type SeedLot } from '@/lib/seedLots'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||||
|
import { Monogram } from './Monogram'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One catalog plant as a card: icon tile tinted with the plant's color, name,
|
* One catalog plant as a card: monogram, name, "Category · spacing · days", a
|
||||||
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
|
* built-in tag for seeded plants, and a seed-lot summary. Clicking expands it
|
||||||
* offer only "Duplicate" (they're read-only); own plants add Edit/Delete.
|
* (accent border) to the lot cards — vendor, packed-for year, what's left — and
|
||||||
|
* the plant's own actions. Built-ins are read-only: duplicate to customize.
|
||||||
*/
|
*/
|
||||||
export function PlantCard({
|
export function PlantCard({
|
||||||
plant,
|
plant,
|
||||||
|
letters,
|
||||||
unit,
|
unit,
|
||||||
lots,
|
lots,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -24,9 +27,8 @@ export function PlantCard({
|
|||||||
onDeleteLot,
|
onDeleteLot,
|
||||||
}: {
|
}: {
|
||||||
plant: Plant
|
plant: Plant
|
||||||
|
letters: string
|
||||||
unit: UnitPref
|
unit: UnitPref
|
||||||
/** This plant's purchases. A lot may reference a built-in, so even a built-in
|
|
||||||
* card can carry seed. */
|
|
||||||
lots: SeedLot[]
|
lots: SeedLot[]
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
@@ -36,80 +38,121 @@ export function PlantCard({
|
|||||||
onDeleteLot: (lot: SeedLot) => void
|
onDeleteLot: (lot: SeedLot) => void
|
||||||
}) {
|
}) {
|
||||||
const builtin = isBuiltin(plant)
|
const builtin = isBuiltin(plant)
|
||||||
const [showLots, setShowLots] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const summary = summarizeLots(lots)
|
const sub = [CATEGORY_LABELS[plant.category], `${formatSpacing(plant.spacingCm, unit)} spacing`]
|
||||||
|
if (plant.daysToMaturity != null) sub.push(`${plant.daysToMaturity} days`)
|
||||||
|
const lotText =
|
||||||
|
lots.length === 0
|
||||||
|
? `No seed lots · click to ${open ? 'close' : 'expand'}`
|
||||||
|
: `${lots.length} seed ${lots.length === 1 ? 'lot' : 'lots'} · click to ${open ? 'close' : 'see'}`
|
||||||
|
const source = safeExternalUrl(plant.sourceUrl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
<div
|
||||||
<div className="flex items-start gap-3 p-4">
|
className={cn('panel relative transition-shadow hover:[box-shadow:var(--shadow-md)]', open && 'border-accent-400')}
|
||||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-11 w-11 rounded-lg text-2xl" />
|
>
|
||||||
<div className="min-w-0 flex-1">
|
{/* The whole face is the toggle; the expanded area below has its own controls. */}
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<h3 className="truncate font-semibold text-fg">{plant.name}</h3>
|
type="button"
|
||||||
{builtin && (
|
aria-expanded={open}
|
||||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
onClick={() => setOpen((v) => !v)}
|
||||||
Built-in
|
className="block w-full cursor-pointer rounded-[inherit] px-[18px] py-4 text-left"
|
||||||
</span>
|
>
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<Monogram color={plant.color} letters={letters} />
|
||||||
<p className="mt-0.5 text-sm text-muted">
|
<span className="flex min-w-0 flex-col gap-px">
|
||||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
<span className="truncate font-heading text-[16.5px]" title={plant.name}>
|
||||||
</p>
|
{plant.name}
|
||||||
{(plant.vendor || plant.sourceUrl) && (
|
</span>
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
<span className="text-xs font-semibold text-ink-mute">{sub.join(' · ')}</span>
|
||||||
{plant.vendor && <span>{plant.vendor}</span>}
|
</span>
|
||||||
<SourceLink url={plant.sourceUrl} />
|
{builtin && <Tag tone="neutral" className="ml-auto flex-none">built-in</Tag>}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="mt-2.5 text-[12.5px] text-ink-soft">{lotText}</div>
|
||||||
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-black/10 dark:border-white/10"
|
</button>
|
||||||
style={{ backgroundColor: plant.color }}
|
|
||||||
title={plant.color}
|
{open && (
|
||||||
/>
|
<div className="-mt-1.5 flex flex-col gap-2 px-[18px] pb-4">
|
||||||
</div>
|
{lots.map((lot) => (
|
||||||
{showLots && (
|
<LotCard key={lot.id} lot={lot} onEdit={() => onEditLot(lot)} onDelete={() => onDeleteLot(lot)} />
|
||||||
<div className="border-t border-border px-3 py-2">
|
))}
|
||||||
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
|
{lots.length === 0 && (
|
||||||
|
<div className="text-[12.5px] text-ink-mute">No seed lots yet — scan a packet, or record one by hand.</div>
|
||||||
|
)}
|
||||||
|
{(plant.vendor || source || plant.notes) && (
|
||||||
|
<div className="text-xs leading-relaxed text-ink-soft">
|
||||||
|
{plant.vendor && <span>{plant.vendor}</span>}
|
||||||
|
{plant.vendor && source && <span> · </span>}
|
||||||
|
{source && (
|
||||||
|
<a href={source} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1">
|
||||||
|
source <Icon name="external-link" size={11} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{plant.notes && <p className="mt-1 whitespace-pre-wrap">{plant.notes}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
|
||||||
|
Record a lot
|
||||||
|
</Button>
|
||||||
|
<span className="ml-auto flex flex-wrap justify-end gap-1">
|
||||||
|
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
{!builtin && (
|
||||||
|
<Button variant="ghost" icon="pencil" iconSize={12} className="px-2.5 text-[13px]" onClick={onEdit}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!builtin && (
|
||||||
|
<Button variant="ghost" icon="trash-2" iconSize={12} className="px-2.5 text-[13px] text-accent-700" onClick={onDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
|
/** What's left of a lot, said plainly: "50 cloves · 14 left" — or how far over
|
||||||
{/* The seed count sits with the actions rather than in the body: it's
|
* it was planted, which is a real situation worth showing rather than clamping. */
|
||||||
what you scan for down a list of twenty packets, so it wants a fixed
|
export function describeLot(lot: SeedLot): string {
|
||||||
place on the card. */}
|
const parts = [`${formatQuantity(lot.quantity)} ${lot.unit}`]
|
||||||
<button
|
const state = lotState(lot)
|
||||||
type="button"
|
if (state === 'over') parts.push(`${formatQuantity(-lot.remaining)} over what was bought`)
|
||||||
onClick={() => setShowLots((v) => !v)}
|
else if (state === 'empty') parts.push('none left')
|
||||||
className={`${cardActionClass} mr-auto gap-1.5`}
|
else if (state !== 'unknown') parts.push(`${formatQuantity(lot.remaining)} left`)
|
||||||
aria-expanded={showLots}
|
if (lot.germinationPct != null) parts.push(`${lot.germinationPct}% germination`)
|
||||||
>
|
const cost = formatCost(lot.costCents)
|
||||||
{lots.length === 0 ? (
|
if (cost) parts.push(cost)
|
||||||
<span className="text-muted">No seed</span>
|
return parts.join(' · ')
|
||||||
) : (
|
}
|
||||||
<>
|
|
||||||
<span className="tabular-nums">
|
function LotCard({ lot, onEdit, onDelete }: { lot: SeedLot; onEdit: () => void; onDelete: () => void }) {
|
||||||
{formatQuantity(summary.remaining)}
|
const state = lotState(lot)
|
||||||
{summary.unit ? ` ${summary.unit}` : ''} left
|
return (
|
||||||
</span>
|
<div className="rounded-md border border-divider bg-bg px-[13px] py-2.5">
|
||||||
<LotStateChip state={summary.state} />
|
<div className="flex items-center gap-1.5">
|
||||||
</>
|
<span className="min-w-0 truncate text-[12.5px] font-bold">{lot.vendor || 'Unnamed lot'}</span>
|
||||||
)}
|
{state === 'low' && <Tag tone="accent">low</Tag>}
|
||||||
|
{state === 'empty' && <Tag tone="neutral">empty</Tag>}
|
||||||
|
{state === 'over' && <Tag tone="accent">over-planted</Tag>}
|
||||||
|
<span className="ml-auto flex-none text-[11.5px] text-ink-mute">
|
||||||
|
{lot.packedForYear != null ? `packed for ${lot.packedForYear}` : lot.purchasedAt ? `bought ${lot.purchasedAt}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[3px] text-xs text-ink-soft">{describeLot(lot)}</div>
|
||||||
|
{lot.notes && <div className="mt-1 text-xs text-ink-mute">{lot.notes}</div>}
|
||||||
|
<div className="mt-1 flex justify-end gap-1">
|
||||||
|
<button type="button" className="btn btn-ghost px-2 py-1 text-xs" onClick={onEdit}>
|
||||||
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
<button type="button" className="btn btn-ghost px-2 py-1 text-xs text-accent-700" onClick={onDelete}>
|
||||||
Duplicate
|
Retire
|
||||||
</button>
|
</button>
|
||||||
{!builtin && (
|
|
||||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!builtin && (
|
|
||||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
|
||||||
import type { Plant } from '@/lib/plants'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
|
|
||||||
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
|
|
||||||
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
|
|
||||||
* `rounded` is false when a caller (the tray) attaches a trailing control and
|
|
||||||
* needs a flat right edge.
|
|
||||||
*/
|
|
||||||
export function PlantChip({
|
|
||||||
plant,
|
|
||||||
active,
|
|
||||||
onArm,
|
|
||||||
rounded = true,
|
|
||||||
}: {
|
|
||||||
plant: Plant
|
|
||||||
active: boolean
|
|
||||||
onArm: (plant: Plant) => void
|
|
||||||
rounded?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onArm(plant)}
|
|
||||||
aria-pressed={active}
|
|
||||||
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
|
||||||
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
|
|
||||||
active
|
|
||||||
? 'border-accent bg-accent/10 text-accent-strong'
|
|
||||||
: 'border-border bg-surface text-fg hover:border-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
|
||||||
<span className="max-w-[6rem] truncate">{plant.name}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
PLANT_CATEGORIES,
|
||||||
|
conflictPlant,
|
||||||
|
useCreatePlant,
|
||||||
|
useUpdatePlant,
|
||||||
|
type Plant,
|
||||||
|
type PlantCategory,
|
||||||
|
type PlantInput,
|
||||||
|
} from '@/lib/plants'
|
||||||
|
import { safeExternalUrl } from '@/lib/seedLots'
|
||||||
|
import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
|
||||||
|
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
|
||||||
|
|
||||||
|
// Markers are monograms now, but the API still carries an icon per plant; a
|
||||||
|
// plant made here gets the neutral sprout so nothing downstream sees an empty one.
|
||||||
|
const DEFAULT_ICON = '🌱'
|
||||||
|
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
|
||||||
|
* (`template`, the Duplicate action; the only way to customize a built-in).
|
||||||
|
* Spacing is typed in the page's unit and stored in centimeters — as a
|
||||||
|
* LengthField, so a Save that didn't touch it sends the centimeters that were
|
||||||
|
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
|
||||||
|
* server's current row.
|
||||||
|
*/
|
||||||
|
export function PlantDialog({
|
||||||
|
plant,
|
||||||
|
template,
|
||||||
|
unit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
plant?: Plant
|
||||||
|
template?: Plant
|
||||||
|
unit: UnitPref
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const isEdit = !!plant
|
||||||
|
const source = plant ?? template
|
||||||
|
const create = useCreatePlant()
|
||||||
|
const update = useUpdatePlant()
|
||||||
|
const pending = create.isPending || update.isPending
|
||||||
|
|
||||||
|
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
|
||||||
|
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
|
||||||
|
const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
|
||||||
|
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
|
||||||
|
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||||
|
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||||
|
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||||
|
const [notes, setNotes] = useState(source?.notes ?? '')
|
||||||
|
const [more, setMore] = useState(!!(source?.vendor || source?.sourceUrl || source?.notes))
|
||||||
|
const [version, setVersion] = useState(plant?.version ?? 0)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const unitLabel = spacingUnitLabel(unit)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFormError(null)
|
||||||
|
setConflict(null)
|
||||||
|
if (!name.trim()) {
|
||||||
|
setFormError('Give the plant a name.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const spacingCm = spacing.cm
|
||||||
|
if (spacingCm === null || spacingCm < 1) {
|
||||||
|
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let daysToMaturity: number | null = null
|
||||||
|
if (days.trim()) {
|
||||||
|
const d = Number(days)
|
||||||
|
if (!Number.isInteger(d) || d < 1) {
|
||||||
|
setFormError('Days to maturity is a whole number of days, or blank.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
daysToMaturity = d
|
||||||
|
}
|
||||||
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||||
|
setFormError('The source link needs to be a full http:// or https:// address.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const input: PlantInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
category,
|
||||||
|
spacingCm,
|
||||||
|
color,
|
||||||
|
icon: source?.icon || DEFAULT_ICON,
|
||||||
|
daysToMaturity,
|
||||||
|
sourceUrl: sourceUrl.trim(),
|
||||||
|
vendor: vendor.trim(),
|
||||||
|
notes: notes.trim(),
|
||||||
|
}
|
||||||
|
// Nothing changed: close without a request, so a look-and-Save doesn't bump
|
||||||
|
// the version for every garden that shares the plant.
|
||||||
|
if (isEdit && (Object.keys(input) as (keyof PlantInput)[]).every((k) => input[k] === (k === 'color' ? expandHex(plant.color) : plant[k]))) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
|
||||||
|
else await create.mutateAsync(input)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const current = conflictPlant(err)
|
||||||
|
if (current) {
|
||||||
|
setVersion(current.version)
|
||||||
|
setName(current.name)
|
||||||
|
setCategory(current.category)
|
||||||
|
setSpacing(spacingField(current.spacingCm, unit))
|
||||||
|
setColor(expandHex(current.color))
|
||||||
|
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
||||||
|
setVendor(current.vendor)
|
||||||
|
setSourceUrl(current.sourceUrl)
|
||||||
|
setNotes(current.notes)
|
||||||
|
setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={isEdit ? `Edit ${plant.name}` : 'A new plant'} onClose={onClose} busy={pending}>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||||
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||||
|
<TextField label="Name" name="name" required autoFocus placeholder="Delicata squash" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<SelectField
|
||||||
|
label="Category"
|
||||||
|
name="category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||||
|
options={categoryOptions}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={`Spacing (${unitLabel})`}
|
||||||
|
name="spacing"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="any"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={spacing.text}
|
||||||
|
onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field label="Marker color">
|
||||||
|
<ColorSwatches value={color} onChange={setColor} />
|
||||||
|
</Field>
|
||||||
|
<TextField
|
||||||
|
label="Days to maturity (optional)"
|
||||||
|
name="days"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!more ? (
|
||||||
|
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
||||||
|
Vendor, source link & notes…
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<TextField label="Vendor" name="vendor" placeholder="Johnny's" value={vendor} onChange={(e) => setVendor(e.target.value)} wrapperClassName="flex-1" />
|
||||||
|
<TextField
|
||||||
|
label="Source link"
|
||||||
|
name="sourceUrl"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://…"
|
||||||
|
value={sourceUrl}
|
||||||
|
onChange={(e) => setSourceUrl(e.target.value)}
|
||||||
|
wrapperClassName="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formError && <Alert>{formError}</Alert>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" onClick={onClose} disabled={pending}>
|
||||||
|
Never mind
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={pending}>
|
||||||
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Add it'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { Select } from '@/components/ui/Select'
|
|
||||||
import { TextArea } from '@/components/ui/TextArea'
|
|
||||||
import { TextField } from '@/components/ui/TextField'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
import {
|
|
||||||
CATEGORY_LABELS,
|
|
||||||
PLANT_CATEGORIES,
|
|
||||||
conflictPlant,
|
|
||||||
useCreatePlant,
|
|
||||||
useUpdatePlant,
|
|
||||||
type Plant,
|
|
||||||
type PlantCategory,
|
|
||||||
type PlantInput,
|
|
||||||
} from '@/lib/plants'
|
|
||||||
import { safeExternalUrl } from '@/lib/seedLots'
|
|
||||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
|
||||||
|
|
||||||
const DEFAULT_COLOR = '#4a7c3f'
|
|
||||||
const DEFAULT_ICON = '🌱'
|
|
||||||
|
|
||||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
|
||||||
|
|
||||||
/** Expand a #rgb shorthand to #rrggbb so the native color input renders it. */
|
|
||||||
function expandHex(color: string): string {
|
|
||||||
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
|
||||||
const [, r, g, b] = color
|
|
||||||
return `#${r}${r}${g}${g}${b}${b}`
|
|
||||||
}
|
|
||||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : DEFAULT_COLOR
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create or edit a custom plant. `plant` puts it in edit mode; `template` (a
|
|
||||||
* built-in or another plant, used by "Duplicate") pre-fills a fresh create. A
|
|
||||||
* 409 rebases the form onto the server's current row. Spacing is entered in the
|
|
||||||
* page's unit and converted to centimeters for the API.
|
|
||||||
*/
|
|
||||||
export function PlantFormModal({
|
|
||||||
plant,
|
|
||||||
template,
|
|
||||||
unit,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
plant?: Plant
|
|
||||||
template?: Plant
|
|
||||||
unit: UnitPref
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const isEdit = !!plant
|
|
||||||
const source = plant ?? template
|
|
||||||
const create = useCreatePlant()
|
|
||||||
const update = useUpdatePlant()
|
|
||||||
const pending = create.isPending || update.isPending
|
|
||||||
|
|
||||||
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
|
|
||||||
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
|
|
||||||
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
|
|
||||||
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
|
||||||
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
|
||||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
|
||||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
|
||||||
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
|
||||||
const [notes, setNotes] = useState(source?.notes ?? '')
|
|
||||||
const [version, setVersion] = useState(plant?.version ?? 0)
|
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
const unitLabel = spacingUnitLabel(unit)
|
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setFormError(null)
|
|
||||||
setConflict(null)
|
|
||||||
|
|
||||||
if (!name.trim()) {
|
|
||||||
setFormError('Enter a name for the plant.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!icon.trim()) {
|
|
||||||
setFormError('Pick an emoji icon.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
|
||||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
|
||||||
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let daysToMaturity: number | null = null
|
|
||||||
if (days.trim()) {
|
|
||||||
// Number() (not parseInt) so "1.5" is rejected, not silently truncated to 1.
|
|
||||||
const d = Number(days)
|
|
||||||
if (!Number.isInteger(d) || d < 1) {
|
|
||||||
setFormError('Days to maturity must be a whole number of days, or left blank.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
daysToMaturity = d
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server refuses anything that isn't http(s) with a host, but say so here
|
|
||||||
// rather than letting a paste of "johnnyseeds.com" come back as a generic
|
|
||||||
// error with no hint about which field or why.
|
|
||||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
|
||||||
setFormError('The source link needs to be a full http:// or https:// address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const input: PlantInput = {
|
|
||||||
name: name.trim(),
|
|
||||||
category,
|
|
||||||
spacingCm,
|
|
||||||
color,
|
|
||||||
icon: icon.trim(),
|
|
||||||
daysToMaturity,
|
|
||||||
sourceUrl: sourceUrl.trim(),
|
|
||||||
vendor: vendor.trim(),
|
|
||||||
notes: notes.trim(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEdit) {
|
|
||||||
await update.mutateAsync({ id: plant.id, ...input, version })
|
|
||||||
} else {
|
|
||||||
await create.mutateAsync(input)
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
const current = conflictPlant(err)
|
|
||||||
if (current) {
|
|
||||||
// Someone else changed this plant: rebase onto the fresh row so a re-save
|
|
||||||
// applies against the current version.
|
|
||||||
setVersion(current.version)
|
|
||||||
setName(current.name)
|
|
||||||
setCategory(current.category)
|
|
||||||
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
|
|
||||||
setColor(expandHex(current.color))
|
|
||||||
setIcon(current.icon)
|
|
||||||
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
|
||||||
setNotes(current.notes)
|
|
||||||
setConflict('This plant changed elsewhere. The latest values are shown — review and save again.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title={isEdit ? 'Edit plant' : 'New plant'} onClose={onClose} busy={pending}>
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
|
||||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
|
||||||
|
|
||||||
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Select
|
|
||||||
label="Category"
|
|
||||||
name="category"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
|
||||||
options={categoryOptions}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={`Spacing (${unitLabel})`}
|
|
||||||
name="spacing"
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="any"
|
|
||||||
min="1"
|
|
||||||
required
|
|
||||||
value={spacing}
|
|
||||||
onChange={(e) => setSpacing(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="plant-color" className="text-sm font-medium text-fg">
|
|
||||||
Color
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="plant-color"
|
|
||||||
type="color"
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className="h-10 w-full cursor-pointer rounded-md border border-border bg-surface"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
label="Icon (emoji)"
|
|
||||||
name="icon"
|
|
||||||
value={icon}
|
|
||||||
onChange={(e) => setIcon(e.target.value)}
|
|
||||||
hint="A single emoji, e.g. 🍅"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Days to maturity (optional)"
|
|
||||||
name="days"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
step="1"
|
|
||||||
min="1"
|
|
||||||
value={days}
|
|
||||||
onChange={(e) => setDays(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Provenance for the variety itself. What you bought and what's left
|
|
||||||
of it is a seed lot, added from the plant card. */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField
|
|
||||||
label="Vendor"
|
|
||||||
name="vendor"
|
|
||||||
placeholder="Johnny's Selected Seeds"
|
|
||||||
value={vendor}
|
|
||||||
onChange={(e) => setVendor(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Source link"
|
|
||||||
name="sourceUrl"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
placeholder="https://…"
|
|
||||||
value={sourceUrl}
|
|
||||||
onChange={(e) => setSourceUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
||||||
|
|
||||||
{formError && <Alert>{formError}</Alert>}
|
|
||||||
|
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={pending}>
|
|
||||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create plant'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { cn } from '@/lib/cn'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A plant's emoji on a tile tinted with its own color (via color-mix, so any
|
|
||||||
* valid CSS color works). Shared by PlantCard and the PlantPicker rows. Size and
|
|
||||||
* shape come from `className`.
|
|
||||||
*/
|
|
||||||
export function PlantIcon({ color, icon, className }: { color: string; icon: string; className?: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn('grid shrink-0 place-items-center', className)}
|
|
||||||
style={{ backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { useRef, useState, type ChangeEvent, type DragEvent, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { SelectField, TextField } from '@/components/ui/Field'
|
||||||
|
import { Icon } from '@/components/ui/Icon'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { CATEGORY_LABELS, PLANT_CATEGORIES, isBuiltin, type PlantCategory, type PlantInput } from '@/lib/plants'
|
||||||
|
import { lotDefaults, newPlantDefaults, useCreateFromPacket, useScanPacket, type PacketProposal } from '@/lib/seedPacket'
|
||||||
|
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
|
||||||
|
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
|
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||||
|
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||||
|
|
||||||
|
// 'new' is "create a new plant"; a number selects that existing candidate.
|
||||||
|
type Selection = number | 'new'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photograph a seed packet → the vision model reads it into fields → the user
|
||||||
|
* matches it to the catalog and confirms (#81/#102). Two steps in one dialog.
|
||||||
|
* The read never writes; a misread can't add anything on its own, and a wrong
|
||||||
|
* auto-match would split a variety's history across duplicate rows — so the
|
||||||
|
* match is always a human choice. Only offered where `capabilities.vision` is
|
||||||
|
* on; a 503 is still handled in case the model is torn down in between.
|
||||||
|
*/
|
||||||
|
export function ScanPacketDialog({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||||
|
const scan = useScanPacket()
|
||||||
|
const create = useCreateFromPacket()
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
const scanAbort = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState<Selection>('new')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||||
|
const [spacing, setSpacing] = useState('')
|
||||||
|
const [days, setDays] = useState('')
|
||||||
|
const [vendor, setVendor] = useState('')
|
||||||
|
const [quantity, setQuantity] = useState('')
|
||||||
|
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||||
|
const [packedForYear, setPackedForYear] = useState('')
|
||||||
|
|
||||||
|
const unitLabel = spacingUnitLabel(unit)
|
||||||
|
const busy = scan.isPending || create.isPending
|
||||||
|
|
||||||
|
function readFile(file: File | undefined) {
|
||||||
|
if (!file) return
|
||||||
|
setError(null)
|
||||||
|
const controller = new AbortController()
|
||||||
|
scanAbort.current = controller
|
||||||
|
scan.mutate(
|
||||||
|
{ file, signal: controller.signal },
|
||||||
|
{
|
||||||
|
onSuccess: (p) => {
|
||||||
|
const plant = newPlantDefaults(p)
|
||||||
|
const lot = lotDefaults(p.packet)
|
||||||
|
setProposal(p)
|
||||||
|
setSelection(p.candidates[0]?.plant.id ?? 'new')
|
||||||
|
setName(plant.name)
|
||||||
|
setCategory(plant.category)
|
||||||
|
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
|
||||||
|
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
|
||||||
|
setVendor(lot.vendor)
|
||||||
|
setQuantity(String(lot.quantity))
|
||||||
|
setLotUnit(lot.unit)
|
||||||
|
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if ((err as Error)?.name === 'AbortError') return
|
||||||
|
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = '' // so re-picking the same file fires change again
|
||||||
|
readFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
readFile(e.dataTransfer.files?.[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!proposal) return
|
||||||
|
setError(null)
|
||||||
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||||
|
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
||||||
|
let year: number | null = null
|
||||||
|
if (packedForYear.trim()) {
|
||||||
|
const y = Number(packedForYear)
|
||||||
|
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
|
||||||
|
year = y
|
||||||
|
}
|
||||||
|
const lot = {
|
||||||
|
vendor: vendor.trim(),
|
||||||
|
sourceUrl: '',
|
||||||
|
sku: proposal.packet.sku,
|
||||||
|
lotCode: proposal.packet.lotCode,
|
||||||
|
purchasedAt: null,
|
||||||
|
packedForYear: year,
|
||||||
|
quantity: qty,
|
||||||
|
unit: lotUnit,
|
||||||
|
costCents: null,
|
||||||
|
germinationPct: null,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
let newPlant: PlantInput | undefined
|
||||||
|
let plantId: number | undefined
|
||||||
|
if (selection === 'new') {
|
||||||
|
if (!name.trim()) return setError('Name the new plant, or pick an existing one above.')
|
||||||
|
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||||
|
if (!Number.isFinite(spacingCm) || spacingCm < 1) return setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||||
|
let daysToMaturity: number | null = null
|
||||||
|
if (days.trim()) {
|
||||||
|
const d = Number(days)
|
||||||
|
if (!Number.isInteger(d) || d < 1) return setError('Days to maturity is a whole number of days, or blank.')
|
||||||
|
daysToMaturity = d
|
||||||
|
}
|
||||||
|
newPlant = { ...newPlantDefaults(proposal), name: name.trim(), category, spacingCm, daysToMaturity, vendor: vendor.trim() }
|
||||||
|
} else {
|
||||||
|
plantId = selection
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await create.mutateAsync({ plantId, newPlant, lot })
|
||||||
|
toast.info(res.plantIsNew ? `Added ${res.plant.name} and its seed lot.` : `Recorded a seed lot for ${res.plant.name}.`)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, 'Could not save the packet.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const variety = proposal ? [proposal.packet.species, proposal.packet.variety].filter(Boolean).join(' — ') : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Scan a seed packet" onClose={onClose} busy={create.isPending} width={560}>
|
||||||
|
{!proposal ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={onFile}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-2.5 rounded-lg border-2 border-dashed px-5 py-9 text-center',
|
||||||
|
dragOver ? 'border-accent-400 bg-accent-100' : 'border-neutral-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name="camera" size={30} className="text-accent-2-600" />
|
||||||
|
<div className="text-sm font-semibold">Photograph the packet — front is enough</div>
|
||||||
|
<div className="max-w-[36ch] text-[12.5px] text-ink-mute">
|
||||||
|
The vision model reads it into fields. It only reads; nothing is saved until you confirm.
|
||||||
|
</div>
|
||||||
|
{scan.isPending ? (
|
||||||
|
<p className="mt-1 flex items-center gap-2 text-[13px] font-semibold text-ink-soft">
|
||||||
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||||
|
Reading the packet… this can take a few seconds.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" icon="camera" className="mt-1" onClick={() => fileInput.current?.click()}>
|
||||||
|
Take or choose a photo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* Never disabled — this is the way out of a slow scan. */}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
scanAbort.current?.abort()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onConfirm} className="flex flex-col gap-3.5">
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<TextField label="Variety" name="variety" value={selection === 'new' ? name : variety} readOnly={selection !== 'new'} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||||
|
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="scan-quantity">Quantity</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input id="scan-quantity" className="input min-w-0" type="number" inputMode="decimal" step="any" min="0" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||||
|
<select className="input w-auto flex-none" aria-label="Unit" value={lotUnit} onChange={(e) => setLotUnit(e.target.value as LotUnit)}>
|
||||||
|
{unitOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 text-[12.5px] font-bold text-ink-soft">Match it to your catalog — nothing is auto-created:</div>
|
||||||
|
<div role="radiogroup" aria-label="Which plant this packet is" className="flex flex-col gap-2">
|
||||||
|
{proposal.candidates.map((c, i) => (
|
||||||
|
<MatchOption
|
||||||
|
key={c.plant.id}
|
||||||
|
selected={selection === c.plant.id}
|
||||||
|
onSelect={() => setSelection(c.plant.id)}
|
||||||
|
label={`${c.plant.name} (${isBuiltin(c.plant) ? 'built-in' : 'yours'})`}
|
||||||
|
sub={i === 0 ? `best match · ${c.reason}` : c.reason}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<MatchOption
|
||||||
|
selected={selection === 'new'}
|
||||||
|
onSelect={() => setSelection('new')}
|
||||||
|
label="Create a new plant"
|
||||||
|
sub="from the extracted fields"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selection === 'new' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 rounded-md border border-divider bg-bg p-3.5">
|
||||||
|
<SelectField label="Category" name="category" value={category} onChange={(e) => setCategory(e.target.value as PlantCategory)} options={categoryOptions} />
|
||||||
|
<TextField label={`Spacing (${unitLabel})`} name="spacing" type="number" inputMode="decimal" step="any" min="1" required value={spacing} onChange={(e) => setSpacing(e.target.value)} />
|
||||||
|
<TextField label="Days to maturity" name="days" type="number" inputMode="numeric" step="1" min="1" value={days} onChange={(e) => setDays(e.target.value)} wrapperClassName="col-span-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="mt-0.5 flex justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
setProposal(null)
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Rescan
|
||||||
|
</Button>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<Button onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={busy}>
|
||||||
|
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add the lot'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchOption({ selected, onSelect, label, sub }: { selected: boolean; onSelect: () => void; label: string; sub: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-2 rounded-full border px-4 py-2.5 text-left',
|
||||||
|
selected ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[13.5px] font-bold">{label}</span>
|
||||||
|
<span className="ml-auto text-right text-xs text-ink-mute">{sub}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import {
|
||||||
|
conflictSeedLot,
|
||||||
|
LOT_UNITS,
|
||||||
|
safeExternalUrl,
|
||||||
|
useCreateSeedLot,
|
||||||
|
useUpdateSeedLot,
|
||||||
|
type LotUnit,
|
||||||
|
type SeedLot,
|
||||||
|
} from '@/lib/seedLots'
|
||||||
|
|
||||||
|
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a purchase, or correct one. Everything except quantity and unit is
|
||||||
|
* optional — the point is to make writing it down cheap enough to bother with.
|
||||||
|
*/
|
||||||
|
export function SeedLotDialog({ plant, lot, onClose }: { plant: Plant; lot?: SeedLot; onClose: () => void }) {
|
||||||
|
const isEdit = !!lot
|
||||||
|
const create = useCreateSeedLot()
|
||||||
|
const update = useUpdateSeedLot()
|
||||||
|
const pending = create.isPending || update.isPending
|
||||||
|
|
||||||
|
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
||||||
|
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
||||||
|
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
||||||
|
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
||||||
|
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
||||||
|
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
||||||
|
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
||||||
|
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
||||||
|
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||||
|
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||||
|
const [notes, setNotes] = useState(lot?.notes ?? '')
|
||||||
|
const [version, setVersion] = useState(lot?.version ?? 0)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setConflict(null)
|
||||||
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||||
|
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
||||||
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim()))
|
||||||
|
return setError('The source link needs to be a full http:// or https:// address.')
|
||||||
|
let year: number | null = null
|
||||||
|
if (packedForYear.trim()) {
|
||||||
|
const y = Number(packedForYear)
|
||||||
|
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
|
||||||
|
year = y
|
||||||
|
}
|
||||||
|
let costCents: number | null = null
|
||||||
|
if (cost.trim()) {
|
||||||
|
const c = Number(cost)
|
||||||
|
if (!Number.isFinite(c) || c < 0) return setError('Cost must be an amount, or blank.')
|
||||||
|
costCents = Math.round(c * 100)
|
||||||
|
}
|
||||||
|
let germinationPct: number | null = null
|
||||||
|
if (germination.trim()) {
|
||||||
|
const g = Number(germination)
|
||||||
|
if (!Number.isFinite(g) || g < 0 || g > 100) return setError('Germination is a percentage between 0 and 100.')
|
||||||
|
germinationPct = g
|
||||||
|
}
|
||||||
|
const input = {
|
||||||
|
plantId: plant.id,
|
||||||
|
vendor: vendor.trim(),
|
||||||
|
sourceUrl: sourceUrl.trim(),
|
||||||
|
sku: sku.trim(),
|
||||||
|
lotCode: lotCode.trim(),
|
||||||
|
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
||||||
|
packedForYear: year,
|
||||||
|
quantity: qty,
|
||||||
|
unit,
|
||||||
|
costCents,
|
||||||
|
germinationPct,
|
||||||
|
notes: notes.trim(),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isEdit) await update.mutateAsync({ id: lot.id, version, ...input })
|
||||||
|
else await create.mutateAsync(input)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const current = conflictSeedLot(err)
|
||||||
|
if (current) {
|
||||||
|
setVersion(current.version)
|
||||||
|
setVendor(current.vendor)
|
||||||
|
setSourceUrl(current.sourceUrl)
|
||||||
|
setQuantity(String(current.quantity))
|
||||||
|
setUnit(current.unit)
|
||||||
|
setPurchasedAt(current.purchasedAt ?? '')
|
||||||
|
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
||||||
|
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
||||||
|
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
||||||
|
setSku(current.sku)
|
||||||
|
setLotCode(current.lotCode)
|
||||||
|
setNotes(current.notes)
|
||||||
|
setConflict('This lot changed elsewhere. The latest values are shown — look them over and save again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={isEdit ? 'Edit the lot' : `A lot of ${plant.name}`} onClose={onClose} busy={pending} width={460}>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||||
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<TextField label="Quantity" name="quantity" type="number" inputMode="decimal" step="any" min="0" autoFocus value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||||
|
<SelectField label="Unit" name="unit" value={unit} onChange={(e) => setUnit(e.target.value as LotUnit)} options={unitOptions} />
|
||||||
|
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||||
|
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" placeholder="2026" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
|
||||||
|
<TextField label="Purchased" name="purchasedAt" type="date" value={purchasedAt} onChange={(e) => setPurchasedAt(e.target.value)} />
|
||||||
|
<TextField label="Cost" name="cost" type="number" inputMode="decimal" step="0.01" min="0" placeholder="4.99" value={cost} onChange={(e) => setCost(e.target.value)} />
|
||||||
|
<TextField label="Germination %" name="germination" type="number" inputMode="decimal" step="any" min="0" max="100" value={germination} onChange={(e) => setGermination(e.target.value)} />
|
||||||
|
<TextField label="Source link" name="sourceUrl" type="url" inputMode="url" placeholder="https://…" value={sourceUrl} onChange={(e) => setSourceUrl(e.target.value)} />
|
||||||
|
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||||
|
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<TextAreaField label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" onClick={onClose} disabled={pending}>
|
||||||
|
Never mind
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={pending}>
|
||||||
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record the lot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { SourceLink } from './SourceLink'
|
|
||||||
import {
|
|
||||||
formatCost,
|
|
||||||
formatQuantity,
|
|
||||||
formatUnitCost,
|
|
||||||
lotState,
|
|
||||||
type LotState,
|
|
||||||
type SeedLot,
|
|
||||||
} from '@/lib/seedLots'
|
|
||||||
|
|
||||||
const STATE_LABEL: Record<LotState, string> = {
|
|
||||||
over: 'over-planted',
|
|
||||||
empty: 'empty',
|
|
||||||
low: 'low',
|
|
||||||
ok: 'in stock',
|
|
||||||
unknown: 'no count',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encoded in colour AND words, because this is the thing you skim down a list of
|
|
||||||
// twenty packets deciding what to order — a bare number doesn't survive that.
|
|
||||||
const STATE_CLASS: Record<LotState, string> = {
|
|
||||||
// "over" is a discrepancy to look at rather than a shortage to act on, so it
|
|
||||||
// reads as a distinct warning rather than sharing "low"'s styling.
|
|
||||||
over: 'bg-orange-500/25 text-orange-900 dark:text-orange-200',
|
|
||||||
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
|
|
||||||
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
|
||||||
ok: 'bg-accent/20 text-accent-strong',
|
|
||||||
unknown: 'bg-border/60 text-muted',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
|
||||||
STATE_CLASS[state],
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{STATE_LABEL[state]}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A proportional bar for how much of a lot is left, so the state reads before
|
|
||||||
* any of the text does. Omitted when there's no quantity to be a fraction of. */
|
|
||||||
function RemainingBar({ lot }: { lot: SeedLot }) {
|
|
||||||
if (lot.quantity <= 0) return null
|
|
||||||
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
|
|
||||||
const state = lotState(lot)
|
|
||||||
return (
|
|
||||||
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-full rounded-full',
|
|
||||||
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
|
|
||||||
)}
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A plant's purchases: what came from where, and what's left of each.
|
|
||||||
*
|
|
||||||
* Two lots of the same variety are two separate rows with independent counts,
|
|
||||||
* which is the whole reason inventory lives on the purchase rather than on the
|
|
||||||
* plant (#50).
|
|
||||||
*/
|
|
||||||
export function SeedLotList({
|
|
||||||
lots,
|
|
||||||
canEdit,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
lots: SeedLot[]
|
|
||||||
canEdit: boolean
|
|
||||||
onAdd: () => void
|
|
||||||
onEdit: (lot: SeedLot) => void
|
|
||||||
onDelete: (lot: SeedLot) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{lots.length === 0 && (
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
No seed recorded. Add a lot to track what you bought and how much is left.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{lots.map((lot) => (
|
|
||||||
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
|
|
||||||
))}
|
|
||||||
{canEdit && (
|
|
||||||
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
|
|
||||||
+ Add seed lot
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LotRow({
|
|
||||||
lot,
|
|
||||||
canEdit,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
lot: SeedLot
|
|
||||||
canEdit: boolean
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}) {
|
|
||||||
const cost = formatCost(lot.costCents)
|
|
||||||
const unitCost = formatUnitCost(lot)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border px-2 py-1.5">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
|
|
||||||
<span className="font-medium tabular-nums">
|
|
||||||
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
|
|
||||||
</span>
|
|
||||||
<LotStateChip state={lotState(lot)} />
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
|
|
||||||
{lot.vendor && <span>{lot.vendor}</span>}
|
|
||||||
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
|
|
||||||
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
|
||||||
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
|
||||||
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
|
||||||
<SourceLink url={lot.sourceUrl} />
|
|
||||||
</p>
|
|
||||||
<RemainingBar lot={lot} />
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<div className="flex shrink-0 flex-col items-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDelete}
|
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
|
||||||
>
|
|
||||||
Retire
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
|
||||||
import { Select } from '@/components/ui/Select'
|
|
||||||
import { TextArea } from '@/components/ui/TextArea'
|
|
||||||
import { TextField } from '@/components/ui/TextField'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
import {
|
|
||||||
conflictSeedLot,
|
|
||||||
LOT_UNITS,
|
|
||||||
safeExternalUrl,
|
|
||||||
useCreateSeedLot,
|
|
||||||
useUpdateSeedLot,
|
|
||||||
type LotUnit,
|
|
||||||
type SeedLot,
|
|
||||||
} from '@/lib/seedLots'
|
|
||||||
import type { Plant } from '@/lib/plants'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a purchase, or correct one. Everything except quantity and unit is
|
|
||||||
* optional — the point is to make writing it down cheap enough to bother with,
|
|
||||||
* and a form that demands a SKU and a lot code gets skipped.
|
|
||||||
*/
|
|
||||||
export function SeedLotModal({
|
|
||||||
plant,
|
|
||||||
lot,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
plant: Plant
|
|
||||||
/** Editing an existing lot, or undefined to record a new one. */
|
|
||||||
lot?: SeedLot
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const isEdit = !!lot
|
|
||||||
const create = useCreateSeedLot()
|
|
||||||
const update = useUpdateSeedLot()
|
|
||||||
const pending = create.isPending || update.isPending
|
|
||||||
|
|
||||||
// A new lot inherits the plant's vendor and source link, since the usual case
|
|
||||||
// is buying the variety you already recorded from the place you recorded it.
|
|
||||||
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
|
||||||
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
|
||||||
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
|
||||||
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
|
||||||
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
|
||||||
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
|
||||||
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
|
||||||
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
|
||||||
const [sku, setSku] = useState(lot?.sku ?? '')
|
|
||||||
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
|
||||||
const [notes, setNotes] = useState(lot?.notes ?? '')
|
|
||||||
const [version, setVersion] = useState(lot?.version ?? 0)
|
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
setConflict(null)
|
|
||||||
|
|
||||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
|
||||||
if (!Number.isFinite(qty) || qty < 0) {
|
|
||||||
setError('Quantity must be a number, or left blank.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
|
||||||
setError('The source link needs to be a full http:// or https:// address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let year: number | null = null
|
|
||||||
if (packedForYear.trim()) {
|
|
||||||
const y = Number(packedForYear)
|
|
||||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
|
||||||
setError('Packed-for year should be a four-digit year.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
year = y
|
|
||||||
}
|
|
||||||
let costCents: number | null = null
|
|
||||||
if (cost.trim()) {
|
|
||||||
const c = Number(cost)
|
|
||||||
if (!Number.isFinite(c) || c < 0) {
|
|
||||||
setError('Cost must be an amount, or left blank.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
costCents = Math.round(c * 100)
|
|
||||||
}
|
|
||||||
let germinationPct: number | null = null
|
|
||||||
if (germination.trim()) {
|
|
||||||
const g = Number(germination)
|
|
||||||
if (!Number.isFinite(g) || g < 0 || g > 100) {
|
|
||||||
setError('Germination is a percentage between 0 and 100.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
germinationPct = g
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = {
|
|
||||||
plantId: plant.id,
|
|
||||||
vendor: vendor.trim(),
|
|
||||||
sourceUrl: sourceUrl.trim(),
|
|
||||||
sku: sku.trim(),
|
|
||||||
lotCode: lotCode.trim(),
|
|
||||||
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
|
||||||
packedForYear: year,
|
|
||||||
quantity: qty,
|
|
||||||
unit,
|
|
||||||
costCents,
|
|
||||||
germinationPct,
|
|
||||||
notes: notes.trim(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEdit) {
|
|
||||||
await update.mutateAsync({ id: lot.id, version, ...input })
|
|
||||||
} else {
|
|
||||||
await create.mutateAsync(input)
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
const current = conflictSeedLot(err)
|
|
||||||
if (current) {
|
|
||||||
// Someone edited this lot elsewhere. Rebase onto the fresh row so a
|
|
||||||
// re-save applies, rather than making them retype everything — the same
|
|
||||||
// contract every other version-guarded form here honours.
|
|
||||||
setVersion(current.version)
|
|
||||||
setVendor(current.vendor)
|
|
||||||
setSourceUrl(current.sourceUrl)
|
|
||||||
setQuantity(String(current.quantity))
|
|
||||||
setUnit(current.unit)
|
|
||||||
setPurchasedAt(current.purchasedAt ?? '')
|
|
||||||
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
|
||||||
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
|
||||||
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
|
||||||
setSku(current.sku)
|
|
||||||
setLotCode(current.lotCode)
|
|
||||||
setNotes(current.notes)
|
|
||||||
setConflict('This lot changed elsewhere. The latest values are shown — review and save again.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
|
||||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField
|
|
||||||
label="Quantity"
|
|
||||||
name="quantity"
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="any"
|
|
||||||
min="0"
|
|
||||||
value={quantity}
|
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Unit"
|
|
||||||
name="unit"
|
|
||||||
value={unit}
|
|
||||||
onChange={(e) => setUnit(e.target.value as LotUnit)}
|
|
||||||
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
|
||||||
<TextField
|
|
||||||
label="Source link"
|
|
||||||
name="sourceUrl"
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
placeholder="https://…"
|
|
||||||
value={sourceUrl}
|
|
||||||
onChange={(e) => setSourceUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField
|
|
||||||
label="Purchased"
|
|
||||||
name="purchasedAt"
|
|
||||||
type="date"
|
|
||||||
value={purchasedAt}
|
|
||||||
onChange={(e) => setPurchasedAt(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Packed for"
|
|
||||||
name="packedForYear"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="2026"
|
|
||||||
value={packedForYear}
|
|
||||||
onChange={(e) => setPackedForYear(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField
|
|
||||||
label="Cost"
|
|
||||||
name="cost"
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
placeholder="4.99"
|
|
||||||
value={cost}
|
|
||||||
onChange={(e) => setCost(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Germination %"
|
|
||||||
name="germination"
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="any"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={germination}
|
|
||||||
onChange={(e) => setGermination(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
|
||||||
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
||||||
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
|
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={pending}>
|
|
||||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { safeExternalUrl } from '@/lib/seedLots'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A link out to where seed came from.
|
|
||||||
*
|
|
||||||
* The href is a URL somebody pasted, so it is re-checked here before rendering
|
|
||||||
* and carries rel="noopener noreferrer" — the server scheme-checks it too (#50),
|
|
||||||
* but a link is rendered from whatever the client was handed, and "the backend
|
|
||||||
* validated it" is not a reason to hand javascript: to an anchor tag.
|
|
||||||
*
|
|
||||||
* Renders nothing at all when the URL is absent or unsafe, so callers don't each
|
|
||||||
* have to remember to guard.
|
|
||||||
*/
|
|
||||||
export function SourceLink({ url, label = 'source' }: { url: string; label?: string }) {
|
|
||||||
const safe = safeExternalUrl(url)
|
|
||||||
if (!safe) return null
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={safe}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
|
||||||
>
|
|
||||||
{label} ↗
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,17 @@ import { cn } from '@/lib/cn'
|
|||||||
|
|
||||||
type AlertTone = 'error' | 'info'
|
type AlertTone = 'error' | 'info'
|
||||||
|
|
||||||
/** A small inline notice for form errors and status messages. */
|
/** An inline notice. Error = terracotta wash; info = the sage banner the editor
|
||||||
export function Alert({ tone = 'error', children }: { tone?: AlertTone; children: ReactNode }) {
|
* uses for season notes. */
|
||||||
|
export function Alert({ tone = 'error', className, children }: { tone?: AlertTone; className?: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={tone === 'error' ? 'alert' : 'status'}
|
role={tone === 'error' ? 'alert' : 'status'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md border px-3 py-2 text-sm',
|
'rounded-md px-3.5 py-2 text-[13px] font-semibold leading-relaxed',
|
||||||
tone === 'error' && 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
|
tone === 'error' && 'bg-accent-100 text-accent-800',
|
||||||
tone === 'info' && 'border-border bg-border/30 text-muted',
|
tone === 'info' && 'bg-accent-2-200 text-accent-2-800',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,25 +1,84 @@
|
|||||||
import type { ButtonHTMLAttributes } from 'react'
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
import { Icon, type IconName } from './Icon'
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'ghost' | 'danger'
|
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'plain'
|
||||||
|
|
||||||
/** Shared button styling, exported so anchor "buttons" (e.g. the OIDC link) match. */
|
/** The pill button classes, exported so anchor "buttons" (the OIDC link, card
|
||||||
export function buttonClasses(variant: ButtonVariant = 'primary', className?: string) {
|
* Open links) match real buttons exactly. */
|
||||||
|
export function buttonClass(variant: ButtonVariant = 'secondary', className?: string): string {
|
||||||
return cn(
|
return cn(
|
||||||
'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
'btn',
|
||||||
'outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
|
variant === 'primary' && 'btn-primary',
|
||||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
variant === 'secondary' && 'btn-secondary',
|
||||||
variant === 'primary' && 'bg-accent text-accent-contrast hover:bg-accent-strong',
|
variant === 'ghost' && 'btn-ghost',
|
||||||
variant === 'ghost' && 'border border-border text-fg hover:bg-border/50',
|
variant === 'plain' && 'btn-soft',
|
||||||
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500/40',
|
|
||||||
className,
|
className,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: ButtonVariant
|
variant?: ButtonVariant
|
||||||
|
/** A leading Lucide glyph. */
|
||||||
|
icon?: IconName
|
||||||
|
iconSize?: number
|
||||||
|
/** Phone hit targets are ≥ 44px. */
|
||||||
|
tall?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ variant = 'primary', className, type = 'button', ...props }: ButtonProps) {
|
export function Button({
|
||||||
return <button type={type} className={buttonClasses(variant, className)} {...props} />
|
variant = 'secondary',
|
||||||
|
icon,
|
||||||
|
iconSize = 14,
|
||||||
|
tall,
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button type={type} className={cn(buttonClass(variant), tall && 'min-h-11', className)} {...props}>
|
||||||
|
{icon && <Icon name={icon} size={iconSize} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
/** Accessible name AND tooltip — an icon-only control always says what it does. */
|
||||||
|
label: string
|
||||||
|
icon: IconName
|
||||||
|
iconSize?: number
|
||||||
|
variant?: ButtonVariant
|
||||||
|
/** Pixel size of the round button (36 by default; 38/44 on the phone). */
|
||||||
|
size?: number
|
||||||
|
iconClassName?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
iconSize = 15,
|
||||||
|
variant = 'secondary',
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
iconClassName,
|
||||||
|
type = 'button',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={cn(buttonClass(variant), 'btn-icon', className)}
|
||||||
|
style={size ? { width: size, height: size } : undefined}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={iconSize} className={iconClassName} />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { Alert } from './Alert'
|
||||||
|
import { Button } from './Button'
|
||||||
|
import { Dialog } from './Dialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A confirm-and-act dialog: a message, then Never mind / Confirm. Owns the busy
|
||||||
|
* lock, the inline error on failure (so a 409 like PLANT_IN_USE is shown, not
|
||||||
|
* swallowed), and the footer. onConfirm resolving closes the dialog; it throwing
|
||||||
|
* keeps the dialog open with the error and re-enables the button for a retry.
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
confirmLabel,
|
||||||
|
busyLabel,
|
||||||
|
cancelLabel = 'Never mind',
|
||||||
|
confirmDisabled = false,
|
||||||
|
errorFallback,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
confirmLabel: string
|
||||||
|
busyLabel: string
|
||||||
|
cancelLabel?: string
|
||||||
|
confirmDisabled?: boolean
|
||||||
|
errorFallback: string
|
||||||
|
onConfirm: () => Promise<unknown>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await onConfirm()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, errorFallback))
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={title} onClose={onClose} busy={busy}>
|
||||||
|
<div className="text-sm leading-relaxed text-ink-soft">{children}</div>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleConfirm} disabled={busy || confirmDisabled}>
|
||||||
|
{busy ? busyLabel : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
|
|
||||||
* shape every confirmation repeated by hand — the busy lock, the inline error on
|
|
||||||
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
|
|
||||||
* so each caller supplies only its message, its action, and its labels.
|
|
||||||
*
|
|
||||||
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
|
|
||||||
* the dialog open with the error and re-enables the button for a retry. This is
|
|
||||||
* confirmations only — dialogs with their own inputs (a rename, a form) keep
|
|
||||||
* using Modal directly.
|
|
||||||
*/
|
|
||||||
export function ConfirmModal({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
confirmLabel,
|
|
||||||
busyLabel,
|
|
||||||
confirmVariant = 'danger',
|
|
||||||
confirmDisabled = false,
|
|
||||||
errorFallback,
|
|
||||||
onConfirm,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
/** The body — what's being confirmed. */
|
|
||||||
children: ReactNode
|
|
||||||
confirmLabel: string
|
|
||||||
/** Label while the action is in flight (e.g. "Deleting…"). */
|
|
||||||
busyLabel: string
|
|
||||||
confirmVariant?: 'danger' | 'primary'
|
|
||||||
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
|
|
||||||
confirmDisabled?: boolean
|
|
||||||
/** Message if the action throws something without its own user-facing text. */
|
|
||||||
errorFallback: string
|
|
||||||
onConfirm: () => Promise<unknown>
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function handleConfirm() {
|
|
||||||
setError(null)
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
await onConfirm()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
setError(errorMessage(err, errorFallback))
|
|
||||||
setBusy(false) // keep the dialog open so the message shows and retry works
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title={title} onClose={onClose} busy={busy}>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{children}
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={confirmVariant}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={busy || confirmDisabled}
|
|
||||||
>
|
|
||||||
{busy ? busyLabel : confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||||
|
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||||
|
// break the wrap math.
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||||
|
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A centered dialog over a dimmed backdrop — the design's `.dialog` card. Closes
|
||||||
|
* on Escape or a backdrop click unless `busy` (a mutation is in flight), so an
|
||||||
|
* action can finish and report. Focus is trapped inside and returned to the
|
||||||
|
* opener on close. The caller owns open/closed state (render only when open).
|
||||||
|
*/
|
||||||
|
export function Dialog({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
busy = false,
|
||||||
|
width = 420,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
onClose: () => void
|
||||||
|
busy?: boolean
|
||||||
|
/** Card width in px (capped at the viewport). */
|
||||||
|
width?: number
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
|
// Latest onClose/busy in refs so the mount-only effect never re-runs (which
|
||||||
|
// would re-attach the listener and steal focus on every parent re-render).
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
const busyRef = useRef(busy)
|
||||||
|
busyRef.current = busy
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const card = cardRef.current
|
||||||
|
const opener = document.activeElement as HTMLElement | null
|
||||||
|
// Land on the first control if there is one, else the card itself.
|
||||||
|
const first = card?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
|
;(first ?? card)?.focus()
|
||||||
|
|
||||||
|
const focusable = () => Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && !busyRef.current) {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCloseRef.current()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const items = focusable()
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
card?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const firstItem = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
const active = document.activeElement
|
||||||
|
if (!card || !card.contains(active)) {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e.shiftKey ? last : firstItem).focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.shiftKey && (active === firstItem || active === card)) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstItem.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey, true)
|
||||||
|
if (opener && opener.isConnected) opener.focus()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dialog-backdrop"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn('dialog', className)}
|
||||||
|
style={{ width: `min(${width}px, 100%)` }}
|
||||||
|
>
|
||||||
|
<h3 className="text-[22px]">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
/** A horizontal rule with centered label text (e.g. "or" between auth options). */
|
|
||||||
export function Divider({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted">
|
|
||||||
<span className="h-px flex-1 bg-border" />
|
|
||||||
{children}
|
|
||||||
<span className="h-px flex-1 bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useId,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
type SelectHTMLAttributes,
|
||||||
|
type TextareaHTMLAttributes,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
/** The field id: an explicit id, else the name, else a generated stable id, so
|
||||||
|
* the label's htmlFor always binds to something. */
|
||||||
|
function useFieldId(id?: string, name?: string): string {
|
||||||
|
const generated = useId()
|
||||||
|
return id ?? name ?? generated
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled control: the design's `.field` (12px label above a pill input). */
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
hint,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: ReactNode
|
||||||
|
htmlFor?: string
|
||||||
|
hint?: ReactNode
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('field', className)}>
|
||||||
|
<label htmlFor={htmlFor}>{label}</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="mt-1 text-xs leading-relaxed text-ink-mute">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
/** 16px font + 44px height for phone forms (stops iOS zoom). */
|
||||||
|
large?: boolean
|
||||||
|
wrapperClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||||
|
{ label, hint, large, id, name, className, wrapperClassName, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const inputId = useFieldId(id, name)
|
||||||
|
return (
|
||||||
|
<Field label={label} htmlFor={inputId} hint={hint} className={wrapperClassName}>
|
||||||
|
<input ref={ref} id={inputId} name={name} className={cn('input', large && 'input-lg', className)} {...props} />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SelectFieldProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label: ReactNode
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
hint?: ReactNode
|
||||||
|
wrapperClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(function SelectField(
|
||||||
|
{ label, options, hint, id, name, className, wrapperClassName, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const fieldId = useFieldId(id, name)
|
||||||
|
return (
|
||||||
|
<Field label={label} htmlFor={fieldId} hint={hint} className={wrapperClassName}>
|
||||||
|
<select ref={ref} id={fieldId} name={name} className={cn('input', className)} {...props}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
interface TextAreaFieldProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
wrapperClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextAreaField(
|
||||||
|
{ label, hint, id, name, className, wrapperClassName, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const fieldId = useFieldId(id, name)
|
||||||
|
return (
|
||||||
|
<Field label={label} htmlFor={fieldId} hint={hint} className={wrapperClassName}>
|
||||||
|
<textarea ref={ref} id={fieldId} name={name} className={cn('input', className)} {...props} />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
// Lucide glyphs (https://lucide.dev), inlined at the design's stroke-width 2.75
|
||||||
|
// with round caps and joins. Each entry is the element list of one 24×24 icon:
|
||||||
|
// a path `d`, or `c:cx,cy,r` for a circle, or `r:x,y,w,h,rx` for a rect.
|
||||||
|
const GLYPHS = {
|
||||||
|
sprout: [
|
||||||
|
'M7 20h10',
|
||||||
|
'M10 20c5.5-2.5.8-6.4 3-10',
|
||||||
|
'M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z',
|
||||||
|
'M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z',
|
||||||
|
],
|
||||||
|
shovel: ['M2 22v-5l5-5 5 5-5 5z', 'M9.5 14.5 16 8', 'm17 2 5 5-.5.5a3.53 3.53 0 0 1-5 0v0a3.53 3.53 0 0 1 0-5L17 2'],
|
||||||
|
notebook: ['M2 6h4', 'M2 10h4M2 14h4M2 18h4', 'M8 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z'],
|
||||||
|
'message-circle': ['M7.9 20A9 9 0 1 0 4 16.1L2 22Z'],
|
||||||
|
settings: [
|
||||||
|
'c:12,12,3',
|
||||||
|
'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z',
|
||||||
|
],
|
||||||
|
'undo-2': ['M9 14 4 9l5-5', 'M4 9h10.5a5.5 5.5 0 0 1 0 11H11'],
|
||||||
|
'rotate-cw': ['M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8', 'M21 3v5h-5'],
|
||||||
|
'trash-2': ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'],
|
||||||
|
plus: ['M5 12h14', 'M12 5v14'],
|
||||||
|
minus: ['M5 12h14'],
|
||||||
|
maximize: ['M8 3H5a2 2 0 0 0-2 2v3', 'M21 8V5a2 2 0 0 0-2-2h-3', 'M3 16v3a2 2 0 0 0 2 2h3', 'M16 21h3a2 2 0 0 0 2-2v-3'],
|
||||||
|
'chevron-left': ['m15 18-6-6 6-6'],
|
||||||
|
'chevron-right': ['m9 18 6-6-6-6'],
|
||||||
|
'chevron-down': ['m6 9 6 6 6-6'],
|
||||||
|
x: ['M18 6 6 18', 'm6 6 12 12'],
|
||||||
|
camera: ['M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z', 'c:12,13,3'],
|
||||||
|
'share-2': ['c:18,5,3', 'c:6,12,3', 'c:18,19,3', 'm8.6 10.6 6.8-3.9', 'm8.6 13.4 6.8 3.9'],
|
||||||
|
copy: ['r:8,8,14,14,2', 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'],
|
||||||
|
lock: ['r:3,11,18,11,2', 'M7 11V7a5 5 0 0 1 10 0v4'],
|
||||||
|
monitor: ['M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z', 'M8 21h8', 'M12 17v4'],
|
||||||
|
sun: ['M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8', 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4'],
|
||||||
|
moon: ['M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'],
|
||||||
|
search: ['c:11,11,8', 'm21 21-4.3-4.3'],
|
||||||
|
send: ['m22 2-7 20-4-9-9-4Z', 'M22 2 11 13'],
|
||||||
|
pencil: [
|
||||||
|
'M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z',
|
||||||
|
'm15 5 4 4',
|
||||||
|
],
|
||||||
|
'log-out': ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'],
|
||||||
|
check: ['M20 6 9 17l-5-5'],
|
||||||
|
eye: ['M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0', 'c:12,12,3'],
|
||||||
|
'external-link': ['M15 3h6v6', 'M10 14 21 3', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'],
|
||||||
|
'rows-3': ['r:3,3,18,18,2', 'M21 9H3', 'M21 15H3'],
|
||||||
|
eraser: ['m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21', 'M22 21H7', 'm5 11 9 9'],
|
||||||
|
'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
|
||||||
|
upload: ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'm17 8-5-5-5 5', 'M12 3v12'],
|
||||||
|
'triangle-alert': ['m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01'],
|
||||||
|
square: ['r:3,3,18,18,2'],
|
||||||
|
history: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5', 'M12 7v5l4 2'],
|
||||||
|
link: [
|
||||||
|
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
||||||
|
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||||
|
],
|
||||||
|
'arrow-left': ['m12 19-7-7 7-7', 'M19 12H5'],
|
||||||
|
'more-horizontal': ['c:12,12,1', 'c:19,12,1', 'c:5,12,1'],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IconName = keyof typeof GLYPHS
|
||||||
|
|
||||||
|
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
|
||||||
|
name: IconName
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One Lucide icon at the design's 2.75 stroke; color comes from currentColor
|
||||||
|
* unless a `stroke` is given. Decorative by default (aria-hidden) — the
|
||||||
|
* control around it carries the label. */
|
||||||
|
export function Icon({ name, size = 15, stroke, ...rest }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={stroke ?? 'currentColor'}
|
||||||
|
strokeWidth={2.75}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{GLYPHS[name].map((el, i) => {
|
||||||
|
if (el.startsWith('c:')) {
|
||||||
|
const [cx, cy, r] = el.slice(2).split(',').map(Number)
|
||||||
|
return <circle key={i} cx={cx} cy={cy} r={r} />
|
||||||
|
}
|
||||||
|
if (el.startsWith('r:')) {
|
||||||
|
const [x, y, w, h, rx] = el.slice(2).split(',').map(Number)
|
||||||
|
return <rect key={i} x={x} y={y} width={w} height={h} rx={rx} />
|
||||||
|
}
|
||||||
|
return <path key={i} d={el} />
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react'
|
|
||||||
|
|
||||||
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
|
||||||
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
|
||||||
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
|
|
||||||
const FOCUSABLE_SELECTOR =
|
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
|
||||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
|
||||||
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
|
||||||
* action can finish and report. The caller owns open/closed state (render only
|
|
||||||
* when open).
|
|
||||||
*/
|
|
||||||
export function Modal({
|
|
||||||
title,
|
|
||||||
onClose,
|
|
||||||
busy = false,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
onClose: () => void
|
|
||||||
busy?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
const cardRef = useRef<HTMLDivElement>(null)
|
|
||||||
// Keep the latest onClose/busy in refs so the mount-only effect below never
|
|
||||||
// re-runs (which would re-attach the listener and steal focus on every parent
|
|
||||||
// re-render, e.g. during a background refetch).
|
|
||||||
const onCloseRef = useRef(onClose)
|
|
||||||
onCloseRef.current = onClose
|
|
||||||
const busyRef = useRef(busy)
|
|
||||||
busyRef.current = busy
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const card = cardRef.current
|
|
||||||
// Remember who opened the dialog so focus can return there on close —
|
|
||||||
// otherwise it lands on <body> and a keyboard user loses their place.
|
|
||||||
const opener = document.activeElement as HTMLElement | null
|
|
||||||
card?.focus()
|
|
||||||
|
|
||||||
const focusable = () =>
|
|
||||||
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape' && !busyRef.current) {
|
|
||||||
onCloseRef.current()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key !== 'Tab') return
|
|
||||||
const items = focusable()
|
|
||||||
if (items.length === 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
card?.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const first = items[0]
|
|
||||||
const last = items[items.length - 1]
|
|
||||||
const active = document.activeElement
|
|
||||||
// If focus is NOT inside the dialog, pull it back in rather than let Tab
|
|
||||||
// escape. This is the robust case that covers focus having fallen to
|
|
||||||
// <body> — a control that was removed (ShareGardenModal's remove-share
|
|
||||||
// button) or disabled while busy — as well as any externally-stolen focus.
|
|
||||||
if (!card || !card.contains(active)) {
|
|
||||||
e.preventDefault()
|
|
||||||
;(e.shiftKey ? last : first).focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.shiftKey && (active === first || active === card)) {
|
|
||||||
e.preventDefault()
|
|
||||||
last.focus()
|
|
||||||
} else if (!e.shiftKey && active === last) {
|
|
||||||
e.preventDefault()
|
|
||||||
first.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKey)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', onKey)
|
|
||||||
// Restore focus to the opener only if it's still in the document — the
|
|
||||||
// delete/clear flows this trap targets often remove the element that
|
|
||||||
// opened the dialog (a garden card, a plop row). A disconnected node's
|
|
||||||
// focus() silently no-ops and leaves focus on <body>, so fall through to
|
|
||||||
// that case explicitly rather than pretend it worked.
|
|
||||||
if (opener && opener.isConnected) opener.focus()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (e.target === e.currentTarget && !busy) onClose()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={title}
|
|
||||||
tabIndex={-1}
|
|
||||||
className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-lg outline-none"
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
|
||||||
<div className="mt-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
export interface SegOption<T extends string> {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The segmented pill control: options on a neutral-200 track, the active one
|
||||||
|
* lifted on a neutral-100 pill with a soft shadow. Used for seasons, the theme,
|
||||||
|
* units, and read-only status displays (pass `disabled`).
|
||||||
|
*/
|
||||||
|
export function Seg<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
optionClassName,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
options: SegOption<T>[]
|
||||||
|
value: T
|
||||||
|
onChange?: (v: T) => void
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
optionClassName?: string
|
||||||
|
/** Accessible group name. */
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={cn('seg', className)} role="group" aria-label={label}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
className={cn('seg-opt', optionClassName)}
|
||||||
|
aria-pressed={o.value === value}
|
||||||
|
disabled={disabled}
|
||||||
|
title={o.title}
|
||||||
|
onClick={() => onChange?.(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { forwardRef, type SelectHTMLAttributes } from 'react'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { fieldControlClass, useFieldId } from './field'
|
|
||||||
|
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
||||||
label: string
|
|
||||||
options: { value: string; label: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
||||||
{ label, options, id, name, className, ...props },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const fieldId = useFieldId(id, name)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<select ref={ref} id={fieldId} name={name} className={cn(fieldControlClass, className)} {...props}>
|
|
||||||
{options.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
export type TagTone = 'accent' | 'accent-2' | 'neutral' | 'outline'
|
||||||
|
|
||||||
|
export function Tag({ tone = 'neutral', className, ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: TagTone }) {
|
||||||
|
return <span className={cn('tag', `tag-${tone}`, className)} {...props} />
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { fieldControlClass, useFieldId } from './field'
|
|
||||||
|
|
||||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
|
||||||
{ label, id, name, className, ...props },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const fieldId = useFieldId(id, name)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<textarea ref={ref} id={fieldId} name={name} className={cn(fieldControlClass, className)} {...props} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { fieldControlClass, useFieldId } from './field'
|
|
||||||
|
|
||||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label: string
|
|
||||||
hint?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
|
||||||
{ label, hint, id, name, className, ...props },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const inputId = useFieldId(id, name)
|
|
||||||
const hintId = hint ? `${inputId}-hint` : undefined
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor={inputId} className="text-sm font-medium text-fg">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
id={inputId}
|
|
||||||
name={name}
|
|
||||||
aria-describedby={hintId}
|
|
||||||
className={cn(fieldControlClass, className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{hint && (
|
|
||||||
<p id={hintId} className="text-xs text-muted">
|
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
/** The On/Off pill. Sage when on, neutral when off. */
|
||||||
|
export function Toggle({
|
||||||
|
on,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
onLabel = 'On',
|
||||||
|
offLabel = 'Off',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
on: boolean
|
||||||
|
onChange?: (next: boolean) => void
|
||||||
|
disabled?: boolean
|
||||||
|
/** Accessible name for the switch. */
|
||||||
|
label: string
|
||||||
|
onLabel?: string
|
||||||
|
offLabel?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
|
aria-pressed={on}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('toggle', className)}
|
||||||
|
onClick={() => onChange?.(!on)}
|
||||||
|
>
|
||||||
|
{on ? onLabel : offLabel}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Shared footer-action button styling for list cards (garden/plant), so the
|
|
||||||
// verbatim class strings don't drift between them.
|
|
||||||
//
|
|
||||||
// Sized for touch: a ~40px-tall tap target (min-h + py-2) rather than the old
|
|
||||||
// ~28px text-link row, which was easy to mis-tap on a phone (#105). The min-h is
|
|
||||||
// what guarantees the target even when the label is short.
|
|
||||||
|
|
||||||
const cardActionBase =
|
|
||||||
'inline-flex min-h-[2.5rem] items-center rounded-md px-3 py-2 text-sm font-medium ' +
|
|
||||||
'text-muted outline-none transition-colors focus-visible:ring-2 '
|
|
||||||
|
|
||||||
export const cardActionClass =
|
|
||||||
cardActionBase + 'hover:bg-border/50 hover:text-fg focus-visible:ring-accent/40'
|
|
||||||
|
|
||||||
export const cardDangerClass =
|
|
||||||
cardActionBase +
|
|
||||||
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-red-500/40 dark:hover:text-red-400'
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useId } from 'react'
|
|
||||||
|
|
||||||
// Shared field plumbing for the labelled form controls (TextField, TextArea,
|
|
||||||
// Select) so their id-fallback and base styling stay in one place.
|
|
||||||
|
|
||||||
/** The field id: an explicit id, else the name, else a generated stable id, so
|
|
||||||
* the label's htmlFor always binds to something. */
|
|
||||||
export function useFieldId(id?: string, name?: string): string {
|
|
||||||
const generated = useId()
|
|
||||||
return id ?? name ?? generated
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Base classes shared by the text/select/textarea controls. text-base (16px)
|
|
||||||
* keeps iOS Safari from zooming on focus. */
|
|
||||||
export const fieldControlClass =
|
|
||||||
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg ' +
|
|
||||||
'outline-none transition-colors placeholder:text-muted/70 ' +
|
|
||||||
'focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60'
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
import { Icon } from './Icon'
|
||||||
|
|
||||||
type ToastTone = 'info' | 'error'
|
type ToastTone = 'info' | 'error'
|
||||||
interface Toast {
|
interface Toast {
|
||||||
@@ -17,10 +18,8 @@ interface ToastState {
|
|||||||
|
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
// Error toasts don't auto-dismiss (#85), so a burst of failures could grow the
|
||||||
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
// stack without bound; keep the most recent few so the newest is always visible.
|
||||||
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
|
||||||
// happened — is always visible.
|
|
||||||
const MAX_TOASTS = 4
|
const MAX_TOASTS = 4
|
||||||
|
|
||||||
export const useToastStore = create<ToastState>((set) => ({
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
@@ -36,14 +35,12 @@ export const toast = {
|
|||||||
error: (m: string) => useToastStore.getState().push(m, 'error'),
|
error: (m: string) => useToastStore.getState().push(m, 'error'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
|
||||||
function ToastItem({ item }: { item: Toast }) {
|
function ToastItem({ item }: { item: Toast }) {
|
||||||
const dismiss = useToastStore((s) => s.dismiss)
|
const dismiss = useToastStore((s) => s.dismiss)
|
||||||
const isError = item.tone === 'error'
|
const isError = item.tone === 'error'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Error toasts are the primary report that a mutation failed, so they do NOT
|
// Errors are the primary report that a mutation failed, so they stay until
|
||||||
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
// dismissed; info toasts time out.
|
||||||
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
|
||||||
if (isError) return
|
if (isError) return
|
||||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
@@ -52,10 +49,8 @@ function ToastItem({ item }: { item: Toast }) {
|
|||||||
<div
|
<div
|
||||||
role={isError ? 'alert' : 'status'}
|
role={isError ? 'alert' : 'status'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
'elev-md pointer-events-auto flex items-center gap-2.5 rounded-full border py-2 pl-4 pr-2 text-[13px] font-semibold',
|
||||||
isError
|
isError ? 'border-accent-300 bg-accent-100 text-accent-800' : 'border-divider bg-neutral-100 text-text',
|
||||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
|
||||||
: 'border-border bg-surface text-fg',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex-1">{item.message}</span>
|
<span className="flex-1">{item.message}</span>
|
||||||
@@ -63,20 +58,20 @@ function ToastItem({ item }: { item: Toast }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => dismiss(item.id)}
|
onClick={() => dismiss(item.id)}
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
className="btn btn-icon btn-soft !h-7 !w-7 text-current"
|
||||||
>
|
>
|
||||||
✕
|
<Icon name="x" size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed stack of active toasts. Mount once near the app root. */
|
/** Fixed stack of active toasts. Mounted once in the app shell. */
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const toasts = useToastStore((s) => s.toasts)
|
const toasts = useToastStore((s) => s.toasts)
|
||||||
if (toasts.length === 0) return null
|
if (toasts.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
|
<div className="pointer-events-none fixed inset-x-4 bottom-[calc(16px+env(safe-area-inset-bottom))] z-[60] flex flex-col items-center gap-2">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<ToastItem key={t.id} item={t} />
|
<ToastItem key={t.id} item={t} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { IconButton } from '@/components/ui/Button'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn } from '@/lib/agent'
|
||||||
|
import type { useUndo } from '@/lib/history'
|
||||||
|
import { lazyPage } from '@/lib/lazyPage'
|
||||||
|
|
||||||
|
// Lazy so the markdown renderer loads only when an assistant message renders.
|
||||||
|
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||||
|
|
||||||
|
/** Falls back to the raw text if the markdown chunk can't load or throws. */
|
||||||
|
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||||
|
state = { failed: false }
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true }
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? this.props.fallback : this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talk to the garden assistant beside the canvas — watching the plan change as
|
||||||
|
* it works IS the confirmation. Every turn lands as one undoable step, so each
|
||||||
|
* assistant reply that changed something carries its own Undo.
|
||||||
|
*/
|
||||||
|
export function AssistantTab({ gardenId, canEdit, undo, large = false }: { gardenId: number; canEdit: boolean; undo: ReturnType<typeof useUndo>; large?: boolean }) {
|
||||||
|
const history = useAgentHistory(gardenId, true)
|
||||||
|
const clear = useClearAgentHistory(gardenId)
|
||||||
|
const refresh = useAgentRefresh(gardenId)
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [warning, setWarning] = useState<string | null>(null)
|
||||||
|
const abort = useRef<AbortController | null>(null)
|
||||||
|
const bottom = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Deliberately NOT aborted on unmount: selecting a bed switches the rail to
|
||||||
|
// the inspector, and that must not kill a turn mid-flight. The request runs
|
||||||
|
// on; the exchange is persisted server-side; coming back shows it.
|
||||||
|
useEffect(() => {
|
||||||
|
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}, [history.data, pending])
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const message = input.trim()
|
||||||
|
if (!message || pending) return
|
||||||
|
setInput('')
|
||||||
|
setError(null)
|
||||||
|
setWarning(null)
|
||||||
|
setPending({ message, steps: [] })
|
||||||
|
const controller = new AbortController()
|
||||||
|
abort.current = controller
|
||||||
|
void streamChat(
|
||||||
|
gardenId,
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
onStep: (step) => {
|
||||||
|
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||||
|
refresh.canvas()
|
||||||
|
},
|
||||||
|
onDone: (turn: AgentTurn) => {
|
||||||
|
setPending(null)
|
||||||
|
refresh.everything()
|
||||||
|
if (turn.truncated) setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||||
|
},
|
||||||
|
onWarning: setWarning,
|
||||||
|
onError: (m) => {
|
||||||
|
setPending(null)
|
||||||
|
setError(m)
|
||||||
|
refresh.everything()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
abort.current?.abort()
|
||||||
|
setPending(null)
|
||||||
|
refresh.everything()
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = history.data ?? []
|
||||||
|
const bubbleText = large ? 'text-[13.5px]' : 'text-[13px]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||||
|
{!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
|
||||||
|
{history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
|
||||||
|
{history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}
|
||||||
|
{history.isSuccess && messages.length === 0 && !pending && (
|
||||||
|
<p className="text-[13px] leading-relaxed text-ink-mute">
|
||||||
|
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the north half of the
|
||||||
|
west bed with beans”, “what's in here?”. Everything it does lands as one change you can undo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((m) =>
|
||||||
|
m.role === 'user' ? (
|
||||||
|
<div key={m.id} className={cn('self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText, 'max-w-[85%]')}>
|
||||||
|
{m.body}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={m.id} className="flex max-w-[90%] flex-col items-start gap-1 self-start">
|
||||||
|
<div className={cn('rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
|
||||||
|
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
|
||||||
|
<Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
|
||||||
|
<MarkdownMessage>{m.body}</MarkdownMessage>
|
||||||
|
</Suspense>
|
||||||
|
</MarkdownBoundary>
|
||||||
|
</div>
|
||||||
|
{m.changeSetId != null && canEdit && <TurnUndo changeSetId={m.changeSetId} undo={undo} />}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{pending && (
|
||||||
|
<>
|
||||||
|
<div className={cn('max-w-[85%] self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText)}>
|
||||||
|
{pending.message}
|
||||||
|
</div>
|
||||||
|
<div className={cn('max-w-[90%] self-start rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
|
||||||
|
{pending.steps.map((s) => (
|
||||||
|
<div key={s.index} className="text-xs text-ink-mute">
|
||||||
|
{describeStep(s)}…
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-ink-mute">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
||||||
|
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{warning && <Alert tone="info">{warning}</Alert>}
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div ref={bottom} />
|
||||||
|
<div className="mt-auto flex flex-col gap-1.5 pt-1.5">
|
||||||
|
{messages.length > 0 && !pending && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost self-end px-2 py-0.5 text-[11.5px]"
|
||||||
|
disabled={clear.isPending}
|
||||||
|
onClick={() => clear.mutate(undefined, { onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")) })}
|
||||||
|
>
|
||||||
|
Start over
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
className={cn('input', large && 'input-lg')}
|
||||||
|
placeholder="Ask about your garden…"
|
||||||
|
aria-label="Message the assistant"
|
||||||
|
value={input}
|
||||||
|
disabled={!!pending}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{pending ? (
|
||||||
|
<IconButton label="Stop" icon="square" iconSize={large ? 16 : 15} size={large ? 44 : 36} onClick={stop} />
|
||||||
|
) : (
|
||||||
|
<IconButton label="Send" icon="send" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={!input.trim()} onClick={send} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undo on the turn itself, so the common case never involves the History tab. */
|
||||||
|
function TurnUndo({ changeSetId, undo }: { changeSetId: number; undo: ReturnType<typeof useUndo> }) {
|
||||||
|
const outcome = undo.outcomeFor(changeSetId)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-0.5 pl-1">
|
||||||
|
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" disabled={outcome?.tone === 'pending'} onClick={() => undo.undo({ id: changeSetId })}>
|
||||||
|
{outcome?.tone === 'pending' ? 'Undoing…' : 'Undo this'}
|
||||||
|
</button>
|
||||||
|
{outcome && outcome.tone !== 'pending' && (
|
||||||
|
<p role="status" className={cn('text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
|
||||||
|
{outcome.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type DragEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from 'react'
|
||||||
|
import { clampScale, type Point } from '@/lib/geometry'
|
||||||
|
import { monogramInk } from '@/lib/monogram'
|
||||||
|
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||||
|
import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
|
||||||
|
import type { EditorPlanting } from '@/lib/plantings'
|
||||||
|
import { formatSize } from '@/lib/units'
|
||||||
|
import { kindDef, objectStyle, rectRadius } from './kinds'
|
||||||
|
import {
|
||||||
|
DIM_OBJECT,
|
||||||
|
DIM_PLOP,
|
||||||
|
MAX_SCALE,
|
||||||
|
MIN_OBJECT_CM,
|
||||||
|
MIN_SCALE,
|
||||||
|
SNAP_CM,
|
||||||
|
clampPlopLocal,
|
||||||
|
defaultPlopRadius,
|
||||||
|
objectAt,
|
||||||
|
objectTransform,
|
||||||
|
rotatedHalfHeight,
|
||||||
|
snapPlopLocal,
|
||||||
|
toLocal,
|
||||||
|
toWorld,
|
||||||
|
} from './shared'
|
||||||
|
import { useEditorStore, type Viewport } from './store'
|
||||||
|
import type { EditorGarden, EditorObject } from './types'
|
||||||
|
|
||||||
|
const WHEEL_SENSITIVITY = 0.0016
|
||||||
|
const ANIM_MS = 520
|
||||||
|
const REFIT_THRESHOLD_PX = 60
|
||||||
|
const FT = 30.48
|
||||||
|
|
||||||
|
export interface CanvasHandle {
|
||||||
|
zoomFit: () => void
|
||||||
|
zoomIn: () => void
|
||||||
|
zoomOut: () => void
|
||||||
|
/** Frame a bed and enter focus mode (the page handles mode + URL). */
|
||||||
|
focusObject: (o: EditorObject) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Drag =
|
||||||
|
| { t: 'pan'; sx: number; sy: number; tx: number; ty: number; moved: boolean; th: number }
|
||||||
|
| { t: 'obj'; base: EditorObject; sx: number; sy: number; moved: boolean; th: number }
|
||||||
|
| { t: 'plop'; base: EditorPlanting; obj: EditorObject; sx: number; sy: number; moved: boolean; th: number }
|
||||||
|
| { t: 'resize'; base: EditorObject; cx: number; cy: number; opp: Point; moved: boolean }
|
||||||
|
|
||||||
|
interface Pinch {
|
||||||
|
d0: number
|
||||||
|
cx0: number
|
||||||
|
cy0: number
|
||||||
|
s0: number
|
||||||
|
tx0: number
|
||||||
|
ty0: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The garden canvas: one SVG, world = garden cm under a single translate/scale
|
||||||
|
* group. Everything the prototype does — wheel zoom to the cursor, two-finger
|
||||||
|
* pinch about the centroid, one-finger pan, drag-to-move with a 3″ snap, tap to
|
||||||
|
* select, semantic zoom on the plant markers, a ghost while a kind is armed,
|
||||||
|
* drag-and-drop from the toolkit — lives here, with native SVG hit-testing
|
||||||
|
* doing the picking. Drags commit ONE change on release (a PATCH with the row's
|
||||||
|
* version); placement creates. Corner handles on the selected object resize it.
|
||||||
|
*/
|
||||||
|
export const Canvas = forwardRef<
|
||||||
|
CanvasHandle,
|
||||||
|
{
|
||||||
|
garden: EditorGarden
|
||||||
|
objects: EditorObject[]
|
||||||
|
plantings: EditorPlanting[]
|
||||||
|
plantsById: Map<number, Plant>
|
||||||
|
letters: Map<number, string>
|
||||||
|
canEdit: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
}
|
||||||
|
>(function Canvas({ garden, objects, plantings, plantsById, letters, canEdit, isMobile }, ref) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||||
|
const fitSize = useRef({ w: 0, h: 0 })
|
||||||
|
const fittedGarden = useRef<number | null>(null)
|
||||||
|
const wasMobile = useRef(isMobile)
|
||||||
|
const animTimer = useRef<number | null>(null)
|
||||||
|
const pts = useRef(new Map<number, Point>())
|
||||||
|
const drag = useRef<Drag | null>(null)
|
||||||
|
const pinch = useRef<Pinch | null>(null)
|
||||||
|
const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null)
|
||||||
|
|
||||||
|
const vp = useEditorStore((s) => s.vp)
|
||||||
|
const anim = useEditorStore((s) => s.anim)
|
||||||
|
const sel = useEditorStore((s) => s.sel)
|
||||||
|
const focusId = useEditorStore((s) => s.focusId)
|
||||||
|
const armedKind = useEditorStore((s) => s.armedKind)
|
||||||
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
|
const ghost = useEditorStore((s) => s.ghost)
|
||||||
|
const liveObject = useEditorStore((s) => s.liveObject)
|
||||||
|
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||||
|
|
||||||
|
const createObject = useCreateObject(garden.id)
|
||||||
|
const createPlanting = useCreatePlanting(garden.id)
|
||||||
|
const updateObject = useUpdateObject(garden.id)
|
||||||
|
const updatePlanting = useUpdatePlanting(garden.id)
|
||||||
|
|
||||||
|
const GW = garden.widthCm
|
||||||
|
const GH = garden.heightCm
|
||||||
|
const s = vp.s
|
||||||
|
const snapStep = garden.snapToGrid && garden.gridSizeCm > 0 ? garden.gridSizeCm : SNAP_CM
|
||||||
|
|
||||||
|
// Objects in draw order, with any in-flight drag geometry merged in.
|
||||||
|
const rendered = useMemo(() => {
|
||||||
|
const sorted = [...objects].sort((a, b) => a.zIndex - b.zIndex || a.id - b.id)
|
||||||
|
return liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted
|
||||||
|
}, [objects, liveObject])
|
||||||
|
const renderedPlops = useMemo(
|
||||||
|
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
|
||||||
|
[plantings, livePlanting],
|
||||||
|
)
|
||||||
|
const byId = useMemo(() => new Map(rendered.map((o) => [o.id, o])), [rendered])
|
||||||
|
// Latest lists for the pointer handlers, which must not close over a render.
|
||||||
|
const latest = useRef({ rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep })
|
||||||
|
latest.current = { rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep }
|
||||||
|
|
||||||
|
// ── camera ──────────────────────────────────────────────────────────────
|
||||||
|
const camera = useCallback((next: Viewport, animate: boolean) => {
|
||||||
|
useEditorStore.getState().setVp(next, animate)
|
||||||
|
if (animTimer.current != null) window.clearTimeout(animTimer.current)
|
||||||
|
if (animate) {
|
||||||
|
animTimer.current = window.setTimeout(() => {
|
||||||
|
animTimer.current = null
|
||||||
|
useEditorStore.getState().setAnim(false)
|
||||||
|
}, ANIM_MS)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const zoomFit = useCallback(() => {
|
||||||
|
const el = svgRef.current
|
||||||
|
if (!el) return
|
||||||
|
const w = el.clientWidth
|
||||||
|
const h = el.clientHeight
|
||||||
|
const pad = latest.current.isMobile ? 20 : 40
|
||||||
|
const { widthCm, heightCm } = latest.current.garden
|
||||||
|
const ns = clampScale(Math.min((w - pad * 2) / widthCm, (h - pad * 2) / heightCm), MIN_SCALE, MAX_SCALE)
|
||||||
|
if (!Number.isFinite(ns) || ns <= 0) return
|
||||||
|
fitSize.current = { w, h }
|
||||||
|
camera({ s: ns, tx: (w - widthCm * ns) / 2, ty: (h - heightCm * ns) / 2 }, true)
|
||||||
|
}, [camera])
|
||||||
|
|
||||||
|
const zoomBy = useCallback(
|
||||||
|
(f: number) => {
|
||||||
|
const el = svgRef.current
|
||||||
|
if (!el) return
|
||||||
|
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||||
|
const ns = clampScale(cur * f, MIN_SCALE, MAX_SCALE)
|
||||||
|
const px = el.clientWidth / 2
|
||||||
|
const py = el.clientHeight / 2
|
||||||
|
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, true)
|
||||||
|
},
|
||||||
|
[camera],
|
||||||
|
)
|
||||||
|
|
||||||
|
const focusObject = useCallback(
|
||||||
|
(o: EditorObject) => {
|
||||||
|
const el = svgRef.current
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
const m = latest.current.isMobile
|
||||||
|
st.setFocus(o.id)
|
||||||
|
st.setSel(m ? null : { type: 'object', id: o.id })
|
||||||
|
st.setArmedKind(null)
|
||||||
|
st.setGhost(null)
|
||||||
|
st.setTab('plot')
|
||||||
|
if (!el) return
|
||||||
|
const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm
|
||||||
|
const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm
|
||||||
|
const ns = Math.min(
|
||||||
|
MAX_SCALE * 0.75,
|
||||||
|
Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))),
|
||||||
|
)
|
||||||
|
camera({ s: ns, tx: el.clientWidth / 2 - o.xCm * ns, ty: el.clientHeight / 2 - o.yCm * ns + (m ? 0 : 10) }, true)
|
||||||
|
},
|
||||||
|
[camera],
|
||||||
|
)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ zoomFit, zoomIn: () => zoomBy(1.45), zoomOut: () => zoomBy(1 / 1.45), focusObject }), [
|
||||||
|
zoomFit,
|
||||||
|
zoomBy,
|
||||||
|
focusObject,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Measure; refit on a real size change (>60px) or when the chrome flips.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = hostRef.current
|
||||||
|
if (!el) return
|
||||||
|
const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (size.w === 0 || size.h === 0 || GW === 0 || GH === 0) return
|
||||||
|
const first = fittedGarden.current !== garden.id
|
||||||
|
const flipped = wasMobile.current !== isMobile
|
||||||
|
const big =
|
||||||
|
Math.abs(size.w - fitSize.current.w) > REFIT_THRESHOLD_PX || Math.abs(size.h - fitSize.current.h) > REFIT_THRESHOLD_PX
|
||||||
|
wasMobile.current = isMobile
|
||||||
|
if (!first && !flipped && !big) return
|
||||||
|
fittedGarden.current = garden.id
|
||||||
|
// A focused bed (a ?focus= deep link, or a chrome flip mid-planting) frames
|
||||||
|
// its bed rather than the whole garden.
|
||||||
|
const f = useEditorStore.getState().focusId
|
||||||
|
const target = f != null ? latest.current.byId.get(f) : undefined
|
||||||
|
if (target) {
|
||||||
|
fitSize.current = { w: size.w, h: size.h }
|
||||||
|
focusObject(target)
|
||||||
|
} else zoomFit()
|
||||||
|
}, [size, garden.id, GW, GH, isMobile, zoomFit, focusObject])
|
||||||
|
|
||||||
|
// Wheel zooms to the cursor. Non-passive so the page doesn't scroll.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = svgRef.current
|
||||||
|
if (!el) return
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!Number.isFinite(e.deltaY)) return
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||||
|
const ns = clampScale(cur * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)
|
||||||
|
const px = e.clientX - r.left
|
||||||
|
const py = e.clientY - r.top
|
||||||
|
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, false)
|
||||||
|
}
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
return () => el.removeEventListener('wheel', onWheel)
|
||||||
|
}, [camera])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (animTimer.current != null) window.clearTimeout(animTimer.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── pointer math ────────────────────────────────────────────────────────
|
||||||
|
const world = (e: { clientX: number; clientY: number }): Point => {
|
||||||
|
const r = svgRef.current!.getBoundingClientRect()
|
||||||
|
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||||
|
return { x: (e.clientX - r.left - tx) / cur, y: (e.clientY - r.top - ty) / cur }
|
||||||
|
}
|
||||||
|
const snap = (v: number) => Math.round(v / latest.current.snapStep) * latest.current.snapStep
|
||||||
|
const thresh = (e: { pointerType?: string }) => (e.pointerType === 'touch' ? 7 : 3)
|
||||||
|
const track = (e: ReactPointerEvent) => {
|
||||||
|
const r = svgRef.current!.getBoundingClientRect()
|
||||||
|
pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
|
||||||
|
try {
|
||||||
|
svgRef.current!.setPointerCapture(e.pointerId)
|
||||||
|
} catch {
|
||||||
|
/* not all pointers capture */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pinchStart = () => {
|
||||||
|
const [a, b] = [...pts.current.values()]
|
||||||
|
drag.current = null
|
||||||
|
const { s: cur, tx, ty } = useEditorStore.getState().vp
|
||||||
|
pinch.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: cur, tx0: tx, ty0: ty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── placement ───────────────────────────────────────────────────────────
|
||||||
|
const placeObject = (kind: string, w: Point) => {
|
||||||
|
const def = kindDef(kind)
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
st.setArmedKind(null)
|
||||||
|
st.setGhost(null)
|
||||||
|
if (!def || !latest.current.canEdit) return
|
||||||
|
const n = latest.current.rendered.filter((o) => o.kind === def.kind).length + 1
|
||||||
|
createObject.mutate(
|
||||||
|
{
|
||||||
|
kind: def.kind,
|
||||||
|
shape: def.shape,
|
||||||
|
name: `${def.label} ${n}`,
|
||||||
|
xCm: snap(w.x),
|
||||||
|
yCm: snap(w.y),
|
||||||
|
widthCm: def.widthCm,
|
||||||
|
heightCm: def.heightCm,
|
||||||
|
zIndex: def.defaultZ,
|
||||||
|
plantable: def.plantable,
|
||||||
|
},
|
||||||
|
{ onSuccess: (o) => useEditorStore.getState().setSel({ type: 'object', id: o.id }) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const placePlop = (o: EditorObject, local: Point, plant: Plant, lotId: number | null) => {
|
||||||
|
if (!latest.current.canEdit || !o.plantable) return
|
||||||
|
const r = defaultPlopRadius(plant)
|
||||||
|
const c = clampPlopLocal(o, snapPlopLocal(o, local), r)
|
||||||
|
createPlanting.mutate(
|
||||||
|
{ objectId: o.id, plantId: plant.id, xCm: c.x, yCm: c.y, radiusCm: r, seedLotId: lotId ?? undefined },
|
||||||
|
{
|
||||||
|
onSuccess: (p) => {
|
||||||
|
// Desktop selects what it just placed; the phone keeps the strip's plant
|
||||||
|
// armed and the peek closed so the next tap plants again.
|
||||||
|
if (!latest.current.isMobile) useEditorStore.getState().setSel({ type: 'plop', id: p.id })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pointer handlers ────────────────────────────────────────────────────
|
||||||
|
const onCanvasDown = (e: ReactPointerEvent) => {
|
||||||
|
track(e)
|
||||||
|
if (pts.current.size === 2) return pinchStart()
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
const w = world(e)
|
||||||
|
if (st.armedKind) return placeObject(st.armedKind, w)
|
||||||
|
if (st.armedPlant) {
|
||||||
|
const o = st.focusId != null ? latest.current.byId.get(st.focusId) : objectAt(latest.current.rendered, w)
|
||||||
|
if (o?.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const objDown = (o: EditorObject) => (e: ReactPointerEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
track(e)
|
||||||
|
if (pts.current.size === 2) return pinchStart()
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
const w = world(e)
|
||||||
|
if (st.armedKind) return placeObject(st.armedKind, w)
|
||||||
|
if (st.armedPlant) {
|
||||||
|
if (o.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Dimmed siblings stay inert inside a focused bed.
|
||||||
|
if (st.focusId != null && o.id !== st.focusId) return
|
||||||
|
if (!latest.current.canEdit) {
|
||||||
|
st.setSel({ type: 'object', id: o.id })
|
||||||
|
st.setTab('plot')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drag.current = { t: 'obj', base: o, sx: w.x, sy: w.y, moved: false, th: thresh(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const plopDown = (p: EditorPlanting) => (e: ReactPointerEvent) => {
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
if (st.armedKind || st.armedPlant) return // bubbles to the bed / canvas, which places
|
||||||
|
e.stopPropagation()
|
||||||
|
const o = latest.current.byId.get(p.objectId)
|
||||||
|
if (!o) return
|
||||||
|
// Outside focus a plop is just part of its bed: grabbing it grabs the bed.
|
||||||
|
if (st.focusId !== p.objectId) return objDown(o)(e)
|
||||||
|
track(e)
|
||||||
|
if (pts.current.size === 2) return pinchStart()
|
||||||
|
if (!latest.current.canEdit) {
|
||||||
|
st.setSel({ type: 'plop', id: p.id })
|
||||||
|
st.setTab('plot')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const l = toLocal(o, world(e))
|
||||||
|
drag.current = { t: 'plop', base: p, obj: o, sx: l.x, sy: l.y, moved: false, th: thresh(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDown = (o: EditorObject, cx: number, cy: number) => (e: ReactPointerEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
track(e)
|
||||||
|
if (!latest.current.canEdit) return
|
||||||
|
// The corner opposite the dragged one stays put, in the object's own frame.
|
||||||
|
drag.current = { t: 'resize', base: o, cx, cy, opp: { x: -cx * (o.widthCm / 2), y: -cy * (o.heightCm / 2) }, moved: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasMove = (e: ReactPointerEvent) => {
|
||||||
|
const r = svgRef.current!.getBoundingClientRect()
|
||||||
|
if (pts.current.has(e.pointerId)) pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
if (pinch.current && pts.current.size >= 2) {
|
||||||
|
const [a, b] = [...pts.current.values()]
|
||||||
|
const p = pinch.current
|
||||||
|
const d1 = Math.hypot(a.x - b.x, a.y - b.y)
|
||||||
|
const cx = (a.x + b.x) / 2
|
||||||
|
const cy = (a.y + b.y) / 2
|
||||||
|
const ns = clampScale(p.s0 * (d1 / Math.max(1, p.d0)), MIN_SCALE, MAX_SCALE)
|
||||||
|
const wx = (p.cx0 - p.tx0) / p.s0
|
||||||
|
const wy = (p.cy0 - p.ty0) / p.s0
|
||||||
|
camera({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns }, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = drag.current
|
||||||
|
if (!d) {
|
||||||
|
if (st.armedKind && e.pointerType !== 'touch') {
|
||||||
|
const w = world(e)
|
||||||
|
st.setGhost({ x: snap(w.x), y: snap(w.y) })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (d.t === 'pan') {
|
||||||
|
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > d.th) d.moved = true
|
||||||
|
camera({ ...st.vp, tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy }, false)
|
||||||
|
} else if (d.t === 'obj') {
|
||||||
|
const w = world(e)
|
||||||
|
if (Math.hypot(w.x - d.sx, w.y - d.sy) > d.th / st.vp.s) d.moved = true
|
||||||
|
if (!d.moved) return
|
||||||
|
st.setLiveObject({ ...d.base, xCm: snap(d.base.xCm + w.x - d.sx), yCm: snap(d.base.yCm + w.y - d.sy) })
|
||||||
|
} else if (d.t === 'plop') {
|
||||||
|
const l = toLocal(d.obj, world(e))
|
||||||
|
if (Math.hypot(l.x - d.sx, l.y - d.sy) > d.th / st.vp.s) d.moved = true
|
||||||
|
if (!d.moved) return
|
||||||
|
const next = clampPlopLocal(
|
||||||
|
d.obj,
|
||||||
|
snapPlopLocal(d.obj, { x: d.base.xCm + l.x - d.sx, y: d.base.yCm + l.y - d.sy }),
|
||||||
|
d.base.radiusCm,
|
||||||
|
)
|
||||||
|
st.setLivePlanting({ ...d.base, xCm: next.x, yCm: next.y })
|
||||||
|
} else if (d.t === 'resize') {
|
||||||
|
const p = toLocal(d.base, world(e))
|
||||||
|
d.moved = true
|
||||||
|
let newW = Math.max(MIN_OBJECT_CM, Math.abs(p.x - d.opp.x))
|
||||||
|
let newH = Math.max(MIN_OBJECT_CM, Math.abs(p.y - d.opp.y))
|
||||||
|
if (latest.current.garden.snapToGrid) {
|
||||||
|
const g = latest.current.snapStep
|
||||||
|
newW = Math.max(g, Math.round(newW / g) * g)
|
||||||
|
newH = Math.max(g, Math.round(newH / g) * g)
|
||||||
|
}
|
||||||
|
const dragged = { x: d.opp.x + d.cx * newW, y: d.opp.y + d.cy * newH }
|
||||||
|
const c = toWorld(d.base, { x: (d.opp.x + dragged.x) / 2, y: (d.opp.y + dragged.y) / 2 })
|
||||||
|
st.setLiveObject({ ...d.base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasUp = (e: ReactPointerEvent) => {
|
||||||
|
pts.current.delete(e.pointerId)
|
||||||
|
if (pinch.current) {
|
||||||
|
if (pts.current.size < 2) pinch.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = drag.current
|
||||||
|
drag.current = null
|
||||||
|
if (!d) return
|
||||||
|
const st = useEditorStore.getState()
|
||||||
|
if (d.t === 'pan') {
|
||||||
|
if (!d.moved) st.setSel(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (d.t === 'obj') {
|
||||||
|
if (!d.moved) {
|
||||||
|
st.setSel({ type: 'object', id: d.base.id })
|
||||||
|
st.setTab('plot')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const final = st.liveObject
|
||||||
|
st.setLiveObject(null)
|
||||||
|
if (final && (final.xCm !== d.base.xCm || final.yCm !== d.base.yCm))
|
||||||
|
updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
|
||||||
|
} else if (d.t === 'plop') {
|
||||||
|
if (!d.moved) {
|
||||||
|
st.setSel({ type: 'plop', id: d.base.id })
|
||||||
|
st.setTab('plot')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const final = st.livePlanting
|
||||||
|
st.setLivePlanting(null)
|
||||||
|
if (final) updatePlanting.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
|
||||||
|
} else if (d.t === 'resize') {
|
||||||
|
const final = st.liveObject
|
||||||
|
st.setLiveObject(null)
|
||||||
|
if (final && d.moved)
|
||||||
|
updateObject.mutate({
|
||||||
|
id: final.id,
|
||||||
|
version: final.version,
|
||||||
|
xCm: final.xCm,
|
||||||
|
yCm: final.yCm,
|
||||||
|
widthCm: final.widthCm,
|
||||||
|
heightCm: final.heightCm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML5 drag-and-drop from the toolkit (desktop).
|
||||||
|
const onDragOver = (e: DragEvent) => e.preventDefault()
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const data = e.dataTransfer.getData('text/plain')
|
||||||
|
if (!data) return
|
||||||
|
const w = world(e)
|
||||||
|
const [t, id] = data.split(':')
|
||||||
|
if (t === 'kind') placeObject(id, w)
|
||||||
|
if (t === 'plant' && dropPlant.current) {
|
||||||
|
const o = objectAt(latest.current.rendered, w)
|
||||||
|
if (o?.plantable) placePlop(o, toLocal(o, w), dropPlant.current.plant, dropPlant.current.lotId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The plant being dragged from the toolkit rides along in the store (the
|
||||||
|
// dataTransfer only carries its id); the armed plant is what's being dragged.
|
||||||
|
dropPlant.current = armedPlant ? { plant: armedPlant, lotId: useEditorStore.getState().armedLotId } : null
|
||||||
|
|
||||||
|
// ── derived drawing data ───────────────────────────────────────────────
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const imperial = garden.unitPref === 'imperial'
|
||||||
|
let step = imperial ? FT : 25
|
||||||
|
const majorEvery = imperial ? 5 : 4
|
||||||
|
while (Math.max(GW, GH) / step > 400) step *= majorEvery // a 100 m field still draws a sane number of lines
|
||||||
|
const minor: string[] = []
|
||||||
|
const major: string[] = []
|
||||||
|
for (let i = 1; i * step < GW; i++) (i % majorEvery ? minor : major).push(`M${(i * step).toFixed(2)} 0V${GH}`)
|
||||||
|
for (let i = 1; i * step < GH; i++) (i % majorEvery ? minor : major).push(`M0 ${(i * step).toFixed(2)}H${GW}`)
|
||||||
|
return { minor: minor.join(''), major: major.join('') }
|
||||||
|
}, [GW, GH, garden.unitPref])
|
||||||
|
|
||||||
|
const selectedObject = sel?.type === 'object' ? (byId.get(sel.id) ?? null) : null
|
||||||
|
const showLabels = s > 0.28
|
||||||
|
const handlePx = isMobile ? 14 : 9
|
||||||
|
const cursor = armedKind || armedPlant ? 'crosshair' : 'default'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={hostRef} className="absolute inset-0">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ display: 'block', touchAction: 'none', cursor, userSelect: 'none' }}
|
||||||
|
onPointerDown={onCanvasDown}
|
||||||
|
onPointerMove={onCanvasMove}
|
||||||
|
onPointerUp={onCanvasUp}
|
||||||
|
onPointerCancel={onCanvasUp}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
role="application"
|
||||||
|
aria-label={`${garden.name} — the plan. Tap a bed to select it; double-click to plant it.`}
|
||||||
|
>
|
||||||
|
<title>{garden.name} plan</title>
|
||||||
|
<g
|
||||||
|
className={anim ? 'camera-anim' : undefined}
|
||||||
|
style={{ transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${s})`, transformOrigin: '0 0' }}
|
||||||
|
>
|
||||||
|
<rect x={0} y={0} width={GW} height={GH} rx={18} fill="var(--p-field)" stroke="var(--color-accent-2-500)" strokeWidth={3 / s} />
|
||||||
|
<path d={grid.minor} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.06} fill="none" />
|
||||||
|
<path d={grid.major} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.12} fill="none" />
|
||||||
|
|
||||||
|
{rendered.map((o) => {
|
||||||
|
const st = objectStyle(o)
|
||||||
|
const isSel = sel?.type === 'object' && sel.id === o.id
|
||||||
|
const dim = focusId != null && o.id !== focusId
|
||||||
|
const common = {
|
||||||
|
fill: st.fill,
|
||||||
|
stroke: isSel ? 'var(--color-accent)' : st.stroke,
|
||||||
|
strokeWidth: (isSel ? 3.5 : 2.5) / s,
|
||||||
|
strokeDasharray: st.dash,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={o.id}
|
||||||
|
data-object-id={o.id}
|
||||||
|
transform={objectTransform(o)}
|
||||||
|
opacity={dim ? DIM_OBJECT : 1}
|
||||||
|
style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }}
|
||||||
|
onPointerDown={objDown(o)}
|
||||||
|
onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined}
|
||||||
|
>
|
||||||
|
{o.shape === 'circle' ? (
|
||||||
|
<ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} />
|
||||||
|
) : (
|
||||||
|
<rect x={-o.widthCm / 2} y={-o.heightCm / 2} width={o.widthCm} height={o.heightCm} rx={rectRadius(o.widthCm)} {...common} />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{renderedPlops.map((p) => {
|
||||||
|
const o = byId.get(p.objectId)
|
||||||
|
if (!o) return null
|
||||||
|
const plant = plantsById.get(p.plantId)
|
||||||
|
const w = toWorld(o, { x: p.xCm, y: p.yCm })
|
||||||
|
const isSel = sel?.type === 'plop' && sel.id === p.id
|
||||||
|
const inFocus = focusId === p.objectId
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={p.id}
|
||||||
|
data-plop-id={p.id}
|
||||||
|
transform={`translate(${w.x} ${w.y})`}
|
||||||
|
opacity={focusId != null && !inFocus ? DIM_PLOP : 0.94}
|
||||||
|
style={{ cursor: inFocus ? (canEdit ? 'grab' : 'pointer') : 'inherit' }}
|
||||||
|
onPointerDown={plopDown(p)}
|
||||||
|
>
|
||||||
|
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
|
||||||
|
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
|
||||||
|
<circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Labels — semantic zoom. pointer-events none so they never catch a tap. */}
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
|
{showLabels &&
|
||||||
|
rendered.map((o) => {
|
||||||
|
if (!o.name || Math.max(o.widthCm, o.heightCm) * s <= 54 || (focusId != null && focusId !== o.id)) return null
|
||||||
|
const bh = rotatedHalfHeight(o)
|
||||||
|
return o.plantable ? (
|
||||||
|
<text
|
||||||
|
key={`ol${o.id}`}
|
||||||
|
x={o.xCm}
|
||||||
|
y={o.yCm - bh - 9 / s}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={13 / s}
|
||||||
|
fill="var(--p-ink-soft)"
|
||||||
|
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' }}
|
||||||
|
>
|
||||||
|
{o.name}
|
||||||
|
</text>
|
||||||
|
) : (
|
||||||
|
<text
|
||||||
|
key={`ol${o.id}`}
|
||||||
|
x={o.xCm}
|
||||||
|
y={o.yCm}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize={13 / s}
|
||||||
|
fill="var(--p-ink-mute)"
|
||||||
|
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' }}
|
||||||
|
>
|
||||||
|
{o.name}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{renderedPlops.map((p) => {
|
||||||
|
const o = byId.get(p.objectId)
|
||||||
|
if (!o || (focusId != null && p.objectId !== focusId)) return null
|
||||||
|
const plant = plantsById.get(p.plantId)
|
||||||
|
const r = p.radiusCm
|
||||||
|
if (r * s < 9) return null
|
||||||
|
const w = toWorld(o, { x: p.xCm, y: p.yCm })
|
||||||
|
return (
|
||||||
|
<g key={`pl${p.id}`}>
|
||||||
|
<text
|
||||||
|
x={w.x}
|
||||||
|
y={w.y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize={r * 1.05}
|
||||||
|
fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
|
||||||
|
style={{ fontFamily: 'var(--font-heading)' }}
|
||||||
|
>
|
||||||
|
{letters.get(p.plantId) ?? '?'}
|
||||||
|
</text>
|
||||||
|
{r * s >= 34 && plant && (
|
||||||
|
<text
|
||||||
|
x={w.x}
|
||||||
|
y={w.y + r + 13 / s}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={11 / s}
|
||||||
|
fill="var(--p-ink-strong)"
|
||||||
|
style={{ fontFamily: 'var(--font-body)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{plant.name}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{selectedObject && (
|
||||||
|
<text
|
||||||
|
x={selectedObject.xCm}
|
||||||
|
y={selectedObject.yCm + rotatedHalfHeight(selectedObject) + 22 / s}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={12.5 / s}
|
||||||
|
fill="var(--color-accent-700)"
|
||||||
|
style={{ fontFamily: 'var(--font-body)', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{formatSize(selectedObject.widthCm, selectedObject.heightCm, garden.unitPref)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Selection: a dashed accent outline offset from the object, and —
|
||||||
|
for editors — corner handles to resize it. */}
|
||||||
|
{selectedObject && (
|
||||||
|
<g transform={objectTransform(selectedObject)}>
|
||||||
|
{selectedObject.shape === 'circle' ? (
|
||||||
|
<ellipse
|
||||||
|
rx={selectedObject.widthCm / 2 + 7 / s}
|
||||||
|
ry={selectedObject.heightCm / 2 + 7 / s}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={1.8 / s}
|
||||||
|
strokeDasharray={`${8 / s} ${6 / s}`}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<rect
|
||||||
|
x={-selectedObject.widthCm / 2 - 7 / s}
|
||||||
|
y={-selectedObject.heightCm / 2 - 7 / s}
|
||||||
|
width={selectedObject.widthCm + 14 / s}
|
||||||
|
height={selectedObject.heightCm + 14 / s}
|
||||||
|
rx={16 / s}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={1.8 / s}
|
||||||
|
strokeDasharray={`${8 / s} ${6 / s}`}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canEdit &&
|
||||||
|
!liveObject &&
|
||||||
|
(
|
||||||
|
[
|
||||||
|
[-1, -1],
|
||||||
|
[1, -1],
|
||||||
|
[1, 1],
|
||||||
|
[-1, 1],
|
||||||
|
] as const
|
||||||
|
).map(([cx, cy]) => (
|
||||||
|
<rect
|
||||||
|
key={`${cx},${cy}`}
|
||||||
|
x={cx * (selectedObject.widthCm / 2 + 7 / s) - handlePx / 2 / s}
|
||||||
|
y={cy * (selectedObject.heightCm / 2 + 7 / s) - handlePx / 2 / s}
|
||||||
|
width={handlePx / s}
|
||||||
|
height={handlePx / s}
|
||||||
|
rx={2 / s}
|
||||||
|
fill="var(--color-neutral-100)"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={1.5 / s}
|
||||||
|
style={{ cursor: cx * cy > 0 ? 'nwse-resize' : 'nesw-resize' }}
|
||||||
|
onPointerDown={handleDown(selectedObject, cx, cy)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ghost && armedKind && (() => {
|
||||||
|
const def = kindDef(armedKind)
|
||||||
|
if (!def) return null
|
||||||
|
const st = objectStyle({ kind: def.kind })
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${ghost.x} ${ghost.y})`} opacity={0.55} style={{ pointerEvents: 'none' }}>
|
||||||
|
{def.shape === 'circle' ? (
|
||||||
|
<circle r={def.widthCm / 2} fill={st.fill} stroke="var(--color-accent)" strokeWidth={2 / s} strokeDasharray={`${7 / s} ${5 / s}`} />
|
||||||
|
) : (
|
||||||
|
<rect
|
||||||
|
x={-def.widthCm / 2}
|
||||||
|
y={-def.heightCm / 2}
|
||||||
|
width={def.widthCm}
|
||||||
|
height={def.heightCm}
|
||||||
|
rx={12}
|
||||||
|
fill={st.fill}
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={2 / s}
|
||||||
|
strokeDasharray={`${7 / s} ${5 / s}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
||||||
import { Alert } from '@/components/ui/Alert'
|
|
||||||
import { errorMessage } from '@/lib/api'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { TextArea } from '@/components/ui/TextArea'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import {
|
|
||||||
describeStep,
|
|
||||||
streamChat,
|
|
||||||
useAgentHistory,
|
|
||||||
useAgentRefresh,
|
|
||||||
useClearAgentHistory,
|
|
||||||
type AgentStep,
|
|
||||||
type AgentTurn,
|
|
||||||
} from '@/lib/agent'
|
|
||||||
import { useUndo } from '@/lib/history'
|
|
||||||
import { UndoButton } from './UndoButton'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Talk to the garden assistant, in the editor beside the canvas.
|
|
||||||
*
|
|
||||||
* Here rather than on its own page because watching the garden change as the
|
|
||||||
* agent works IS the confirmation — which is what makes acting without asking
|
|
||||||
* first tolerable. It also means the agent never has to guess which garden you
|
|
||||||
* mean.
|
|
||||||
*/
|
|
||||||
export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
|
||||||
const history = useAgentHistory(gardenId, true)
|
|
||||||
const clear = useClearAgentHistory(gardenId)
|
|
||||||
const refresh = useAgentRefresh(gardenId)
|
|
||||||
const undo = useUndo(gardenId)
|
|
||||||
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
// The turn in flight: what we sent, the steps so far, and how it ended.
|
|
||||||
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [warning, setWarning] = useState<string | null>(null)
|
|
||||||
const abort = useRef<AbortController | null>(null)
|
|
||||||
const bottom = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
|
|
||||||
// rail to the inspector, so aborting here would mean clicking the canvas
|
|
||||||
// mid-turn silently killed the turn — and the canvas is exactly what you're
|
|
||||||
// meant to be watching. The request continues, the exchange is persisted
|
|
||||||
// server-side, and coming back to this tab shows it. Only Stop aborts, and
|
|
||||||
// even that only stops us READING: the turn keeps running server-side, which
|
|
||||||
// is why its work still lands in History either way.
|
|
||||||
|
|
||||||
// Follow the conversation as it grows, including mid-turn as steps arrive.
|
|
||||||
useEffect(() => {
|
|
||||||
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
|
||||||
}, [history.data, pending])
|
|
||||||
|
|
||||||
const send = () => {
|
|
||||||
const message = input.trim()
|
|
||||||
if (!message || pending) return
|
|
||||||
setInput('')
|
|
||||||
setError(null)
|
|
||||||
setWarning(null)
|
|
||||||
setPending({ message, steps: [] })
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
abort.current = controller
|
|
||||||
|
|
||||||
void streamChat(
|
|
||||||
gardenId,
|
|
||||||
message,
|
|
||||||
{
|
|
||||||
onStep: (step) => {
|
|
||||||
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
|
||||||
// The canvas updating under the conversation is the whole point of
|
|
||||||
// putting the chat here, so refresh it as each step lands — but only
|
|
||||||
// it: nothing else can have changed until the turn commits.
|
|
||||||
refresh.canvas()
|
|
||||||
},
|
|
||||||
onDone: (turn: AgentTurn) => {
|
|
||||||
setPending(null)
|
|
||||||
refresh.everything()
|
|
||||||
if (turn.truncated) {
|
|
||||||
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onWarning: setWarning,
|
|
||||||
onError: (message) => {
|
|
||||||
setPending(null)
|
|
||||||
setError(message)
|
|
||||||
// Something may still have landed before it failed.
|
|
||||||
refresh.everything()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
controller.signal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = history.data ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h2 className="text-sm font-semibold text-fg">Assistant</h2>
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
clear.mutate(undefined, {
|
|
||||||
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={clear.isPending}
|
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
||||||
>
|
|
||||||
Start over
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!canEdit && (
|
|
||||||
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">
|
|
||||||
You can only view this garden, so the assistant can't change anything in it.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
|
||||||
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
|
||||||
{/* A failed load rendering as an empty thread would look like the
|
|
||||||
conversation had been lost, which is a much worse thing to believe. */}
|
|
||||||
{history.isError && (
|
|
||||||
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{history.isSuccess && messages.length === 0 && !pending && (
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
|
|
||||||
north half of the west bed with beans”, “what's in here?”. Everything it does lands as one change you
|
|
||||||
can undo.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((m) => (
|
|
||||||
<Bubble key={m.id} role={m.role} body={m.body}>
|
|
||||||
{/* Undo on the turn itself, so the common case never involves
|
|
||||||
opening the History panel. Same hook #49 uses, not a second
|
|
||||||
implementation. */}
|
|
||||||
{m.role === 'assistant' && m.changeSetId != null && canEdit && (
|
|
||||||
<UndoButton changeSet={{ id: m.changeSetId }} undo={undo} className="mt-1 items-start" />
|
|
||||||
)}
|
|
||||||
</Bubble>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pending && (
|
|
||||||
<>
|
|
||||||
<Bubble role="user" body={pending.message} />
|
|
||||||
<div className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
|
||||||
<ol className="flex flex-col gap-0.5">
|
|
||||||
{pending.steps.map((s) => (
|
|
||||||
<li key={s.index} className="text-xs text-muted">
|
|
||||||
{describeStep(s)}…
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
|
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
|
||||||
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{warning && <Alert tone="info">{warning}</Alert>}
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
<div ref={bottom} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-2">
|
|
||||||
<TextArea
|
|
||||||
label="Message"
|
|
||||||
name="agentMessage"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Change the garlic bed to cucumbers this year"
|
|
||||||
value={input}
|
|
||||||
disabled={!!pending}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Enter sends, Shift+Enter breaks the line — the convention every
|
|
||||||
// chat box uses, and typing a newline by accident mid-thought is a
|
|
||||||
// worse failure than the reverse.
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
send()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{pending && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
abort.current?.abort()
|
|
||||||
setPending(null)
|
|
||||||
refresh.everything()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button className="px-3 py-1.5 text-sm" disabled={!!pending || input.trim() === ''} onClick={send}>
|
|
||||||
{pending ? 'Working…' : 'Send'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Bubble({
|
|
||||||
role,
|
|
||||||
body,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
body: string
|
|
||||||
children?: ReactNode
|
|
||||||
}) {
|
|
||||||
const mine = role === 'user'
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
|
|
||||||
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||||
|
import { useClearObject } from '@/lib/objects'
|
||||||
|
|
||||||
|
/** Confirm clearing every active plop from a bed — a soft remove (the rows are
|
||||||
|
* kept with removed_at, so history and past seasons survive), as one step. */
|
||||||
|
export function ClearBedDialog({
|
||||||
|
objectId,
|
||||||
|
objectName,
|
||||||
|
plopCount,
|
||||||
|
gardenId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
objectId: number
|
||||||
|
objectName: string
|
||||||
|
plopCount: number
|
||||||
|
gardenId: number
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const clear = useClearObject(gardenId)
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Clear ${objectName}?`}
|
||||||
|
confirmLabel="Clear it"
|
||||||
|
busyLabel="Clearing…"
|
||||||
|
confirmDisabled={plopCount === 0}
|
||||||
|
errorFallback="Could not clear the bed."
|
||||||
|
onConfirm={() => clear.mutateAsync(objectId)}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
Pull all <span className="font-semibold text-text">{plopCount}</span> {plopCount === 1 ? 'planting' : 'plantings'} out
|
||||||
|
of <span className="font-semibold text-text">{objectName}</span>. They stay in the journal and past seasons, and this
|
||||||
|
is one undoable step.
|
||||||
|
</ConfirmDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||