Merge pull request 'Replace the UI with the Organic design handoff' (#124) from feat/organic-ui into main
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
This commit was merged in pull request #124.
This commit is contained in:
@@ -72,6 +72,42 @@ handler, put it in the service instead.
|
||||
Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
||||
`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
|
||||
|
||||
- **Everything is centimeters**, stored as SQLite `REAL`. Imperial is a display
|
||||
|
||||
@@ -43,13 +43,10 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
||||
## 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.
|
||||
- **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.
|
||||
- **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):
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
## API
|
||||
|
||||
@@ -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)
|
||||
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,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,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
|
||||
@@ -125,13 +123,17 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
|
||||
|
||||
## 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`.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -151,6 +153,6 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
||||
1. Plop `count` derived from area ÷ spacing², explicit override allowed.
|
||||
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.
|
||||
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).
|
||||
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`).
|
||||
|
||||
## 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
|
||||
|
||||
CI (`.gitea/workflows/build-image.yml`) builds the single-binary image and pushes it to the Gitea registry on every branch push:
|
||||
|
||||
@@ -39,6 +39,27 @@ type settingsResponse struct {
|
||||
// Effective is the configuration actually in force after layering settings
|
||||
// over the environment.
|
||||
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 {
|
||||
@@ -79,6 +100,13 @@ func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings)
|
||||
VisionModel: vis.Model,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,25 @@ func TestSettingsInheritFromEnv(t *testing.T) {
|
||||
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
||||
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
|
||||
|
||||
+54
-1
@@ -4,9 +4,62 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<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>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
-19
@@ -10,7 +10,6 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -1757,24 +1756,6 @@
|
||||
"integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@use-gesture/core": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@use-gesture/react": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@use-gesture/core": "10.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
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'
|
||||
|
||||
/** The router's catch-all for unknown paths. */
|
||||
export function NotFound() {
|
||||
usePageTitle('Not found')
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-5xl" aria-hidden>
|
||||
🌱
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold text-fg">Page not found</h1>
|
||||
<p className="text-sm text-muted">That page doesn't exist — the link may be wrong or the page moved.</p>
|
||||
<Link to="/gardens" className={buttonClasses('primary')}>
|
||||
Back to gardens
|
||||
</Link>
|
||||
<div className="min-h-full">
|
||||
<Nav />
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 text-center">
|
||||
<Icon name="sprout" size={40} className="text-accent-2-500" />
|
||||
<h3>Nothing growing here</h3>
|
||||
<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="btn btn-primary mt-2 no-underline">
|
||||
Back to the gardens
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { errorMessage } from '@/lib/api'
|
||||
export function RouteError({ error }: { error: Error }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<h1 className="text-lg font-semibold text-fg">Something went wrong</h1>
|
||||
<p className="text-sm text-muted">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button onClick={() => router.invalidate()}>Try again</Button>
|
||||
<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">
|
||||
<h3>Something went wrong</h3>
|
||||
<p className="text-[13px] text-ink-soft">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button variant="primary" className="mt-2" onClick={() => router.invalidate()}>
|
||||
Try again
|
||||
</Button>
|
||||
</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,62 @@
|
||||
import { 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, type Garden } from '@/lib/gardens'
|
||||
import { 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> — <next year>", 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 base = parsePlanName(garden.name)?.base ?? garden.name
|
||||
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const [name, setName] = useState(() => planNameFor(base, year))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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) => 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>
|
||||
{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,24 @@
|
||||
import { useMemo } from 'react'
|
||||
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 { formatDimensions } from '@/lib/units'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { useGardenFull } from '@/lib/objects'
|
||||
import { 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
|
||||
* role — the owner gets Share / Copy / Edit / Delete; a recipient sees a
|
||||
* "shared · role" badge and a Leave action (garden metadata edit, sharing and
|
||||
* copying are owner-only).
|
||||
* Ownership is the authoritative ownerId==me check, not the my_role hint.
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name + an
|
||||
* optional `plan` tag, size, a counts line, who it's shared with, and a footer
|
||||
* of Open + share / copy / edit / delete. A garden shared WITH you shows its
|
||||
* role and a leave action instead of the owner's tools.
|
||||
*/
|
||||
export function GardenCard({
|
||||
garden,
|
||||
@@ -28,47 +38,75 @@ export function GardenCard({
|
||||
onLeave: () => void
|
||||
}) {
|
||||
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||
const full = useGardenFull(garden.id)
|
||||
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||
const planYear = planYearOf(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 (
|
||||
<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
|
||||
to="/gardens/$gardenId"
|
||||
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">
|
||||
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
|
||||
{!owner && garden.myRole && (
|
||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
||||
shared · {garden.myRole}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
|
||||
{garden.name}
|
||||
</span>
|
||||
{planYear != null && <Tag tone="accent">plan</Tag>}
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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,
|
||||
dimensionInputMode,
|
||||
dimensionUnitLabel,
|
||||
formatCm,
|
||||
formatDimensionInput,
|
||||
isValidDimensionCm,
|
||||
MIN_GARDEN_GRID_CM,
|
||||
parseDimension,
|
||||
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; switching units converts what's typed so
|
||||
* the physical size holds. 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(() =>
|
||||
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'),
|
||||
)
|
||||
const [height, setHeight] = useState(() =>
|
||||
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
|
||||
)
|
||||
const [gridSize, setGridSize] = useState(() =>
|
||||
formatDimensionInput(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) {
|
||||
const convert = (s: string) => {
|
||||
const cm = parseDimension(s, unit)
|
||||
return cm === null ? s : formatDimensionInput(cm, next)
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
setGridSize(convert(gridSize))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
const gridSizeCm = parseDimension(gridSize, unit)
|
||||
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 = 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 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 }
|
||||
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(formatDimensionInput(current.widthCm, current.unitPref))
|
||||
setHeight(formatDimensionInput(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 — 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}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label={`Depth (${u})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
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}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
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,79 @@
|
||||
import { useMemo } from 'react'
|
||||
import { localToWorld } from '@/lib/geometry'
|
||||
import type { FullGarden } from '@/lib/objects'
|
||||
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) ?? '#97a97c'} />
|
||||
})}
|
||||
</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,236 +1,24 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Suspense } from 'react'
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
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
|
||||
* section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint
|
||||
* (`md:`) restores the inline top nav. The editor is a full-screen context, so it
|
||||
* owns the bottom of the screen — the app bottom bar hides there (the brand link
|
||||
* is the way back to the gardens list), leaving exactly one bottom bar per route.
|
||||
* The root layout is deliberately empty chrome: every page renders its own nav
|
||||
* (the editor wants a different one on a phone than Gardens does), so the shell
|
||||
* only provides the Suspense boundary for the code-split routes and the toast
|
||||
* stack. The theme is applied to <html> by the bootstrap in index.html.
|
||||
*/
|
||||
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
|
||||
// On a phone the editor is a full-screen canvas, so the global top bar is pure
|
||||
// chrome above the garden — hide it and let the editor's own strip carry the
|
||||
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
|
||||
// public view, which has no strip of its own to fall back on.
|
||||
const hideHeaderOnMobile = inEditor
|
||||
|
||||
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
|
||||
hideHeaderOnMobile && 'hidden md:block',
|
||||
)}
|
||||
>
|
||||
{/* The bar matches the content width below: constrained on reading pages,
|
||||
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
|
||||
<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} />}
|
||||
|
||||
<>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Account control: a compact button that toggles a small sign-out popover. On
|
||||
* desktop the display name shows inline; on mobile it lives inside the popover.
|
||||
* Exported so the editor's mobile strip can carry it — the global header that
|
||||
* normally hosts it is hidden there (see hideHeaderOnMobile). */
|
||||
export 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>
|
||||
)
|
||||
export function PageFallback({ label = 'Loading…' }: { label?: string }) {
|
||||
return <p className="p-7 text-[13px] font-semibold text-ink-mute">{label}</p>
|
||||
}
|
||||
|
||||
@@ -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,36 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** A plant marker off the canvas: a solid circle in the plant's color with its
|
||||
* 1–2 letter monogram in the display face. 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 text-paper', className)}
|
||||
style={{ width: size, height: size, background: 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 { PlantIcon } from './PlantIcon'
|
||||
import { LotStateChip, SeedLotList } from './SeedLotList'
|
||||
import { SourceLink } from './SourceLink'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import { cn } from '@/lib/cn'
|
||||
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 { Monogram } from './Monogram'
|
||||
|
||||
/**
|
||||
* One catalog plant as a card: icon tile tinted with the plant's color, name,
|
||||
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
|
||||
* offer only "Duplicate" (they're read-only); own plants add Edit/Delete.
|
||||
* One catalog plant as a card: monogram, name, "Category · spacing · days", a
|
||||
* built-in tag for seeded plants, and a seed-lot summary. Clicking expands it
|
||||
* (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({
|
||||
plant,
|
||||
letters,
|
||||
unit,
|
||||
lots,
|
||||
onEdit,
|
||||
@@ -24,9 +27,8 @@ export function PlantCard({
|
||||
onDeleteLot,
|
||||
}: {
|
||||
plant: Plant
|
||||
letters: string
|
||||
unit: UnitPref
|
||||
/** This plant's purchases. A lot may reference a built-in, so even a built-in
|
||||
* card can carry seed. */
|
||||
lots: SeedLot[]
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
@@ -36,80 +38,121 @@ export function PlantCard({
|
||||
onDeleteLot: (lot: SeedLot) => void
|
||||
}) {
|
||||
const builtin = isBuiltin(plant)
|
||||
const [showLots, setShowLots] = useState(false)
|
||||
const summary = summarizeLots(lots)
|
||||
const [open, setOpen] = useState(false)
|
||||
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 (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-11 w-11 rounded-lg text-2xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-fg">{plant.name}</h3>
|
||||
{builtin && (
|
||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
||||
Built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
||||
</p>
|
||||
{(plant.vendor || plant.sourceUrl) && (
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||
{plant.vendor && <span>{plant.vendor}</span>}
|
||||
<SourceLink url={plant.sourceUrl} />
|
||||
</p>
|
||||
)}
|
||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
||||
<div
|
||||
className={cn('panel relative transition-shadow hover:[box-shadow:var(--shadow-md)]', open && 'border-accent-400')}
|
||||
>
|
||||
{/* The whole face is the toggle; the expanded area below has its own controls. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="block w-full cursor-pointer rounded-[inherit] px-[18px] py-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Monogram color={plant.color} letters={letters} />
|
||||
<span className="flex min-w-0 flex-col gap-px">
|
||||
<span className="truncate font-heading text-[16.5px]" title={plant.name}>
|
||||
{plant.name}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-ink-mute">{sub.join(' · ')}</span>
|
||||
</span>
|
||||
{builtin && <Tag tone="neutral" className="ml-auto flex-none">built-in</Tag>}
|
||||
</div>
|
||||
<span
|
||||
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-black/10 dark:border-white/10"
|
||||
style={{ backgroundColor: plant.color }}
|
||||
title={plant.color}
|
||||
/>
|
||||
</div>
|
||||
{showLots && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
|
||||
<div className="mt-2.5 text-[12.5px] text-ink-soft">{lotText}</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="-mt-1.5 flex flex-col gap-2 px-[18px] pb-4">
|
||||
{lots.map((lot) => (
|
||||
<LotCard key={lot.id} lot={lot} onEdit={() => onEditLot(lot)} onDelete={() => onDeleteLot(lot)} />
|
||||
))}
|
||||
{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 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 className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{/* The seed count sits with the actions rather than in the body: it's
|
||||
what you scan for down a list of twenty packets, so it wants a fixed
|
||||
place on the card. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLots((v) => !v)}
|
||||
className={`${cardActionClass} mr-auto gap-1.5`}
|
||||
aria-expanded={showLots}
|
||||
>
|
||||
{lots.length === 0 ? (
|
||||
<span className="text-muted">No seed</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="tabular-nums">
|
||||
{formatQuantity(summary.remaining)}
|
||||
{summary.unit ? ` ${summary.unit}` : ''} left
|
||||
</span>
|
||||
<LotStateChip state={summary.state} />
|
||||
</>
|
||||
)}
|
||||
/** What's left of a lot, said plainly: "50 cloves · 14 left" — or how far over
|
||||
* it was planted, which is a real situation worth showing rather than clamping. */
|
||||
export function describeLot(lot: SeedLot): string {
|
||||
const parts = [`${formatQuantity(lot.quantity)} ${lot.unit}`]
|
||||
const state = lotState(lot)
|
||||
if (state === 'over') parts.push(`${formatQuantity(-lot.remaining)} over what was bought`)
|
||||
else if (state === 'empty') parts.push('none left')
|
||||
else if (state !== 'unknown') parts.push(`${formatQuantity(lot.remaining)} left`)
|
||||
if (lot.germinationPct != null) parts.push(`${lot.germinationPct}% germination`)
|
||||
const cost = formatCost(lot.costCents)
|
||||
if (cost) parts.push(cost)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function LotCard({ lot, onEdit, onDelete }: { lot: SeedLot; onEdit: () => void; onDelete: () => void }) {
|
||||
const state = lotState(lot)
|
||||
return (
|
||||
<div className="rounded-md border border-divider bg-bg px-[13px] py-2.5">
|
||||
<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 type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||
Duplicate
|
||||
<button type="button" className="btn btn-ghost px-2 py-1 text-xs text-accent-700" onClick={onDelete}>
|
||||
Retire
|
||||
</button>
|
||||
{!builtin && (
|
||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{!builtin && (
|
||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
+60
-107
@@ -1,10 +1,8 @@
|
||||
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 { Dialog } from '@/components/ui/Dialog'
|
||||
import { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
@@ -18,28 +16,20 @@ import {
|
||||
} from '@/lib/plants'
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
|
||||
|
||||
const DEFAULT_COLOR = '#4a7c3f'
|
||||
// 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] }))
|
||||
|
||||
/** 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.
|
||||
* "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. A 409 rebases
|
||||
* onto the server's current row.
|
||||
*/
|
||||
export function PlantFormModal({
|
||||
export function PlantDialog({
|
||||
plant,
|
||||
template,
|
||||
unit,
|
||||
@@ -59,12 +49,12 @@ export function PlantFormModal({
|
||||
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 [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
|
||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||
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)
|
||||
@@ -74,13 +64,8 @@ export function PlantFormModal({
|
||||
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.')
|
||||
setFormError('Give the plant a name.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
@@ -90,76 +75,64 @@ export function PlantFormModal({
|
||||
}
|
||||
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.')
|
||||
setFormError('Days to maturity is a whole number of days, or 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(),
|
||||
icon: source?.icon || DEFAULT_ICON,
|
||||
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)
|
||||
}
|
||||
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) : '')
|
||||
setVendor(current.vendor)
|
||||
setSourceUrl(current.sourceUrl)
|
||||
setNotes(current.notes)
|
||||
setConflict('This plant changed elsewhere. The latest values are shown — review and save again.')
|
||||
setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.'))
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit plant' : 'New plant'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
<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 value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
<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})`}
|
||||
@@ -171,31 +144,12 @@ export function PlantFormModal({
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Field label="Marker color">
|
||||
<ColorSwatches value={color} onChange={setColor} />
|
||||
</Field>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
@@ -207,40 +161,39 @@ export function PlantFormModal({
|
||||
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)} />
|
||||
{!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="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={pending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create plant'}
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Add it'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
import { useRef, useState, type ChangeEvent, 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 { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
lotDefaults,
|
||||
newPlantDefaults,
|
||||
useCreateFromPacket,
|
||||
useScanPacket,
|
||||
type PacketProposal,
|
||||
} from '@/lib/seedPacket'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
PLANT_CATEGORIES,
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
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 variety"; a number selects that existing candidate plant.
|
||||
type Selection = number | 'new'
|
||||
|
||||
/**
|
||||
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
|
||||
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
|
||||
* review never commits blind — the model can misread, so every committed field is
|
||||
* editable and the human picks "this is an existing plant" vs "a new variety".
|
||||
*
|
||||
* Only offered where `capabilities.vision` is on (the caller gates the entry
|
||||
* point), so a scan should always be possible; a 503 is still handled in case the
|
||||
* model is torn down between the capabilities poll and the upload.
|
||||
*/
|
||||
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||
const scan = useScanPacket()
|
||||
const create = useCreateFromPacket()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
|
||||
// dialog is never a trap the user can only escape by reloading the page.
|
||||
const scanAbort = useRef<AbortController | null>(null)
|
||||
|
||||
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Review-phase fields, seeded from the proposal when a scan lands.
|
||||
const [selection, setSelection] = useState<Selection>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||
const [spacing, setSpacing] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
|
||||
// creating one) and the lot.
|
||||
const [vendor, setVendor] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||
const [sku, setSku] = useState('')
|
||||
const [lotCode, setLotCode] = useState('')
|
||||
const [packedForYear, setPackedForYear] = useState('')
|
||||
const [cost, setCost] = useState('')
|
||||
|
||||
const unitLabel = spacingUnitLabel(unit)
|
||||
const busy = scan.isPending || create.isPending
|
||||
|
||||
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
// Reset the input so re-picking the same file fires change again (e.g. after
|
||||
// an error, retrying the same photo).
|
||||
e.target.value = ''
|
||||
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)
|
||||
// Default to the top candidate when there is one — the likely case is
|
||||
// the packet is a variety already in the catalog — else create a new one.
|
||||
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)
|
||||
setSku(lot.sku)
|
||||
setLotCode(lot.lotCode)
|
||||
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
// Cost isn't on a packet, so it's the one field not reseeded from the
|
||||
// proposal; clear it so a value typed before a Rescan doesn't linger.
|
||||
setCost('')
|
||||
},
|
||||
onError: (err) => {
|
||||
// An aborted scan is a user cancel, not a failure — and Cancel also
|
||||
// closes the dialog, so there's nothing to report.
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!proposal) return
|
||||
setError(null)
|
||||
|
||||
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) {
|
||||
setError('Quantity must be a number, or left blank.')
|
||||
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)
|
||||
}
|
||||
|
||||
const lot = {
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: '',
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: null,
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit: lotUnit,
|
||||
costCents,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
let newPlant: PlantInput | undefined
|
||||
let plantId: number | undefined
|
||||
if (selection === 'new') {
|
||||
if (!name.trim()) {
|
||||
setError('Name the new variety, or pick an existing plant above.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
||||
setError(`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) {
|
||||
setError('Days to maturity must be a whole number of days, or left blank.')
|
||||
return
|
||||
}
|
||||
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.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
|
||||
{!proposal ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Take a photo of the front of a seed packet and pansy reads the details off it — you review and
|
||||
confirm before anything is saved.
|
||||
</p>
|
||||
|
||||
{/* A hidden input is triggered by the buttons below. `capture` hints a
|
||||
phone to open the camera; on desktop it's ignored and both buttons
|
||||
open a file chooser. */}
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{scan.isPending ? (
|
||||
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
|
||||
<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 type="button" onClick={() => fileInput.current?.click()}>
|
||||
Take or choose a photo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{/* Not disabled while scanning — this is the way out of a slow scan.
|
||||
Aborting a settled/absent request is a harmless no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
scanAbort.current?.abort()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onConfirm} className="flex flex-col gap-4">
|
||||
<ReadFields proposal={proposal} unit={unit} />
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium text-fg">This packet is…</legend>
|
||||
{proposal.candidates.map((c) => (
|
||||
<label
|
||||
key={c.plant.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === c.plant.id}
|
||||
onChange={() => setSelection(c.plant.id)}
|
||||
/>
|
||||
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
|
||||
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
|
||||
<span className="text-xs text-muted">{c.reason}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === 'new'}
|
||||
onChange={() => setSelection('new')}
|
||||
/>
|
||||
<span className="flex-1 font-medium text-fg">
|
||||
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{selection === 'new' && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint="You can set an icon and color later from the plant card."
|
||||
/>
|
||||
<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>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The seed lot — what you bought — recorded against whichever plant. */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-fg">Seed lot</p>
|
||||
<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={lotUnit}
|
||||
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// Clear the review-phase error too, or it would show on the
|
||||
// capture screen we're returning to.
|
||||
setError(null)
|
||||
setProposal(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact read-only summary of what the model pulled off the packet, so the
|
||||
* user can see the extraction at a glance while they confirm. Only fields that
|
||||
* came back are shown. */
|
||||
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
|
||||
const p = proposal.packet
|
||||
const rows: [string, string][] = []
|
||||
if (p.species) rows.push(['Species', p.species])
|
||||
if (p.variety) rows.push(['Variety', p.variety])
|
||||
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
|
||||
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
|
||||
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="text-fg">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
/** A small inline notice for form errors and status messages. */
|
||||
export function Alert({ tone = 'error', children }: { tone?: AlertTone; children: ReactNode }) {
|
||||
/** An inline notice. Error = terracotta wash; info = the sage banner the editor
|
||||
* uses for season notes. */
|
||||
export function Alert({ tone = 'error', className, children }: { tone?: AlertTone; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role={tone === 'error' ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-2 text-sm',
|
||||
tone === 'error' && 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
tone === 'info' && 'border-border bg-border/30 text-muted',
|
||||
'rounded-md px-3.5 py-2 text-[13px] font-semibold leading-relaxed',
|
||||
tone === 'error' && 'bg-accent-100 text-accent-800',
|
||||
tone === 'info' && 'bg-accent-2-200 text-accent-2-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,25 +1,84 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
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. */
|
||||
export function buttonClasses(variant: ButtonVariant = 'primary', className?: string) {
|
||||
/** The pill button classes, exported so anchor "buttons" (the OIDC link, card
|
||||
* Open links) match real buttons exactly. */
|
||||
export function buttonClass(variant: ButtonVariant = 'secondary', className?: string): string {
|
||||
return cn(
|
||||
'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
variant === 'primary' && 'bg-accent text-accent-contrast hover:bg-accent-strong',
|
||||
variant === 'ghost' && 'border border-border text-fg hover:bg-border/50',
|
||||
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500/40',
|
||||
'btn',
|
||||
variant === 'primary' && 'btn-primary',
|
||||
variant === 'secondary' && 'btn-secondary',
|
||||
variant === 'ghost' && 'btn-ghost',
|
||||
variant === 'plain' && 'btn-soft',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
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) {
|
||||
return <button type={type} className={buttonClasses(variant, className)} {...props} />
|
||||
export function Button({
|
||||
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 { create } from 'zustand'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type ToastTone = 'info' | 'error'
|
||||
interface Toast {
|
||||
@@ -17,10 +18,8 @@ interface ToastState {
|
||||
|
||||
let nextId = 1
|
||||
|
||||
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||
// happened — is always visible.
|
||||
// Error toasts don't auto-dismiss (#85), so a burst of failures could grow the
|
||||
// stack without bound; keep the most recent few so the newest is always visible.
|
||||
const MAX_TOASTS = 4
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
@@ -36,14 +35,12 @@ export const toast = {
|
||||
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 }) {
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
const isError = item.tone === 'error'
|
||||
useEffect(() => {
|
||||
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||
// Errors are the primary report that a mutation failed, so they stay until
|
||||
// dismissed; info toasts time out.
|
||||
if (isError) return
|
||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||
return () => clearTimeout(t)
|
||||
@@ -52,10 +49,8 @@ function ToastItem({ item }: { item: Toast }) {
|
||||
<div
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
isError
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: 'border-border bg-surface text-fg',
|
||||
'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 ? 'border-accent-300 bg-accent-100 text-accent-800' : 'border-divider bg-neutral-100 text-text',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{item.message}</span>
|
||||
@@ -63,20 +58,20 @@ function ToastItem({ item }: { item: Toast }) {
|
||||
type="button"
|
||||
onClick={() => dismiss(item.id)}
|
||||
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>
|
||||
</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() {
|
||||
const toasts = useToastStore((s) => s.toasts)
|
||||
if (toasts.length === 0) return null
|
||||
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) => (
|
||||
<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,762 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react'
|
||||
import { clampScale, type Point } from '@/lib/geometry'
|
||||
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import 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 ?? '#97a97c'} 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="var(--color-paper)"
|
||||
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,278 +0,0 @@
|
||||
import { Component, Suspense, 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 { lazyPage } from '@/lib/lazyPage'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
|
||||
// assistant message actually renders, not for everyone who opens the editor.
|
||||
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
|
||||
// chunk 404 would otherwise permanently break the assistant.
|
||||
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||
|
||||
/**
|
||||
* Falls back to the raw message text if the markdown chunk can't load (a
|
||||
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
|
||||
* readable text, never take the whole editor down. Suspense handles the loading
|
||||
* phase; this handles the failure one.
|
||||
*/
|
||||
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, 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 || !!pending}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
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-3 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(
|
||||
'rounded-lg px-2.5 py-2 text-sm',
|
||||
// The user's own text is literal (their `*` shouldn't become a bullet)
|
||||
// and hugs the right; the assistant's Markdown is rendered and gets the
|
||||
// full width so a table has room.
|
||||
mine
|
||||
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
|
||||
: 'w-full border border-border text-fg',
|
||||
)}
|
||||
>
|
||||
{mine ? (
|
||||
body
|
||||
) : (
|
||||
// Show the raw text until the renderer chunk arrives (Suspense), and fall
|
||||
// back to it if the chunk can't load or the renderer throws (boundary) —
|
||||
// either way the message is readable, never blank and never a crash.
|
||||
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<MarkdownMessage>{body}</MarkdownMessage>
|
||||
</Suspense>
|
||||
</MarkdownBoundary>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useClearObject } from '@/lib/objects'
|
||||
|
||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||
* are kept with removed_at, so history survives). */
|
||||
export function ClearBedModal({
|
||||
objectId,
|
||||
objectName,
|
||||
plopCount,
|
||||
gardenId,
|
||||
onClose,
|
||||
}: {
|
||||
objectId: number
|
||||
objectName: string
|
||||
plopCount: number
|
||||
gardenId: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const clear = useClearObject(gardenId)
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Clear bed"
|
||||
confirmLabel="Clear bed"
|
||||
busyLabel="Clearing…"
|
||||
confirmDisabled={plopCount === 0}
|
||||
errorFallback="Could not clear the bed."
|
||||
onConfirm={() => clear.mutateAsync(objectId)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** A non-interactive hint overlaid on the canvas (empty-state guidance). */
|
||||
export function EditorHint({ position = 'center', children }: { position?: 'center' | 'top'; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute flex p-6 text-center',
|
||||
position === 'top' ? 'inset-x-0 top-16 justify-center' : 'inset-0 items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<p className="max-w-xs rounded-lg bg-surface/85 px-4 py-3 text-sm text-muted shadow-sm backdrop-blur">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* The editor's side rail, and the answer to "four things want one rail".
|
||||
*
|
||||
* The inspector, history, journal, and (later) the chat panel all want the same
|
||||
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
|
||||
* one rail — which keeps the canvas one width instead of a different
|
||||
* width per panel, and means adding the journal or chat is adding a tab.
|
||||
*
|
||||
* Two constraints shaped it:
|
||||
*
|
||||
* - Selecting an object must land you in the inspector with no extra click.
|
||||
* The editor watches the selection and switches to that tab itself, so the
|
||||
* rail never becomes a thing you have to operate before you can edit.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the rail
|
||||
* closes completely when nothing needs it.
|
||||
*
|
||||
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
|
||||
* Phone: an in-flow PEEK (#101) — a capped-height panel the editor's flex column
|
||||
* places BETWEEN the canvas and the always-visible mode bar, so the canvas
|
||||
* shrinks to keep the garden visible above it and the mode bar reachable below,
|
||||
* rather than a bottom sheet that covered the whole garden. `tall` raises that
|
||||
* cap for panel modes (journal/history/assistant), where reading and typing are
|
||||
* the task and a half-height peek felt cramped; the inspector keeps the shorter
|
||||
* peek so the canvas it describes stays in view.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Rendered lazily: only the active tab's content is built. */
|
||||
render: () => ReactNode
|
||||
/** Shown as a dot on the tab — a count, or true for a plain marker. */
|
||||
badge?: number | boolean
|
||||
}
|
||||
|
||||
export function EditorRail({
|
||||
tabs,
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
tall = false,
|
||||
}: {
|
||||
tabs: RailTab[]
|
||||
activeId: string
|
||||
onActivate: (id: string) => void
|
||||
onClose: () => void
|
||||
/** Raise the mobile peek's height cap (panel modes want the room). */
|
||||
tall?: boolean
|
||||
}) {
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: an in-flow PEEK — a capped-height panel that sits between the
|
||||
// canvas and the always-visible mode bar (the editor's flex column places
|
||||
// it there), so the garden stays visible above it and the mode bar stays
|
||||
// reachable below. The canvas flexes to fill whatever's left. Desktop: a
|
||||
// fixed-width column beside the canvas (the cap doesn't apply there).
|
||||
'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
|
||||
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
|
||||
// visible area and shove the mode bar off-screen (same #85 reasoning).
|
||||
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
|
||||
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onActivate(tab.id)}
|
||||
aria-current={tab.id === active.id ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
tab.id === active.id ? 'bg-border/60 text-fg' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge != null && tab.badge !== false && tab.badge !== 0 && (
|
||||
<span className="rounded-full bg-accent/20 px-1.5 text-[10px] font-semibold text-accent-strong">
|
||||
{tab.badge === true ? '•' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="ml-auto rounded px-1.5 text-sm text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import {
|
||||
screenToWorld,
|
||||
snapLocalToBedGrid,
|
||||
snapPoint,
|
||||
visibleGridStepCm,
|
||||
worldToLocal,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from '@/lib/geometry'
|
||||
import { formatCm, type UnitPref } from '@/lib/units'
|
||||
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { ObjectShape } from './ObjectShape'
|
||||
import { PlopLayer } from './PlopLayer'
|
||||
import { PlopOverlay } from './PlopOverlay'
|
||||
import { SelectionOverlay } from './SelectionOverlay'
|
||||
import { kindDef } from './kinds'
|
||||
import { DIMMED_OPACITY, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import { useViewport } from './useViewport'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
|
||||
const GRID_MIN_CELL_PX = 6
|
||||
const GRID_OPACITY = 0.18
|
||||
const OVERLAY_BADGE_CLASS = 'rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur'
|
||||
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
|
||||
|
||||
/**
|
||||
* What to tell the user about the grid they're looking at, or null when the
|
||||
* drawn lines are simply the garden's own grid and need no explanation. Two
|
||||
* states are worth naming rather than leaving them to infer: lines drawn every
|
||||
* Nth grid cell (the grid was too fine to draw at this zoom), and — degenerate —
|
||||
* a grid so fine no multiple of it is drawable, which matters most when snapping
|
||||
* is on and would otherwise be invisibly active (#47).
|
||||
*/
|
||||
function gridNoteFor(
|
||||
drawnCm: number | null,
|
||||
gridCm: number,
|
||||
snapToGrid: boolean,
|
||||
unit: UnitPref,
|
||||
): string | null {
|
||||
if (drawnCm == null) return snapToGrid ? 'grid too fine to draw — zoom in' : null
|
||||
if (drawnCm === gridCm) return null
|
||||
return `grid every ${formatCm(drawnCm, unit)}`
|
||||
}
|
||||
|
||||
/** The world-space bounds rect of an object (center + size → top-left rect). */
|
||||
function objectRect(o: EditorObject): Rect {
|
||||
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
|
||||
}
|
||||
|
||||
/** Grid-line offsets (from a bed's top-left corner, in the object-local frame)
|
||||
* for a bed of the given half-extents and grid step. Starts at the corner so the
|
||||
* lines match snapLocalToBedGrid, and is capped so a tiny grid on a big bed can't
|
||||
* emit thousands of lines. Returns cm offsets along one axis. */
|
||||
function bedGridLines(half: number, step: number): number[] {
|
||||
if (!(step > 0) || (2 * half) / step > BED_GRID_MAX_LINES) return []
|
||||
const lines: number[] = []
|
||||
for (let v = -half; v <= half + 1e-6; v += step) lines.push(v)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
|
||||
* rotate an object, and — the #15 core — focus into a plantable object to place,
|
||||
* move, resize and remove plops with semantic-zoom rendering. The object/plop
|
||||
* being dragged renders from liveObject/livePlanting for instant feedback; the
|
||||
* PATCH fires on release.
|
||||
*/
|
||||
export function GardenCanvas({
|
||||
garden,
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
canEdit,
|
||||
}: {
|
||||
garden: EditorGarden
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
|
||||
const fitKeyRef = useRef<string | null>(null)
|
||||
const gridId = 'garden-grid-' + useId().replace(/:/g, '')
|
||||
|
||||
const viewport = useEditorStore((s) => s.viewport)
|
||||
const selectedId = useEditorStore((s) => s.selectedId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
||||
const selectPlanting = useEditorStore((s) => s.selectPlanting)
|
||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const { fitToRect } = useViewport(svgRef)
|
||||
const createObject = useCreateObject(garden.id)
|
||||
const createPlanting = useCreatePlanting(garden.id)
|
||||
|
||||
const gardenRect: Rect = useMemo(
|
||||
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
|
||||
[garden.widthCm, garden.heightCm],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Single fit mechanism: frame the focused object, else the whole garden, and
|
||||
// animate whenever that target (or the garden) changes. The key guard stops a
|
||||
// refit on unrelated re-renders (a plop add, a bed drag).
|
||||
useEffect(() => {
|
||||
if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return
|
||||
const key = `${garden.id}:${focusedObjectId}`
|
||||
if (fitKeyRef.current === key) return
|
||||
const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined
|
||||
if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is
|
||||
fitKeyRef.current = key
|
||||
fitToRect(target ? objectRect(target) : gardenRect, size)
|
||||
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
|
||||
|
||||
// Draw the true grid when its cells are legible, else the coarsest-but-smallest
|
||||
// multiple of it that is (see visibleGridStepCm). Snapping still uses gridCm —
|
||||
// the drawn lines are a subset of the snap positions, never a different grid.
|
||||
const gridCm = garden.gridSizeCm
|
||||
const drawnGridCm = visibleGridStepCm(gridCm, viewport.scale, GRID_MIN_CELL_PX)
|
||||
const gridNote = gridNoteFor(drawnGridCm, gridCm, garden.snapToGrid, garden.unitPref)
|
||||
|
||||
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
|
||||
const rendered = useMemo(
|
||||
() => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
|
||||
[sorted, liveObject],
|
||||
)
|
||||
// Merge the in-flight live plop over the active list, same as liveObject.
|
||||
const renderedPlops = useMemo(
|
||||
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
|
||||
[plantings, livePlanting],
|
||||
)
|
||||
const selectedObject = rendered.find((o) => o.id === selectedId) ?? null
|
||||
const selectedPlop = renderedPlops.find((p) => p.id === selectedPlantingId) ?? null
|
||||
const selectedPlopObject = selectedPlop ? rendered.find((o) => o.id === selectedPlop.objectId) ?? null : null
|
||||
const focusedObject = focusedObjectId != null ? rendered.find((o) => o.id === focusedObjectId) ?? null : null
|
||||
|
||||
// A pointerdown reaching the svg is empty space: place the armed object kind,
|
||||
// or exit focus mode, or just deselect.
|
||||
function onCanvasPointerDown(e: ReactPointerEvent) {
|
||||
const armed = useEditorStore.getState().armedKind
|
||||
// Defense in depth: a viewer can't place objects even if a stale armed kind
|
||||
// slipped through (the palette isn't rendered for them).
|
||||
if (armed && canEdit) {
|
||||
useEditorStore.getState().setArmedKind(null)
|
||||
const def = kindDef(armed)
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
if (!def || !rect) return
|
||||
let world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||
// Snap the new object's center to the garden grid when the garden opts in.
|
||||
if (garden.snapToGrid) world = snapPoint(world, gridCm)
|
||||
createObject.mutate(
|
||||
{ kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ },
|
||||
{ onSuccess: (o) => select(o.id) },
|
||||
)
|
||||
return
|
||||
}
|
||||
// Empty-space tap while focused exits focus (and any placement); otherwise
|
||||
// just clears the selection.
|
||||
if (focusedObjectId != null) {
|
||||
setFocusedObject(null)
|
||||
useEditorStore.getState().setArmedPlant(null)
|
||||
}
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}
|
||||
|
||||
// Drop a plop where the user taps inside the focused object (placement stays
|
||||
// armed for repeat-placement until Escape / Done).
|
||||
function onPlace(e: ReactPointerEvent) {
|
||||
e.stopPropagation()
|
||||
if (!canEdit || !focusedObject || !armedPlant || !focusedObject.plantable) return
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||
const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
|
||||
// snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes
|
||||
// it a pure clamp, so both paths go through one helper.
|
||||
const { x, y } = snapLocalToBedGrid(
|
||||
local,
|
||||
focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0,
|
||||
focusedObject.widthCm / 2,
|
||||
focusedObject.heightCm / 2,
|
||||
)
|
||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||
// the object, so a selection would be hidden until placement ends anyway).
|
||||
createPlanting.mutate({
|
||||
objectId: focusedObject.id,
|
||||
plantId: armedPlant.id,
|
||||
xCm: x,
|
||||
yCm: y,
|
||||
radiusCm,
|
||||
seedLotId: armedLotId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
|
||||
|
||||
// Draw the bed's own grid inside a focused plantable bed that has snapping on,
|
||||
// so the user sees exactly where plants will land. Lines are in the bed's local
|
||||
// frame (origin at center), anchored to the corner to match snapLocalToBedGrid.
|
||||
// Memoized so a high-frequency plop drag (which re-renders the canvas on every
|
||||
// pointermove) doesn't rebuild the line arrays each frame. null when the focused
|
||||
// object isn't a snapping plantable bed.
|
||||
const bedGrid = useMemo(() => {
|
||||
if (!focusedObject || !focusedObject.plantable || !focusedObject.snapToGrid) return null
|
||||
return {
|
||||
v: bedGridLines(focusedObject.widthCm / 2, focusedObject.gridSizeCm),
|
||||
h: bedGridLines(focusedObject.heightCm / 2, focusedObject.gridSizeCm),
|
||||
}
|
||||
}, [focusedObject])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="h-full w-full select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={onCanvasPointerDown}
|
||||
// role="application" tells a screen reader this is an interactive canvas
|
||||
// to operate, not a document to read linearly. The <title> names it, and
|
||||
// objects inside are individually focusable buttons (see ObjectShape).
|
||||
role="application"
|
||||
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
|
||||
>
|
||||
<title>{garden.name} garden layout</title>
|
||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||
{drawnGridCm != null && (
|
||||
<>
|
||||
<defs>
|
||||
<pattern id={gridId} width={drawnGridCm} height={drawnGridCm} patternUnits="userSpaceOnUse">
|
||||
<path d={`M ${drawnGridCm} 0 L 0 0 0 ${drawnGridCm}`} fill="none" stroke="#808080" strokeOpacity={GRID_OPACITY} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="none" stroke="#3f8f4f" strokeOpacity={0.7} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
|
||||
{rendered.map((o) => (
|
||||
<g key={o.id} opacity={focusedObjectId != null && focusedObjectId !== o.id ? DIMMED_OPACITY : 1}>
|
||||
<ObjectShape object={o} selected={o.id === selectedId} onSelect={select} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{focusedObject && bedGrid && (
|
||||
<g transform={objectTransform(focusedObject)} pointerEvents="none">
|
||||
{bedGrid.v.map((x) => (
|
||||
<line key={`v${x}`} x1={x} y1={-halfFH} x2={x} y2={halfFH} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{bedGrid.h.map((y) => (
|
||||
<line key={`h${y}`} x1={-halfFW} y1={y} x2={halfFW} y2={y} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
<PlopLayer
|
||||
objects={rendered}
|
||||
plantings={renderedPlops}
|
||||
plantsById={plantsById}
|
||||
scale={viewport.scale}
|
||||
focusedObjectId={focusedObjectId}
|
||||
selectedPlantingId={selectedPlantingId}
|
||||
onSelectPlop={selectPlanting}
|
||||
/>
|
||||
|
||||
{/* Edit handles are mounted only for editors/owners; viewers can still
|
||||
select to inspect (read-only), but never move/resize. */}
|
||||
{canEdit && selectedObject && (
|
||||
<SelectionOverlay
|
||||
object={selectedObject}
|
||||
gardenId={garden.id}
|
||||
svgRef={svgRef}
|
||||
snap={garden.snapToGrid}
|
||||
gridCm={gridCm}
|
||||
/>
|
||||
)}
|
||||
{canEdit && selectedPlop && selectedPlopObject && (
|
||||
<PlopOverlay
|
||||
plop={selectedPlop}
|
||||
object={selectedPlopObject}
|
||||
gardenId={garden.id}
|
||||
svgRef={svgRef}
|
||||
snap={selectedPlopObject.snapToGrid}
|
||||
gridCm={selectedPlopObject.gridSizeCm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placement capture: a transparent sheet over the focused object while a
|
||||
plant is armed, so taps drop plops instead of selecting the object. */}
|
||||
{canEdit && focusedObject && armedPlant && focusedObject.plantable && (
|
||||
<g transform={objectTransform(focusedObject)}>
|
||||
<rect
|
||||
x={-halfFW}
|
||||
y={-halfFH}
|
||||
width={halfFW * 2}
|
||||
height={halfFH * 2}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onPointerDown={onPlace}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="pointer-events-none absolute left-3 top-3 flex flex-col items-start gap-1">
|
||||
<span className={OVERLAY_BADGE_CLASS}>{viewport.scale.toFixed(2)} px/cm</span>
|
||||
{gridNote && <span className={OVERLAY_BADGE_CLASS}>{gridNote}</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
|
||||
className="absolute bottom-3 right-3 rounded-md border border-border bg-surface/90 px-3 py-1.5 text-sm font-medium text-fg shadow-sm outline-none backdrop-blur transition-colors hover:bg-border/50 focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { describeCounts, totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
/**
|
||||
* The garden's change history, newest first, with an undo on each entry.
|
||||
*
|
||||
* A reverted entry stays in the list, marked — and the revert appears as its own
|
||||
* entry, because that is what it is. Making the original disappear would be
|
||||
* rewriting history rather than adding to it, and would leave no way to undo the
|
||||
* undo.
|
||||
*/
|
||||
export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||
const history = useGardenHistory(gardenId)
|
||||
const undo = useUndo(gardenId)
|
||||
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-fg">History</h2>
|
||||
|
||||
{history.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{history.isError && sets.length === 0 && (
|
||||
<Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>
|
||||
)}
|
||||
|
||||
{history.isSuccess && sets.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
Nothing yet. Every change you make here shows up in this list, and can be undone from it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{sets.map((cs) => (
|
||||
<HistoryEntry key={cs.id} changeSet={cs} canEdit={canEdit} undo={undo} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{history.hasNextPage && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
{/* isError stays true for a failed page even though earlier pages
|
||||
loaded, so say so rather than leaving a button that did nothing. */}
|
||||
{history.isError && sets.length > 0 && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
{errorMessage(history.error, "Couldn't load older entries.")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Said plainly rather than discovered: deleting a garden bypasses this
|
||||
list entirely, because the delete cascades below the layer that records
|
||||
changes. Better to state the gap than to imply cover we don't have. */}
|
||||
<p className="border-t border-border pt-2 text-xs text-muted">
|
||||
Deleting a whole garden isn't covered here and can't be undone.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryEntry({
|
||||
changeSet,
|
||||
canEdit,
|
||||
undo,
|
||||
}: {
|
||||
changeSet: ChangeSet
|
||||
canEdit: boolean
|
||||
undo: ReturnType<typeof useUndo>
|
||||
}) {
|
||||
const counts = describeCounts(changeSet)
|
||||
const reverted = changeSet.revertedById != null
|
||||
const isRevert = changeSet.revertsId != null
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'rounded-lg border border-border px-2.5 py-2 text-sm',
|
||||
reverted && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-fg', reverted && 'line-through decoration-muted')}>{changeSet.summary}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
{changeSet.source === 'agent' && (
|
||||
<span className="rounded bg-accent/20 px-1 py-px font-medium uppercase tracking-wide text-accent-strong">
|
||||
agent
|
||||
</span>
|
||||
)}
|
||||
{isRevert && <span className="rounded bg-border/60 px-1 py-px font-medium">undo</span>}
|
||||
<span>{changeSet.actorName}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<time dateTime={changeSet.createdAt}>{relativeTime(changeSet.createdAt)}</time>
|
||||
{counts && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{counts}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{reverted && <p className="mt-1 text-xs text-muted">Undone</p>}
|
||||
</div>
|
||||
|
||||
{canEdit && totalChanges(changeSet) > 0 && (
|
||||
<UndoButton
|
||||
changeSet={changeSet}
|
||||
undo={undo}
|
||||
className="w-32 shrink-0 text-right"
|
||||
label={reverted ? 'Undo again' : 'Undo'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact "3m ago" / "yesterday" for a UTC timestamp. */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = (Date.now() - then) / 1000
|
||||
// Clock skew between the server and this browser can put a just-written entry
|
||||
// slightly in the future; that's "just now", not a negative duration.
|
||||
if (seconds < 60) return 'just now'
|
||||
// Floor throughout: 18 hours ago is "18h ago", not "yesterday". Rounding up
|
||||
// into the next unit reads as a bigger gap than actually elapsed.
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { totalChanges, type ChangeSet, type UndoOutcome } from '@/lib/history'
|
||||
import { relativeTime } from './shared'
|
||||
import type { useUndoLast } from './useUndoLast'
|
||||
|
||||
/**
|
||||
* Every change — yours or the assistant's — as one pill, newest first, with
|
||||
* Undo the last step at the bottom and an undo on any older step. A reverted
|
||||
* entry stays in the list, struck through, and the revert appears as its own
|
||||
* entry, because that is what it is: making the original disappear would be
|
||||
* rewriting history rather than adding to it.
|
||||
*/
|
||||
export function HistoryTab({ canEdit, undoLast }: { canEdit: boolean; undoLast: ReturnType<typeof useUndoLast> }) {
|
||||
const { history, sets, undo, canUndo, target } = undoLast
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||
<div className="text-[12.5px] leading-relaxed text-ink-mute">Every change — yours or the assistant's — is one undoable step.</div>
|
||||
{history.isPending && <p className="text-[13px] text-ink-mute">Loading…</p>}
|
||||
{history.isError && sets.length === 0 && <Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>}
|
||||
{history.isSuccess && sets.length === 0 && <div className="text-[13px] text-ink-mute">Nothing yet — go move something.</div>}
|
||||
{sets.map((cs) => (
|
||||
<Pill key={cs.id} cs={cs} canEdit={canEdit} outcome={undo.outcomeFor(cs.id)} onUndo={() => undo.undo(cs)} />
|
||||
))}
|
||||
{history.hasNextPage && (
|
||||
<Button variant="ghost" className="self-start text-[13px]" disabled={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Older steps'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-1.5">
|
||||
{canEdit && (
|
||||
<Button disabled={!canUndo} onClick={undoLast.undoLast} title={target ? `Undo: ${target.summary}` : undefined}>
|
||||
Undo the last step
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-[11px] leading-relaxed text-ink-mute">Deleting a whole garden isn't covered here and can't be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({ cs, canEdit, outcome, onUndo }: { cs: ChangeSet; canEdit: boolean; outcome?: UndoOutcome; onUndo: () => void }) {
|
||||
const reverted = cs.revertedById != null
|
||||
const isRevert = cs.revertsId != null
|
||||
const pending = outcome?.tone === 'pending'
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={cn('flex items-center gap-[9px] rounded-full border border-divider bg-bg py-2 pl-3.5 pr-2', reverted && 'opacity-60')}>
|
||||
<span className={cn('h-[7px] w-[7px] flex-none rounded-full', reverted ? 'bg-neutral-400' : isRevert ? 'bg-neutral-500' : cs.source === 'agent' ? 'bg-accent-2-500' : 'bg-accent-400')} />
|
||||
<span className={cn('min-w-0 truncate text-[13px] font-semibold', reverted && 'line-through')} title={`${cs.summary} — ${cs.actorName}${cs.source === 'agent' ? ' (assistant)' : ''}`}>
|
||||
{cs.summary}
|
||||
</span>
|
||||
<span className="ml-auto flex-none text-[11.5px] text-ink-mute">{relativeTime(cs.createdAt)}</span>
|
||||
{canEdit && !reverted && totalChanges(cs) > 0 && (
|
||||
<button type="button" className="btn btn-ghost -my-1 px-2 py-0.5 text-[11.5px]" disabled={pending} onClick={onUndo}>
|
||||
{pending ? 'Undoing…' : 'Undo'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{outcome && outcome.tone !== 'pending' && (
|
||||
<p role="status" className={cn('px-3.5 text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+394
-240
@@ -1,266 +1,242 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ColorDot } from '@/components/plants/Monogram'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import {
|
||||
cmFromSpacing,
|
||||
dimensionUnitLabel,
|
||||
dimensionInputMode,
|
||||
dimensionUnitLabel,
|
||||
formatDimensionInput,
|
||||
formatLength,
|
||||
formatSize,
|
||||
MIN_DIMENSION_CM,
|
||||
parseDimension,
|
||||
spacingFromCm,
|
||||
spacingUnitLabel,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
import { kindDef } from './kinds'
|
||||
import { kindDef, kindPlural } from './kinds'
|
||||
import { MIN_OBJECT_CM, plopCount } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_COLOR = '#8a8a8a'
|
||||
/** "Growing: 64 garlic · 3 tomato" for an object, or the empty line. */
|
||||
export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): string {
|
||||
const roster = new Map<string, number>()
|
||||
for (const p of plantings) {
|
||||
if (p.objectId !== o.id) continue
|
||||
const plant = plantsById.get(p.plantId)
|
||||
const name = (plant?.name ?? 'plants').toLowerCase()
|
||||
roster.set(name, (roster.get(name) ?? 0) + plopCount(p, plant))
|
||||
}
|
||||
if (roster.size === 0) return o.plantable ? 'Nothing planted yet.' : ''
|
||||
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
|
||||
}
|
||||
|
||||
/** A collapsible block of the less-often-needed fields. */
|
||||
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<button type="button" className="btn btn-ghost self-start gap-1.5 text-[13px]" onClick={onToggle} aria-expanded={open}>
|
||||
<Icon name="chevron-down" size={13} className={cn('transition-transform', open && 'rotate-180')} />
|
||||
Details
|
||||
</button>
|
||||
{open && <div className="flex flex-col gap-3 rounded-[18px] border border-divider bg-bg p-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property panel for the selected object. Each field commits a PATCH on
|
||||
* blur/change (carrying the object's version); dimensions and position are shown
|
||||
* in the garden's unit. Keyed by object id in the parent so it re-inits cleanly
|
||||
* on selection change.
|
||||
* The selected object: its name (edits on blur/Enter), kind + size, what's
|
||||
* growing in it, and the three actions — Plant this, rotate 90°, remove. The
|
||||
* details block below carries the exact geometry, color, plantability, the
|
||||
* bed grid and notes. Keyed by object id in the parent so fields re-init.
|
||||
*/
|
||||
export function Inspector({
|
||||
export function ObjectInspector({
|
||||
object,
|
||||
gardenId,
|
||||
unit,
|
||||
onFocus,
|
||||
onAddNote,
|
||||
noteCount = 0,
|
||||
readOnly = false,
|
||||
canEdit,
|
||||
focused,
|
||||
roster,
|
||||
noteCount,
|
||||
large,
|
||||
onPlantThis,
|
||||
onNotes,
|
||||
onDeleted,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
onFocus?: () => void
|
||||
/** Opens the journal scoped to this object. Two taps from a selected bed to
|
||||
* typing is the bar; anything more and the log stays empty. */
|
||||
onAddNote?: () => void
|
||||
/** How many journal entries are about this object, so the log is discoverable
|
||||
* from the thing it's about rather than being a panel you have to remember. */
|
||||
noteCount?: number
|
||||
readOnly?: boolean
|
||||
canEdit: boolean
|
||||
/** Already inside this bed (so "Plant this" is redundant). */
|
||||
focused: boolean
|
||||
roster: string
|
||||
noteCount: number
|
||||
/** Phone: 16px inputs, 44px targets. */
|
||||
large?: boolean
|
||||
onPlantThis: () => void
|
||||
onNotes: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const update = useUpdateObject(gardenId)
|
||||
const del = useDeleteObject(gardenId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
|
||||
// Local field state (initialized once; committed on blur/change).
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [name, setName] = useState(object.name)
|
||||
const [notes, setNotes] = useState(object.notes)
|
||||
const [details, setDetails] = useState(false)
|
||||
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
|
||||
const [height, setHeight] = useState(formatDimensionInput(object.heightCm, unit))
|
||||
const [x, setX] = useState(formatDimensionInput(object.xCm, unit))
|
||||
const [y, setY] = useState(formatDimensionInput(object.yCm, unit))
|
||||
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
|
||||
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
|
||||
const [gridSize, setGridSize] = useState(String(spacingFromCm(object.gridSizeCm, unit)))
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [notes, setNotes] = useState(object.notes)
|
||||
|
||||
// When the object changes underneath us (e.g. a canvas move/resize/rotate, or
|
||||
// an optimistic PATCH result), re-sync the fields — unless the user is
|
||||
// actively editing one here, so we don't clobber their typing.
|
||||
// Re-sync when the object changes underneath (a drag, a server row) unless a
|
||||
// field here is being edited.
|
||||
useEffect(() => {
|
||||
if (rootRef.current?.contains(document.activeElement)) return
|
||||
setName(object.name)
|
||||
setNotes(object.notes)
|
||||
setWidth(formatDimensionInput(object.widthCm, unit))
|
||||
setHeight(formatDimensionInput(object.heightCm, unit))
|
||||
setX(formatDimensionInput(object.xCm, unit))
|
||||
setY(formatDimensionInput(object.yCm, unit))
|
||||
setRotation(String(Math.round(object.rotationDeg)))
|
||||
setColor(object.color ?? DEFAULT_COLOR)
|
||||
setGridSize(String(spacingFromCm(object.gridSizeCm, unit)))
|
||||
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, object.gridSizeCm, unit])
|
||||
setNotes(object.notes)
|
||||
}, [object, unit])
|
||||
|
||||
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => {
|
||||
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
|
||||
if (!canEdit) return
|
||||
update.mutate({ id: object.id, version: object.version, ...fields })
|
||||
}
|
||||
|
||||
// Commit a dimension/position field. `positive` gates width/height (must be
|
||||
// ≥ the server minimum) but not x/y, which may be zero or negative.
|
||||
//
|
||||
// The no-op guard compares the field's TEXT against the string this field
|
||||
// renders for the current value. With compound entry there is no single
|
||||
// display number left to compare, and a numeric tolerance would be worse:
|
||||
// a bed dragged to some arbitrary cm renders as, say, 2′ 7.9″, and merely
|
||||
// tabbing through the field would snap it to a whole inch. Text equality means
|
||||
// "you didn't edit this", which is what the guard is actually for. Typing the
|
||||
// same value a different way (2' 7" vs 2′ 7″) falls through to the cm compare
|
||||
// below and is still a no-op.
|
||||
const commitName = () => {
|
||||
const v = name.trim()
|
||||
if (v && v !== object.name) patch({ name: v })
|
||||
else setName(object.name)
|
||||
}
|
||||
const commitDim = (raw: string, current: number, apply: (cm: number) => void, positive = false) => {
|
||||
if (raw.trim() === formatDimensionInput(current, unit)) return
|
||||
const cm = parseDimension(raw, unit)
|
||||
if (cm === null) return // unparseable: leave the row alone rather than commit a zero
|
||||
if (positive && cm < MIN_DIMENSION_CM) return
|
||||
if (cm === null) return
|
||||
if (positive && cm < Math.max(MIN_DIMENSION_CM, MIN_OBJECT_CM)) return
|
||||
if (cm !== current) apply(cm)
|
||||
}
|
||||
|
||||
// Commit the bed grid size (entered at spacing scale, cm/in). Compare at display
|
||||
// precision so a blur without an edit doesn't fire a spurious PATCH; ignore a
|
||||
// sub-1cm value the server would reject.
|
||||
const commitGrid = () => {
|
||||
const v = parseFloat(gridSize)
|
||||
if (!Number.isFinite(v)) return
|
||||
if (v === spacingFromCm(object.gridSizeCm, unit)) return
|
||||
if (!Number.isFinite(v) || v === spacingFromCm(object.gridSizeCm, unit)) return
|
||||
const cm = cmFromSpacing(v, unit)
|
||||
if (cm >= MIN_DIMENSION_CM && cm !== object.gridSizeCm) patch({ gridSizeCm: cm })
|
||||
}
|
||||
|
||||
const u = dimensionUnitLabel(unit)
|
||||
const inputMode = dimensionInputMode(unit)
|
||||
const inputCls = cn(large && 'input-lg')
|
||||
const btnSize = large ? 44 : 36
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg">{kindDef(object.kind)?.label ?? object.kind}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(null)}
|
||||
className="rounded px-1.5 text-sm text-muted hover:text-fg"
|
||||
aria-label="Close inspector"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<input
|
||||
className={cn('input font-bold', inputCls)}
|
||||
aria-label="Name"
|
||||
value={name}
|
||||
readOnly={!canEdit}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="neutral">{kindDef(object.kind)?.label ?? object.kind}</Tag>
|
||||
<span className="text-[13px] font-semibold text-ink-soft">{formatSize(object.widthCm, object.heightCm, unit)}</span>
|
||||
{noteCount > 0 && (
|
||||
<button type="button" className="tag tag-accent-2 cursor-pointer border-0" onClick={onNotes}>
|
||||
{noteCount} {noteCount === 1 ? 'note' : 'notes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">View only — you can't edit this garden.</p>
|
||||
)}
|
||||
|
||||
{!readOnly && object.plantable && onFocus && (
|
||||
<Button onClick={onFocus} className="w-full">
|
||||
🌱 Plant here
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onAddNote && (
|
||||
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
|
||||
{noteCount > 0
|
||||
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
|
||||
: readOnly
|
||||
? '📝 No notes'
|
||||
: '📝 Add note'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* A disabled fieldset makes every control below read-only for viewers in
|
||||
one shot (no per-input disabled). */}
|
||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => name !== object.name && patch({ name })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Width (${u})`}
|
||||
name="width"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Height (${u})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
|
||||
/>
|
||||
<TextField
|
||||
label={`X (${u})`}
|
||||
name="x"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={x}
|
||||
onChange={(e) => setX(e.target.value)}
|
||||
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
||||
/>
|
||||
<TextField
|
||||
label={`Y (${u})`}
|
||||
name="y"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={y}
|
||||
onChange={(e) => setY(e.target.value)}
|
||||
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Rotation (°)"
|
||||
name="rotation"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(rotation)
|
||||
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="obj-color" className="text-sm font-medium text-fg">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="obj-color"
|
||||
type="color"
|
||||
value={color}
|
||||
// onChange fires continuously while dragging in the native picker;
|
||||
// track it locally for the live swatch and commit one PATCH on blur.
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setColor(e.target.value)}
|
||||
onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
|
||||
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
|
||||
/>
|
||||
</div>
|
||||
{object.color && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setColor(DEFAULT_COLOR)
|
||||
patch({ color: null })
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
{roster && <div className="text-[12.5px] leading-relaxed text-ink-soft">{roster}</div>}
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
{object.plantable && !focused && (
|
||||
<Button variant="primary" className="flex-1" tall={large} onClick={onPlantThis}>
|
||||
Plant this
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.plantable}
|
||||
onChange={(e) => patch({ plantable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
<IconButton label="Rotate 90°" icon="rotate-cw" iconSize={large ? 16 : 15} size={btnSize} onClick={() => patch({ rotationDeg: (object.rotationDeg + 90) % 360 })} />
|
||||
<IconButton
|
||||
label="Remove"
|
||||
icon="trash-2"
|
||||
iconSize={large ? 16 : 15}
|
||||
size={btnSize}
|
||||
iconClassName="text-accent-700"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
onDeleted()
|
||||
del.mutate(object.id)
|
||||
}}
|
||||
/>
|
||||
Plantable
|
||||
</label>
|
||||
{!object.plantable || focused ? <span className="flex-1" /> : null}
|
||||
</div>
|
||||
)}
|
||||
{noteCount === 0 && (
|
||||
<button type="button" className="btn btn-ghost self-start gap-1.5 text-[13px]" onClick={onNotes}>
|
||||
<Icon name="notebook" size={13} />
|
||||
{canEdit ? 'Add a note' : 'Notes'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bed grid: only plantable beds place plants, so the plant-snapping grid
|
||||
is shown just for them. */}
|
||||
{object.plantable && (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Details open={details} onToggle={() => setDetails((v) => !v)}>
|
||||
<fieldset disabled={!canEdit} className="contents">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField label={`Width (${u})`} name="width" type="text" inputMode={inputMode} className={inputCls} value={width} onChange={(e) => setWidth(e.target.value)} onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)} />
|
||||
<TextField label={`Height (${u})`} name="height" type="text" inputMode={inputMode} className={inputCls} value={height} onChange={(e) => setHeight(e.target.value)} onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)} />
|
||||
<TextField label={`X (${u})`} name="x" type="text" inputMode={inputMode} className={inputCls} value={x} onChange={(e) => setX(e.target.value)} onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))} />
|
||||
<TextField label={`Y (${u})`} name="y" type="text" inputMode={inputMode} className={inputCls} value={y} onChange={(e) => setY(e.target.value)} onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))} />
|
||||
<TextField
|
||||
label="Rotation (°)"
|
||||
name="rotation"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
className={inputCls}
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(rotation)
|
||||
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: ((v % 360) + 360) % 360 })
|
||||
}}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor={`color-${object.id}`}>Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`color-${object.id}`}
|
||||
type="color"
|
||||
value={object.color ?? '#e3d0ac'}
|
||||
onChange={(e) => patch({ color: e.target.value })}
|
||||
className="h-9 w-12 cursor-pointer rounded-full border border-divider bg-surface p-1"
|
||||
/>
|
||||
{object.color && (
|
||||
<button type="button" className="btn btn-ghost px-2 text-xs" onClick={() => patch({ color: null })}>
|
||||
Kind's own
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-semibold">Plantable</span>
|
||||
<Toggle className="ml-auto" on={object.plantable} label="Plantable" disabled={!canEdit} onChange={(v) => patch({ plantable: v })} />
|
||||
</div>
|
||||
{object.plantable && (
|
||||
<div className="flex items-end gap-2">
|
||||
<TextField
|
||||
label={`Bed grid (${spacingUnitLabel(unit)})`}
|
||||
name="gridSize"
|
||||
@@ -268,56 +244,234 @@ export function Inspector({
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
className={inputCls}
|
||||
wrapperClassName="flex-1"
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
onBlur={commitGrid}
|
||||
/>
|
||||
<div className="field">
|
||||
<label>Snap plants</label>
|
||||
<Toggle on={object.snapToGrid} label="Snap plants to the bed grid" disabled={!canEdit} onChange={(v) => patch({ snapToGrid: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.snapToGrid}
|
||||
onChange={(e) => patch({ snapToGrid: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
Snap plants
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextArea
|
||||
label="Notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{!readOnly &&
|
||||
(confirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
select(null)
|
||||
del.mutate(object.id)
|
||||
}}
|
||||
>
|
||||
Confirm delete
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" className="text-red-600 dark:text-red-400" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete object
|
||||
</Button>
|
||||
))}
|
||||
)}
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
|
||||
</fieldset>
|
||||
</Details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected planting: its plant, "N plants · 6″ patch", and Pull it out.
|
||||
* Details hold the radius, a count override, a label, the planted date, and
|
||||
* the plant itself (swap it for another without re-placing).
|
||||
*/
|
||||
export function PlopInspector({
|
||||
plop,
|
||||
plant,
|
||||
plants,
|
||||
gardenId,
|
||||
unit,
|
||||
canEdit,
|
||||
noteCount,
|
||||
large,
|
||||
onNotes,
|
||||
onRemoved,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant: Plant | undefined
|
||||
plants: Plant[]
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
canEdit: boolean
|
||||
noteCount: number
|
||||
large?: boolean
|
||||
onNotes: () => void
|
||||
onRemoved: () => void
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
const remove = useRemovePlanting(gardenId)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const p = livePlanting && livePlanting.id === plop.id ? livePlanting : plop
|
||||
const [details, setDetails] = useState(false)
|
||||
const [radius, setRadius] = useState(String(spacingFromCm(p.radiusCm, unit)))
|
||||
const [count, setCount] = useState(p.count != null ? String(p.count) : '')
|
||||
const [label, setLabel] = useState(p.label ?? '')
|
||||
const [planted, setPlanted] = useState(p.plantedAt ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (rootRef.current?.contains(document.activeElement)) return
|
||||
setRadius(String(spacingFromCm(p.radiusCm, unit)))
|
||||
setCount(p.count != null ? String(p.count) : '')
|
||||
setLabel(p.label ?? '')
|
||||
setPlanted(p.plantedAt ?? '')
|
||||
}, [p, unit])
|
||||
|
||||
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) => {
|
||||
if (!canEdit) return
|
||||
update.mutate({ id: plop.id, version: plop.version, ...fields })
|
||||
}
|
||||
const n = plopCount(p, plant)
|
||||
const inputCls = cn(large && 'input-lg')
|
||||
const sorted = useMemo(() => [...plants].sort((a, b) => a.name.localeCompare(b.name)), [plants])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ColorDot color={plant?.color ?? '#97a97c'} size={18} />
|
||||
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
|
||||
{noteCount > 0 && (
|
||||
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
|
||||
{noteCount} {noteCount === 1 ? 'note' : 'notes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[13px] text-ink-soft">
|
||||
{n} {n === 1 ? 'plant' : 'plants'} · {formatLength(p.radiusCm * 2, unit)} patch
|
||||
{p.label ? ` · ${p.label}` : ''}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
tall={large}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onRemoved()
|
||||
remove.mutate({ id: plop.id, version: plop.version })
|
||||
}}
|
||||
>
|
||||
Pull it out
|
||||
</Button>
|
||||
)}
|
||||
{noteCount === 0 && (
|
||||
<button type="button" className="btn btn-ghost gap-1.5 text-[13px]" onClick={onNotes}>
|
||||
<Icon name="notebook" size={13} />
|
||||
{canEdit ? 'Add a note' : 'Notes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Details open={details} onToggle={() => setDetails((v) => !v)}>
|
||||
<fieldset disabled={!canEdit} className="contents">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Radius (${spacingUnitLabel(unit)})`}
|
||||
name="radius"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
className={inputCls}
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = Number(radius)
|
||||
if (radius.trim() === '' || !Number.isFinite(v)) return setRadius(String(spacingFromCm(p.radiusCm, unit)))
|
||||
const cm = Math.max(1, cmFromSpacing(v, unit))
|
||||
if (cm !== p.radiusCm) patch({ radiusCm: cm })
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Count"
|
||||
name="count"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
className={inputCls}
|
||||
placeholder={`${plant ? plopCount({ ...p, count: null }, plant) : p.derivedCount} (auto)`}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (count.trim() === '') return p.count != null ? patch({ count: null }) : undefined
|
||||
const c = Number(count)
|
||||
if (!Number.isInteger(c) || c < 1) return setCount(p.count != null ? String(p.count) : '')
|
||||
if (c !== p.count) patch({ count: c })
|
||||
}}
|
||||
hint={p.count != null ? 'An override — clear it for auto.' : 'Auto from area ÷ spacing².'}
|
||||
/>
|
||||
</div>
|
||||
<TextField label="Label (optional)" name="label" className={inputCls} value={label} onChange={(e) => setLabel(e.target.value)} onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })} />
|
||||
<TextField
|
||||
label="Planted"
|
||||
name="planted"
|
||||
type="date"
|
||||
className={inputCls}
|
||||
value={planted}
|
||||
onChange={(e) => setPlanted(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = planted === '' ? null : planted
|
||||
if (next !== (p.plantedAt ?? null)) patch({ plantedAt: next })
|
||||
}}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor={`plant-${plop.id}`}>Plant</label>
|
||||
<select id={`plant-${plop.id}`} className={cn('input', inputCls)} value={plop.plantId} onChange={(e) => patch({ plantId: Number(e.target.value) })}>
|
||||
{!sorted.some((x) => x.id === plop.plantId) && <option value={plop.plantId}>{plant?.name ?? 'Unknown plant'}</option>}
|
||||
{sorted.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The rail's resting state: what the whole garden holds. */
|
||||
export function GardenSummary({
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
canEdit,
|
||||
}: {
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const o of objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
|
||||
const countsText = [
|
||||
...['bed', 'grow_bag', 'container', 'in_ground', 'tree'].filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!)),
|
||||
`${plantings.length} ${plantings.length === 1 ? 'planting' : 'plantings'}`,
|
||||
].join(' · ')
|
||||
const roster = new Map<number, { name: string; color: string; n: number; beds: string[] }>()
|
||||
const byId = new Map(objects.map((o) => [o.id, o]))
|
||||
for (const p of plantings) {
|
||||
const o = byId.get(p.objectId)
|
||||
const plant = plantsById.get(p.plantId)
|
||||
if (!o || !plant) continue
|
||||
const r = roster.get(plant.id) ?? { name: plant.name, color: plant.color, n: 0, beds: [] }
|
||||
r.n += plopCount(p, plant)
|
||||
if (!r.beds.includes(o.name)) r.beds.push(o.name)
|
||||
roster.set(plant.id, r)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-0.5">This garden</h5>
|
||||
<div className="text-[13px] leading-relaxed text-ink-soft">{objects.length === 0 ? 'Bare ground so far.' : countsText}</div>
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
{[...roster.values()].map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-[9px]">
|
||||
<ColorDot color={r.color} size={13} />
|
||||
<span className="min-w-0 truncate text-[13px] font-semibold">{r.name}</span>
|
||||
<span className="ml-auto flex-none text-xs text-ink-mute">
|
||||
{r.n} in {r.beds[0]}
|
||||
{r.beds.length > 1 ? ` +${r.beds.length - 1}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto pt-2.5 text-xs leading-relaxed text-ink-mute">
|
||||
{canEdit ? 'Select anything on the plan to edit it here. Double-click a bed to plant it.' : 'Select anything on the plan to see it here.'}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
formatObservedAt,
|
||||
today,
|
||||
useCreateJournalEntry,
|
||||
useDeleteJournalEntry,
|
||||
useJournal,
|
||||
useUpdateJournalEntry,
|
||||
type JournalEntry,
|
||||
} from '@/lib/journal'
|
||||
import type { EditorObject } from './types'
|
||||
import { objectDisplayName } from './kinds'
|
||||
|
||||
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||
// don't drift from each other.
|
||||
const dateInputClass =
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
|
||||
/**
|
||||
* The garden's journal: write an entry, read the season back.
|
||||
*
|
||||
* Notes get written standing in the garden holding a phone, usually one-handed.
|
||||
* If it takes more than a couple of taps from looking at a bed to typing a
|
||||
* sentence, the log stays empty — so the composer is open by default rather than
|
||||
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
|
||||
*/
|
||||
export function JournalPanel({
|
||||
gardenId,
|
||||
canEdit,
|
||||
currentUserId,
|
||||
isOwner,
|
||||
objects,
|
||||
scopeObjectId,
|
||||
onScopeChange,
|
||||
scopePlantingId,
|
||||
onScopePlantingChange,
|
||||
}: {
|
||||
gardenId: number
|
||||
canEdit: boolean
|
||||
currentUserId?: number
|
||||
isOwner: boolean
|
||||
objects: EditorObject[]
|
||||
/** Which bed the panel is filtered to, if any. */
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
/** Which single plop the panel is filtered to, if any (#85). The store keeps
|
||||
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
|
||||
scopePlantingId: number | null
|
||||
onScopePlantingChange: (id: number | null) => void
|
||||
}) {
|
||||
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||
// from/to; they just had no UI. Empty inputs don't filter.
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
// One source of scope priority — plop over bed — for both the filter and the
|
||||
// composer's label, so they can't drift.
|
||||
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
|
||||
const filter = {
|
||||
...(scopePlantingId != null
|
||||
? { plantingId: scopePlantingId }
|
||||
: scopeObjectId != null
|
||||
? { objectId: scopeObjectId }
|
||||
: {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-fg">Journal</h2>
|
||||
{objects.length > 0 && (
|
||||
<select
|
||||
value={scopeObjectId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value)
|
||||
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
|
||||
}}
|
||||
className="max-w-[9rem] truncate rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<option value="">Whole garden</option>
|
||||
{objects.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{objectDisplayName(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scopePlantingId != null && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
|
||||
<span className="text-accent-strong">Notes about one planting</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onScopePlantingChange(null)}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
}}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
objectId={scopePlantingId != null ? null : scopeObjectId}
|
||||
plantingId={scopePlantingId}
|
||||
scopeLabel={scopeLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{journal.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{journal.isError && entries.length === 0 && (
|
||||
<Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>
|
||||
)}
|
||||
|
||||
{journal.isSuccess && entries.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
{scopePlantingId != null
|
||||
? 'Nothing written about this planting yet.'
|
||||
: scopedObject
|
||||
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
|
||||
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{entries.map((e) => (
|
||||
<Entry
|
||||
key={e.id}
|
||||
entry={e}
|
||||
gardenId={gardenId}
|
||||
objects={objects}
|
||||
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
|
||||
canRewrite={canEdit && e.authorId === currentUserId}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{journal.hasNextPage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={journal.isFetchingNextPage}
|
||||
onClick={() => void journal.fetchNextPage()}
|
||||
>
|
||||
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
)}
|
||||
{journal.isError && entries.length > 0 && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
{errorMessage(journal.error, "Couldn't load older entries.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Composer({
|
||||
gardenId,
|
||||
objectId,
|
||||
plantingId = null,
|
||||
scopeLabel,
|
||||
}: {
|
||||
gardenId: number
|
||||
objectId: number | null
|
||||
/** When set, the note attaches to this plop rather than a bed (#85). */
|
||||
plantingId?: number | null
|
||||
scopeLabel: string | null
|
||||
}) {
|
||||
const create = useCreateJournalEntry(gardenId)
|
||||
const [body, setBody] = useState('')
|
||||
// Editable so an observation can be backdated: you write up Saturday on Sunday.
|
||||
const [observedAt, setObservedAt] = useState(today())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const submit = () => {
|
||||
const text = body.trim()
|
||||
if (!text) return
|
||||
setError(null)
|
||||
create.mutate(
|
||||
{ body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setBody('')
|
||||
setObservedAt(today())
|
||||
},
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
|
||||
<TextArea
|
||||
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
|
||||
name="journalBody"
|
||||
rows={3}
|
||||
placeholder="What happened?"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
label="Observed"
|
||||
name="observedAt"
|
||||
type="date"
|
||||
value={observedAt}
|
||||
onChange={(e) => setObservedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
|
||||
{create.isPending ? 'Saving…' : 'Save note'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Entry({
|
||||
entry,
|
||||
gardenId,
|
||||
objects,
|
||||
canDelete,
|
||||
canRewrite,
|
||||
}: {
|
||||
entry: JournalEntry
|
||||
gardenId: number
|
||||
objects: EditorObject[]
|
||||
/** May remove it: the author, or the garden owner. */
|
||||
canDelete: boolean
|
||||
/** May rewrite the text: the author only — rewriting someone else's
|
||||
* observation under their name is a different act from removing it. */
|
||||
canRewrite: boolean
|
||||
}) {
|
||||
const update = useUpdateJournalEntry(gardenId)
|
||||
const del = useDeleteJournalEntry(gardenId)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(entry.body)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const about = objects.find((o) => o.id === entry.objectId) ?? null
|
||||
|
||||
// Re-sync the draft when the entry changes underneath — a refetch after
|
||||
// someone else's edit, or this entry's own successful save. Skipped while
|
||||
// editing so a background refetch can't clobber what's being typed; the
|
||||
// version guard is what catches a genuine collision.
|
||||
const [syncedBody, setSyncedBody] = useState(entry.body)
|
||||
if (!editing && entry.body !== syncedBody) {
|
||||
setSyncedBody(entry.body)
|
||||
setDraft(entry.body)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const text = draft.trim()
|
||||
if (!text) {
|
||||
// Silence here reads as a broken button; say why nothing happened.
|
||||
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
update.mutate(
|
||||
{ id: entry.id, version: entry.version, body: text },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
},
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setDraft(entry.body)
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
|
||||
{update.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{entry.authorName}</span>
|
||||
{about && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
|
||||
</>
|
||||
)}
|
||||
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
|
||||
</p>
|
||||
|
||||
{(canRewrite || canDelete) && !editing && (
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
{canRewrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
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>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
setError(null) // clear a previous failure so a retry isn't read as another
|
||||
del.mutate(entry.id, {
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
|
||||
})
|
||||
}}
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
formatObservedAt,
|
||||
today,
|
||||
useCreateJournalEntry,
|
||||
useDeleteJournalEntry,
|
||||
useJournal,
|
||||
useUpdateJournalEntry,
|
||||
type JournalEntry,
|
||||
type JournalFilter,
|
||||
} from '@/lib/journal'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { objectDisplayName } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/** What a new note attaches to: the selection, else the focused bed, else the
|
||||
* garden — and how to say it. */
|
||||
export interface JournalAttach {
|
||||
objectId?: number
|
||||
plantingId?: number
|
||||
label: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The grow journal: entries newest first as cards (what it's about, when, the
|
||||
* note), a one-line composer that attaches to whatever's selected — Enter
|
||||
* submits — and, when the rail was opened from a bed's "N notes", a filter chip
|
||||
* narrowing the list to that thing. Notes get written standing in the garden
|
||||
* holding a phone, so the composer is never more than a tap away.
|
||||
*/
|
||||
export function JournalTab({
|
||||
gardenId,
|
||||
canEdit,
|
||||
currentUserId,
|
||||
isOwner,
|
||||
objects,
|
||||
plantings,
|
||||
attach,
|
||||
composerFirst = false,
|
||||
large = false,
|
||||
}: {
|
||||
gardenId: number
|
||||
canEdit: boolean
|
||||
currentUserId?: number
|
||||
isOwner: boolean
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
attach: JournalAttach
|
||||
/** Phone: the input above the entries (the thumb is at the bottom anyway). */
|
||||
composerFirst?: boolean
|
||||
large?: boolean
|
||||
}) {
|
||||
const scope = useEditorStore((s) => s.journalScope)
|
||||
const setScope = useEditorStore((s) => s.setJournalScope)
|
||||
const filter: JournalFilter = scope?.type === 'object' ? { objectId: scope.id } : scope?.type === 'plop' ? { plantingId: scope.id } : {}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
const scopeName =
|
||||
scope?.type === 'object'
|
||||
? objectDisplayName(objects.find((o) => o.id === scope.id) ?? { name: '', kind: 'object' })
|
||||
: scope?.type === 'plop'
|
||||
? 'one planting'
|
||||
: null
|
||||
|
||||
const composer = canEdit && (
|
||||
<Composer gardenId={gardenId} attach={attach} large={large} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||
{scopeName && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-accent-2-200 py-1 pl-3.5 pr-1 text-xs font-semibold text-accent-2-800">
|
||||
Notes about {scopeName}
|
||||
<IconButton label="Show the whole journal" icon="x" iconSize={12} variant="plain" size={26} className="ml-auto" onClick={() => setScope(null)} />
|
||||
</div>
|
||||
)}
|
||||
{composerFirst && composer}
|
||||
{journal.isPending && <p className="text-[13px] text-ink-mute">Loading…</p>}
|
||||
{journal.isError && entries.length === 0 && <Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>}
|
||||
{journal.isSuccess && entries.length === 0 && (
|
||||
<p className="text-[13px] leading-relaxed text-ink-mute">
|
||||
{scopeName
|
||||
? `Nothing written about ${scopeName} yet.`
|
||||
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||
</p>
|
||||
)}
|
||||
{entries.map((e) => (
|
||||
<Entry
|
||||
key={e.id}
|
||||
entry={e}
|
||||
gardenId={gardenId}
|
||||
objects={objects}
|
||||
plantings={plantings}
|
||||
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
|
||||
canRewrite={canEdit && e.authorId === currentUserId}
|
||||
large={large}
|
||||
/>
|
||||
))}
|
||||
{journal.hasNextPage && (
|
||||
<Button variant="ghost" className="self-start text-[13px]" disabled={journal.isFetchingNextPage} onClick={() => void journal.fetchNextPage()}>
|
||||
{journal.isFetchingNextPage ? 'Loading…' : 'Older notes'}
|
||||
</Button>
|
||||
)}
|
||||
{!composerFirst && <div className="mt-auto pt-1.5">{composer}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Composer({ gardenId, attach, large }: { gardenId: number; attach: JournalAttach; large: boolean }) {
|
||||
const create = useCreateJournalEntry(gardenId)
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const submit = () => {
|
||||
const text = body.trim()
|
||||
if (!text || create.isPending) return
|
||||
setError(null)
|
||||
create.mutate(
|
||||
{ body: text, observedAt: today(), objectId: attach.objectId, plantingId: attach.plantingId },
|
||||
{ onSuccess: () => setBody(''), onError: (err) => setError(errorMessage(err, "Couldn't save that note.")) },
|
||||
)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className={cn('input', large && 'input-lg')}
|
||||
placeholder={attach.label ? `Note something about ${attach.label}…` : 'Note something…'}
|
||||
aria-label="New journal note"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
<IconButton label="Log it" icon="plus" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={create.isPending || !body.trim()} onClick={submit} />
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Entry({
|
||||
entry,
|
||||
gardenId,
|
||||
objects,
|
||||
plantings,
|
||||
canDelete,
|
||||
canRewrite,
|
||||
large,
|
||||
}: {
|
||||
entry: JournalEntry
|
||||
gardenId: number
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
canDelete: boolean
|
||||
canRewrite: boolean
|
||||
large: boolean
|
||||
}) {
|
||||
const update = useUpdateJournalEntry(gardenId)
|
||||
const del = useDeleteJournalEntry(gardenId)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(entry.body)
|
||||
const [observedAt, setObservedAt] = useState(entry.observedAt)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// A plop-level note names its bed; an object-level note names the object.
|
||||
const objectId = entry.objectId ?? (entry.plantingId != null ? plantings.find((p) => p.id === entry.plantingId)?.objectId : undefined)
|
||||
const about = objects.find((o) => o.id === objectId)
|
||||
const aboutLabel = about ? objectDisplayName(about) + (entry.plantingId != null ? ' · planting' : '') : entry.plantingId != null ? 'a planting' : 'Garden'
|
||||
|
||||
const save = () => {
|
||||
const text = draft.trim()
|
||||
if (!text) return setError("An entry needs some text — delete it instead if that's what you meant.")
|
||||
setError(null)
|
||||
update.mutate(
|
||||
{ id: entry.id, version: entry.version, body: text, observedAt },
|
||||
{ onSuccess: () => setEditing(false), onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")) },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-divider bg-bg px-3.5 py-3">
|
||||
<div className="mb-[5px] flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-bold text-accent-2-700">{aboutLabel}</span>
|
||||
<span className="ml-auto text-[11.5px] text-ink-mute" title={`by ${entry.authorName}`}>
|
||||
{formatObservedAt(entry.observedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextAreaField label="Note" name={`entry-${entry.id}`} rows={3} className={cn(large && 'text-base')} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
||||
<TextField label="Observed on" name={`observed-${entry.id}`} type="date" value={observedAt} onChange={(e) => setObservedAt(e.target.value)} />
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2.5 text-[13px]"
|
||||
onClick={() => {
|
||||
setDraft(entry.body)
|
||||
setObservedAt(entry.observedAt)
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" className="px-3.5 text-[13px]" disabled={update.isPending} onClick={save}>
|
||||
{update.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('whitespace-pre-wrap leading-relaxed', large ? 'text-[13.5px]' : 'text-[13px]')}>{entry.body}</div>
|
||||
)}
|
||||
{(canRewrite || canDelete) && !editing && (
|
||||
<div className="-mb-1 mt-1 flex justify-end gap-0.5">
|
||||
{canRewrite && (
|
||||
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost px-2 py-0.5 text-[11.5px] text-accent-700"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
del.mutate(entry.id, { onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")) })
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-1 text-xs font-semibold text-accent-700">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { kindStyle, type KindDef } from './kinds'
|
||||
|
||||
/** A kind's mini shape swatch: true proportions, its own fill and stroke — the
|
||||
* toolkit's "icon" is the thing itself, not a glyph. */
|
||||
export function KindSwatch({ def, compact = false }: { def: KindDef; compact?: boolean }) {
|
||||
const st = kindStyle(def.kind)
|
||||
const f = 24 / Math.max(def.widthCm, def.heightCm)
|
||||
const w = def.widthCm * f
|
||||
const h = def.heightCm * f
|
||||
return (
|
||||
<svg
|
||||
width={compact ? 30 : 38}
|
||||
height={compact ? 24 : 30}
|
||||
viewBox={compact ? '-20 -13 40 26' : '-20 -15 40 30'}
|
||||
className="flex-none"
|
||||
aria-hidden
|
||||
>
|
||||
{def.shape === 'rect' ? (
|
||||
<rect x={-w / 2} y={-h / 2} width={w} height={h} rx={3} fill={st.fill} stroke={st.stroke} strokeWidth={1.5} strokeDasharray={st.dash ? '3 3' : undefined} />
|
||||
) : (
|
||||
<circle r={w / 2} fill={st.fill} stroke={st.stroke} strokeWidth={1.5} strokeDasharray={st.dash ? '3 3' : undefined} />
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -8,33 +8,20 @@ const remarkPlugins = [remarkGfm]
|
||||
const CODE_BLOCK = /language-/
|
||||
|
||||
// The assistant's replies are never trusted markup: their content can be steered
|
||||
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
|
||||
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
|
||||
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
|
||||
// (``); the assistant has no reason to emit images.
|
||||
// by anything the agent read. So we render Markdown but NOT raw HTML (no
|
||||
// rehype-raw), and forbid <img>, whose auto-loading `src` is a prompt-injection
|
||||
// exfiltration beacon; the assistant has no reason to emit images.
|
||||
const disallowedElements = ['img']
|
||||
|
||||
// Tailwind's reset strips default list/table styling, so every element the
|
||||
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
|
||||
// content (tables, code) scrolls in its own box so the bubble never blows out.
|
||||
const bigHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
|
||||
)
|
||||
const bigHeading = ({ children }: { children?: ReactNode }) => <h4 className="mb-1 mt-2 text-[15px] first:mt-0">{children}</h4>
|
||||
const smallHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
|
||||
{children}
|
||||
</h5>
|
||||
<h6 className="mb-1 mt-1.5 text-[11px] text-ink-mute first:mt-0">{children}</h6>
|
||||
)
|
||||
|
||||
const components: Components = {
|
||||
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-strong underline underline-offset-2"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
@@ -45,7 +32,7 @@ const components: Components = {
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
h1: bigHeading,
|
||||
h2: bigHeading,
|
||||
@@ -53,40 +40,27 @@ const components: Components = {
|
||||
h4: smallHeading,
|
||||
h5: smallHeading,
|
||||
h6: smallHeading,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
blockquote: ({ children }) => <blockquote className="my-1.5 border-l-2 border-accent-2-400 pl-2 text-ink-soft">{children}</blockquote>,
|
||||
hr: () => <hr className="my-2 border-divider" />,
|
||||
code: ({ className, children }) => {
|
||||
// A fenced block is wrapped by <pre> (styled below) and carries either a
|
||||
// language- class or a trailing newline; inline code is a single-line bare
|
||||
// <code> and gets the pill treatment. (The newline check catches fences with
|
||||
// no info-string, which have no language- class.)
|
||||
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
|
||||
if (isBlock) return <code className={className}>{children}</code>
|
||||
return (
|
||||
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
)
|
||||
return <code className="rounded-sm bg-neutral-200 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
pre: ({ children }) => <pre className="my-1.5 overflow-x-auto rounded-md bg-neutral-200 p-2 font-mono text-xs">{children}</pre>,
|
||||
table: ({ children }) => (
|
||||
<div className="my-1.5 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
|
||||
// style.textAlign, and dropping it would silently discard it.
|
||||
// Pass `style` through: GFM column alignment arrives as style.textAlign.
|
||||
th: ({ children, style }) => (
|
||||
<th style={style} className="border border-border px-2 py-1 font-semibold">
|
||||
<th style={style} className="border border-divider px-2 py-1 font-bold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, style }) => (
|
||||
<td style={style} className="border border-border px-2 py-1 align-top">
|
||||
<td style={style} className="border border-divider px-2 py-1 align-top">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
@@ -97,11 +71,7 @@ const components: Components = {
|
||||
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
disallowedElements={disallowedElements}
|
||||
components={components}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} disallowedElements={disallowedElements} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { objectTransform } from './shared'
|
||||
import { kindDef, objectDisplayName } from './kinds'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_FILL = '#8a8a8a'
|
||||
|
||||
// Default fills by kind (overridable per object via object.color). Muted, earthy
|
||||
// tones so plops (added in #15) read clearly on top.
|
||||
const kindColors: Record<string, string> = {
|
||||
bed: '#8a6d4b',
|
||||
grow_bag: '#9c7a52',
|
||||
container: '#6b7f8a',
|
||||
in_ground: '#7a6a4a',
|
||||
tree: '#4f7a4f',
|
||||
path: '#b8b0a0',
|
||||
structure: DEFAULT_FILL,
|
||||
}
|
||||
|
||||
// Label font size = this fraction of the object's smaller side, clamped to a
|
||||
// readable cm range. Corner radius = this fraction of the smaller half-side.
|
||||
const LABEL_FONT_FACTOR = 0.28
|
||||
const LABEL_FONT_MIN_CM = 8
|
||||
const LABEL_FONT_MAX_CM = 40
|
||||
const CORNER_RADIUS_FACTOR = 0.06
|
||||
|
||||
function fillFor(o: EditorObject): string {
|
||||
return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL
|
||||
}
|
||||
|
||||
/**
|
||||
* One garden object in world (cm) space: a centered rect or ellipse (a circle
|
||||
* when width == height), rotated about its center, with the object's name. The
|
||||
* parent world <g> applies the viewport scale, so geometry is authored in cm and
|
||||
* strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at
|
||||
* any zoom. memo'd so a pan/zoom (which only changes the world <g> transform)
|
||||
* doesn't re-render every object. Full move/resize/rotate come in #11.
|
||||
*/
|
||||
export const ObjectShape = memo(function ObjectShape({
|
||||
object,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
object: EditorObject
|
||||
selected: boolean
|
||||
onSelect: (id: number) => void
|
||||
}) {
|
||||
const fill = fillFor(object)
|
||||
const halfW = Math.max(0, object.widthCm / 2)
|
||||
const halfH = Math.max(0, object.heightCm / 2)
|
||||
const fontCm = Math.max(
|
||||
LABEL_FONT_MIN_CM,
|
||||
Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR),
|
||||
)
|
||||
|
||||
function handleDown(e: PointerEvent) {
|
||||
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
|
||||
onSelect(object.id)
|
||||
}
|
||||
|
||||
// Keyboard path into selection (#84): the arrow-key nudge handler already
|
||||
// exists but only ever acted on a pointer selection, so it was unreachable
|
||||
// without a mouse. Enter/Space on a focused object selects it, which is the
|
||||
// step that was missing.
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelect(object.id)
|
||||
}
|
||||
}
|
||||
|
||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||
const strokeWidth = selected ? 2 : 1
|
||||
|
||||
// A concise accessible name: the object's label plus its kind's canonical
|
||||
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||
// dimensions aren't included; they need the garden's unit context this
|
||||
// component doesn't hold, so they're a follow-up.
|
||||
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||
|
||||
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
|
||||
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
|
||||
// rather than React state because onFocus on an SVG <g> is unreliable and a
|
||||
// presentation attribute is overridden by any CSS rule.
|
||||
return (
|
||||
<g
|
||||
className="object-shape"
|
||||
transform={objectTransform(object)}
|
||||
onPointerDown={handleDown}
|
||||
onKeyDown={handleKey}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||
// item among the objects. Omitted, not "false", when unselected.
|
||||
aria-current={selected || undefined}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse
|
||||
cx={0}
|
||||
cy={0}
|
||||
rx={halfW}
|
||||
ry={halfH}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={-halfW}
|
||||
y={-halfH}
|
||||
width={halfW * 2}
|
||||
height={halfH * 2}
|
||||
rx={Math.min(halfW, halfH) * CORNER_RADIUS_FACTOR}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{object.name && (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize={fontCm}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="#ffffff"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{object.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { OBJECT_KINDS, kindDef } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
/**
|
||||
* The object-kind palette. Tap a kind to arm it, then tap the canvas to place
|
||||
* (works on desktop and touch). Tapping the armed kind again disarms it.
|
||||
*/
|
||||
export function Palette() {
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 md:flex-col">
|
||||
{OBJECT_KINDS.map((k) => {
|
||||
const active = armedKind === k.kind
|
||||
return (
|
||||
<button
|
||||
key={k.kind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
select(null)
|
||||
setArmedKind(active ? null : k.kind)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong'
|
||||
: 'border-border bg-surface text-fg hover:bg-border/50',
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span aria-hidden className="text-base leading-none">
|
||||
{k.icon}
|
||||
</span>
|
||||
<span>{k.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{armedKind && (
|
||||
<p className="text-xs text-muted">Tap the canvas to place a {kindDef(armedKind)?.label ?? 'object'}.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass } from '@/components/ui/field'
|
||||
import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import {
|
||||
attributableLots,
|
||||
formatQuantity,
|
||||
lotsByPlant,
|
||||
lotState,
|
||||
summarizeLots,
|
||||
useSeedLots,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
import { LotStateChip } from '@/components/plants/SeedLotList'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
|
||||
const RECENT_KEY = 'pansy:recent-plants'
|
||||
const RECENT_MAX = 8
|
||||
|
||||
/** Recently-picked plant ids, most-recent first, from localStorage. */
|
||||
function loadRecent(): number[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
|
||||
return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepend id (dedup, capped) and persist; returns the new list. */
|
||||
function recordRecent(id: number): number[] {
|
||||
const next = [id, ...loadRecent().filter((n) => n !== id)].slice(0, RECENT_MAX)
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// Recents are a nicety; ignore quota/availability failures.
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable plant chooser: a search-first list with a recently-used shortcut and
|
||||
* category filter, rendered as a bottom sheet on mobile / centered panel on
|
||||
* desktop. `onSelect` fires with the chosen plant (and records it as recent).
|
||||
* The plops editor (#15) opens this when placing plants; /plants demos it.
|
||||
*/
|
||||
export function PlantPicker({
|
||||
onSelect,
|
||||
onClose,
|
||||
unit = 'metric',
|
||||
}: {
|
||||
// The chosen plant, and the lot it should be attributed to when that isn't
|
||||
// ambiguous. Undefined lot means "don't attribute" — which is the honest
|
||||
// answer when there are no lots, and the deliberate one when the user skips.
|
||||
onSelect: (plant: Plant, lot?: SeedLot) => void
|
||||
onClose: () => void
|
||||
unit?: UnitPref
|
||||
}) {
|
||||
const plants = usePlants()
|
||||
const seedLots = useSeedLots()
|
||||
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
// Set when a plant with SEVERAL lots is picked: which packet did this come out
|
||||
// of? One lot auto-attributes and zero lots stays silent, because forcing that
|
||||
// question on every placement is how a nicety becomes an obstacle.
|
||||
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus()
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCloseRef.current()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const all = useMemo(() => plants.data ?? [], [plants.data])
|
||||
|
||||
const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category])
|
||||
|
||||
// Recents show only when not actively searching, and only rows still present.
|
||||
const recentPlants = useMemo(() => {
|
||||
if (query.trim() !== '') return []
|
||||
const byId = new Map(all.map((p) => [p.id, p]))
|
||||
return recent.map((id) => byId.get(id)).filter((p): p is Plant => !!p)
|
||||
}, [all, recent, query])
|
||||
|
||||
function choose(p: Plant) {
|
||||
const lots = attributableLots(lotsFor.get(p.id) ?? [])
|
||||
if (lots.length > 1) {
|
||||
setChoosingLotFor(p)
|
||||
return
|
||||
}
|
||||
setRecent(recordRecent(p.id))
|
||||
// A single lot attributes even when its count says empty. The count records
|
||||
// what was written down, not a fact about the packet — dropping attribution
|
||||
// there would make a wrong number harder to correct rather than easier.
|
||||
onSelect(p, lots[0])
|
||||
}
|
||||
|
||||
function chooseLot(p: Plant, lot?: SeedLot) {
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p, lot)
|
||||
}
|
||||
|
||||
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
||||
// both the Recent and All sections doesn't collide on a duplicate React key.
|
||||
const row = (p: Plant, keyPrefix: string) => (
|
||||
<button
|
||||
key={`${keyPrefix}-${p.id}`}
|
||||
type="button"
|
||||
onClick={() => choose(p)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
<PlantIcon color={p.color} icon={p.icon} className="h-9 w-9 rounded-md text-xl" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-fg">{p.name}</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
||||
{remainingLabel(lotsFor.get(p.id) ?? [])}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
// What's left, shown where you're deciding what to plant. Silent when there
|
||||
// are no lots — most plants won't have any, and "0 left" on all of them would
|
||||
// be noise that trains you to ignore the number. summarizeLots owns the
|
||||
// unit-agreement rule; a second copy of it here would drift.
|
||||
function remainingLabel(lots: SeedLot[]): string {
|
||||
if (lots.length === 0) return ''
|
||||
const { remaining, unit } = summarizeLots(lots)
|
||||
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
||||
onMouseDown={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Choose a plant"
|
||||
className="flex max-h-[85vh] w-full flex-col rounded-t-2xl border border-border bg-surface shadow-xl sm:max-w-lg sm:rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border p-3">
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search plants…"
|
||||
aria-label="Search plants"
|
||||
className={cn(fieldControlClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md px-2 py-2 text-muted outline-none transition-colors hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
||||
</div>
|
||||
|
||||
{choosingLotFor && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Which lot of {choosingLotFor.name}?
|
||||
</p>
|
||||
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, lot)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg">
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
|
||||
</span>
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, undefined)}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
Don't attribute to a lot
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
|
||||
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
|
||||
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
|
||||
|
||||
{recentPlants.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent</p>
|
||||
{recentPlants.map((p) => row(p, 'recent'))}
|
||||
<p className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted">All plants</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filtered.map((p) => row(p, 'all'))}
|
||||
|
||||
{plants.isSuccess && filtered.length === 0 && (
|
||||
<p className="p-4 text-sm text-muted">No plants match your search.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { useRemovePlanting, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
import { MIN_RADIUS_CM } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
/**
|
||||
* Property panel for the selected plop. Radius/label/date/count commit a PATCH on
|
||||
* blur (carrying the version); the count field shows the live derived value as a
|
||||
* placeholder and takes an override when typed. "Remove" soft-removes (keeps the
|
||||
* row with removed_at); "Change plant" opens the picker via onChangePlant. While
|
||||
* the plop is dragged/resized on the canvas, it reads livePlanting for live
|
||||
* feedback (subscribed here, not at the page level, so a drag only re-renders
|
||||
* this panel).
|
||||
*/
|
||||
export function PlopInspector({
|
||||
plop,
|
||||
plant,
|
||||
gardenId,
|
||||
unit,
|
||||
onChangePlant,
|
||||
onClose,
|
||||
onAddNote,
|
||||
readOnly = false,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant?: Plant
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
onChangePlant: () => void
|
||||
onClose: () => void
|
||||
/** Scope the journal to this plop and open it — the plop parallel of the bed
|
||||
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
|
||||
* notes, like the bed inspector does); the journal's composer is separately
|
||||
* gated on edit rights, so a viewer just sees the entries. */
|
||||
onAddNote?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
const remove = useRemovePlanting(gardenId)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// The plop with any in-flight drag/resize geometry applied.
|
||||
const p = livePlanting && livePlanting.id === plop.id ? livePlanting : plop
|
||||
|
||||
const [radius, setRadius] = useState(String(spacingFromCm(p.radiusCm, unit)))
|
||||
const [count, setCount] = useState(p.count != null ? String(p.count) : '')
|
||||
const [label, setLabel] = useState(p.label ?? '')
|
||||
const [planted, setPlanted] = useState(p.plantedAt ?? '')
|
||||
|
||||
// Re-sync when the plop changes underneath us (a drag/resize or a server row),
|
||||
// unless the user is editing a field here.
|
||||
useEffect(() => {
|
||||
if (rootRef.current?.contains(document.activeElement)) return
|
||||
setRadius(String(spacingFromCm(p.radiusCm, unit)))
|
||||
setCount(p.count != null ? String(p.count) : '')
|
||||
setLabel(p.label ?? '')
|
||||
setPlanted(p.plantedAt ?? '')
|
||||
}, [p.version, p.radiusCm, p.count, p.label, p.plantedAt, unit])
|
||||
|
||||
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) => {
|
||||
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
|
||||
update.mutate({ id: plop.id, version: plop.version, ...fields })
|
||||
}
|
||||
|
||||
const u = spacingUnitLabel(unit)
|
||||
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
||||
|
||||
function commitRadius() {
|
||||
const v = Number(radius)
|
||||
if (radius.trim() === '' || !Number.isFinite(v)) {
|
||||
setRadius(String(spacingFromCm(p.radiusCm, unit))) // reset stale/invalid text
|
||||
return
|
||||
}
|
||||
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
|
||||
if (cm !== p.radiusCm) patch({ radiusCm: cm })
|
||||
}
|
||||
|
||||
function commitCount() {
|
||||
if (count.trim() === '') {
|
||||
if (p.count != null) patch({ count: null }) // restore derived
|
||||
return
|
||||
}
|
||||
const n = Number(count)
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
setCount(p.count != null ? String(p.count) : '') // reject invalid, restore
|
||||
return
|
||||
}
|
||||
if (n !== p.count) patch({ count: n })
|
||||
}
|
||||
|
||||
function commitPlanted() {
|
||||
const next = planted === '' ? null : planted
|
||||
if (next !== (p.plantedAt ?? null)) patch({ plantedAt: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg">Plant</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded px-1.5 text-sm text-muted hover:text-fg"
|
||||
aria-label="Close inspector"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">View only — you can't edit this garden.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border p-2">
|
||||
{plant ? (
|
||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-9 w-9 rounded-md text-xl" />
|
||||
) : (
|
||||
<span className="grid h-9 w-9 place-items-center rounded-md bg-border/50 text-muted">?</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">{plant?.name ?? 'Unknown plant'}</span>
|
||||
{!readOnly && (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onChangePlant}>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Radius (${u})`}
|
||||
name="radius"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(e.target.value)}
|
||||
onBlur={commitRadius}
|
||||
/>
|
||||
<TextField
|
||||
label="Count"
|
||||
name="count"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
placeholder={`${derived} (auto)`}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
onBlur={commitCount}
|
||||
hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Label (optional)"
|
||||
name="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Planted"
|
||||
name="planted"
|
||||
type="date"
|
||||
value={planted}
|
||||
onChange={(e) => setPlanted(e.target.value)}
|
||||
onBlur={commitPlanted}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{onAddNote && (
|
||||
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
|
||||
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 dark:text-red-400"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
remove.mutate({ id: plop.id, version: plop.version })
|
||||
}}
|
||||
>
|
||||
Remove plant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { PlopMarker, SEMANTIC_FAR } from './PlopMarker'
|
||||
import { DIMMED_OPACITY, objectTransform } from './shared'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/** The most common plant color among an object's plops (for the far-zoom tint). */
|
||||
function dominantColor(plops: EditorPlanting[], plantsById: Map<number, Plant>): string | null {
|
||||
const freq = new Map<string, number>()
|
||||
for (const p of plops) {
|
||||
const c = plantsById.get(p.plantId)?.color
|
||||
if (c) freq.set(c, (freq.get(c) ?? 0) + 1)
|
||||
}
|
||||
let best: string | null = null
|
||||
let bestN = 0
|
||||
for (const [c, n] of freq) {
|
||||
if (n > bestN) {
|
||||
best = c
|
||||
bestN = n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every active plop, grouped under its parent object's translate+rotate
|
||||
* transform so plops track the bed when it's moved/rotated. Far zoom adds a
|
||||
* dominant-plant tint over each planted object so beds read at a glance. Focus
|
||||
* mode dims plops on non-focused objects.
|
||||
*/
|
||||
export const PlopLayer = memo(function PlopLayer({
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
scale,
|
||||
focusedObjectId,
|
||||
selectedPlantingId,
|
||||
onSelectPlop,
|
||||
}: {
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
scale: number
|
||||
focusedObjectId: number | null
|
||||
selectedPlantingId: number | null
|
||||
onSelectPlop: (id: number) => void
|
||||
}) {
|
||||
// Group plops by object once per plops change (not on every pan/zoom frame).
|
||||
const byObject = useMemo(() => {
|
||||
const m = new Map<number, EditorPlanting[]>()
|
||||
for (const p of plantings) {
|
||||
const arr = m.get(p.objectId)
|
||||
if (arr) arr.push(p)
|
||||
else m.set(p.objectId, [p])
|
||||
}
|
||||
return m
|
||||
}, [plantings])
|
||||
const far = scale < SEMANTIC_FAR
|
||||
|
||||
return (
|
||||
<>
|
||||
{objects.map((o) => {
|
||||
const plops = byObject.get(o.id)
|
||||
if (!plops || plops.length === 0) return null
|
||||
const dimmed = focusedObjectId != null && focusedObjectId !== o.id
|
||||
const halfW = o.widthCm / 2
|
||||
const halfH = o.heightCm / 2
|
||||
const tint = far ? dominantColor(plops, plantsById) : null
|
||||
return (
|
||||
<g key={o.id} transform={objectTransform(o)} opacity={dimmed ? DIMMED_OPACITY : 1}>
|
||||
{tint &&
|
||||
(o.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill={tint} fillOpacity={0.5} pointerEvents="none" />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={halfW * 2} height={halfH * 2} fill={tint} fillOpacity={0.5} pointerEvents="none" />
|
||||
))}
|
||||
{plops.map((p) => (
|
||||
<PlopMarker
|
||||
key={p.id}
|
||||
plop={p}
|
||||
plant={plantsById.get(p.plantId)}
|
||||
scale={scale}
|
||||
selected={p.id === selectedPlantingId}
|
||||
onSelect={onSelectPlop}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { memo, type PointerEvent } from 'react'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
|
||||
import { SELECT_COLOR } from './shared'
|
||||
|
||||
// Semantic-zoom thresholds in px/cm (DESIGN § Editor / rendering), tuned by feel:
|
||||
// < FAR — flat color patch, no text ("what's planted where" at a glance)
|
||||
// FAR..NEAR — colored circle + plant emoji
|
||||
// ≥ NEAR — circle + emoji + plant name + count
|
||||
export const SEMANTIC_FAR = 0.75
|
||||
export const SEMANTIC_NEAR = 3
|
||||
|
||||
const DEFAULT_COLOR = '#6aa84f'
|
||||
|
||||
/**
|
||||
* One plop, rendered in its parent object's local frame (the caller wraps it in
|
||||
* the object's translate+rotate group, so the plop tracks the bed). A circle in
|
||||
* the plant's color; emoji and name/count fade in with zoom. The count is
|
||||
* computed live from the current radius so it updates while resizing. memo'd so a
|
||||
* pan/zoom that only changes the world transform doesn't re-render every plop.
|
||||
*/
|
||||
export const PlopMarker = memo(function PlopMarker({
|
||||
plop,
|
||||
plant,
|
||||
scale,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant?: Plant
|
||||
scale: number
|
||||
selected: boolean
|
||||
onSelect: (id: number) => void
|
||||
}) {
|
||||
const color = plant?.color ?? DEFAULT_COLOR
|
||||
const r = Math.max(0, plop.radiusCm)
|
||||
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
|
||||
const showText = scale >= SEMANTIC_NEAR && !!plant
|
||||
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
|
||||
// Keep the tap target at least ~44px across even when the plop draws tiny at
|
||||
// low zoom (fill=transparent still receives pointer events, unlike fill=none).
|
||||
const hitR = Math.max(r, 22 / scale)
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
e.stopPropagation()
|
||||
onSelect(plop.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
|
||||
<circle cx={0} cy={0} r={hitR} fill="transparent" />
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={r}
|
||||
fill={color}
|
||||
fillOpacity={0.82}
|
||||
stroke={selected ? SELECT_COLOR : '#00000033'}
|
||||
strokeWidth={selected ? 2.5 : 1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{showIcon && (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize={r * 1.2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{plant!.icon}
|
||||
</text>
|
||||
)}
|
||||
{showText && (
|
||||
<text
|
||||
x={0}
|
||||
y={r + r * 0.3}
|
||||
fontSize={Math.max(r * 0.5, 6)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="hanging"
|
||||
fill="#1f2937"
|
||||
stroke="#ffffff"
|
||||
strokeWidth={0.5}
|
||||
paintOrder="stroke"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{plant!.name} · {count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||
import { screenToWorld, snapLocalToBedGrid, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import { useUpdatePlanting } from '@/lib/objects'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const MAX_RADIUS_CM = 10_000
|
||||
|
||||
/**
|
||||
* Move/resize handles for the selected plop, rendered inside its parent object's
|
||||
* translate+rotate group so the plop stays in the object's local frame. Drag the
|
||||
* body to move (clamped to the object's bounds); drag the edge handle to resize
|
||||
* the radius. Each gesture updates livePlanting for instant feedback and fires
|
||||
* one PATCH on release — the same contract as SelectionOverlay (#11).
|
||||
*/
|
||||
export function PlopOverlay({
|
||||
plop,
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
snap,
|
||||
gridCm,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
svgRef: RefObject<SVGSVGElement | null>
|
||||
// The parent bed's grid: when snap is true, moving the plop snaps it to the
|
||||
// bed grid (radius stays free either way).
|
||||
snap: boolean
|
||||
gridCm: number
|
||||
}) {
|
||||
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
||||
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
||||
const scale = useEditorStore((s) => s.viewport.scale)
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleCm = HANDLE_PX / scale
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
const center: Point = { x: object.xCm, y: object.yCm }
|
||||
const rot = object.rotationDeg
|
||||
|
||||
// Pointer (client) → the object's local frame, snapshotting the svg rect once
|
||||
// per gesture (the object doesn't move during a plop drag).
|
||||
const makePointerLocal = () => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return (e: { clientX: number; clientY: number }): Point => {
|
||||
const vp = useEditorStore.getState().viewport
|
||||
const canvas = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
||||
return worldToLocal(screenToWorld(canvas, vp), center, rot)
|
||||
}
|
||||
}
|
||||
|
||||
const begin = (
|
||||
e: ReactPointerEvent,
|
||||
onMove: (e: PointerEvent) => EditorPlanting,
|
||||
fields: (final: EditorPlanting) => Parameters<typeof update.mutate>[0],
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setObjectDragging(true)
|
||||
const move = (ev: PointerEvent) => setLivePlanting(onMove(ev))
|
||||
const detach = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', finish)
|
||||
window.removeEventListener('pointercancel', finish)
|
||||
}
|
||||
const finish = () => {
|
||||
detach()
|
||||
cleanupRef.current = null
|
||||
const final = useEditorStore.getState().livePlanting
|
||||
setObjectDragging(false)
|
||||
setLivePlanting(null)
|
||||
if (final) update.mutate(fields(final))
|
||||
}
|
||||
cleanupRef.current = () => {
|
||||
detach()
|
||||
setObjectDragging(false)
|
||||
setLivePlanting(null)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', finish)
|
||||
window.addEventListener('pointercancel', finish)
|
||||
}
|
||||
|
||||
const base = { ...plop }
|
||||
|
||||
const startMove = (e: ReactPointerEvent) => {
|
||||
const ptr = makePointerLocal()
|
||||
const start = ptr(e.nativeEvent)
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const p = ptr(ev)
|
||||
const moved: Point = { x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) }
|
||||
// snapLocalToBedGrid clamps either way; step 0 (snapping off) makes it a
|
||||
// pure clamp, so both paths go through one helper.
|
||||
const next = snapLocalToBedGrid(moved, snap ? gridCm : 0, halfW, halfH)
|
||||
return { ...base, xCm: next.x, yCm: next.y }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startResize = (e: ReactPointerEvent) => {
|
||||
const ptr = makePointerLocal()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const p = ptr(ev)
|
||||
const r = Math.max(MIN_RADIUS_CM, Math.min(MAX_RADIUS_CM, Math.hypot(p.x - base.xCm, p.y - base.yCm)))
|
||||
return { ...base, radiusCm: r }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, radiusCm: f.radiusCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const r = plop.radiusCm
|
||||
return (
|
||||
<g transform={objectTransform(object)}>
|
||||
{/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */}
|
||||
<circle
|
||||
cx={plop.xCm}
|
||||
cy={plop.yCm}
|
||||
r={Math.max(r, handleCm)}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
style={{ cursor: 'move' }}
|
||||
onPointerDown={startMove}
|
||||
/>
|
||||
{/* Selection ring. */}
|
||||
<circle
|
||||
cx={plop.xCm}
|
||||
cy={plop.yCm}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{/* Radius handle at the local +x edge. */}
|
||||
<circle
|
||||
cx={plop.xCm + r}
|
||||
cy={plop.yCm}
|
||||
r={handleCm * 0.6}
|
||||
fill="#ffffff"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ cursor: 'ew-resize' }}
|
||||
onPointerDown={startResize}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { PlantChip } from '@/components/plants/PlantChip'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
|
||||
* so re-placing "more of the same" is one tap instead of a trip through the
|
||||
* catalog or the manual tray. Derived from actual plantings (see
|
||||
* recentlyPlantedIds), newest first; renders nothing until something's planted.
|
||||
* Tap a chip to arm it for placement (the armed one is highlighted).
|
||||
*/
|
||||
export function RecentPlants({
|
||||
plants,
|
||||
armedPlantId,
|
||||
onArm,
|
||||
}: {
|
||||
plants: Plant[]
|
||||
armedPlantId: number | null
|
||||
onArm: (plant: Plant) => void
|
||||
}) {
|
||||
if (plants.length === 0) return null
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
|
||||
{plants.map((p) => (
|
||||
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* Which season the canvas is showing.
|
||||
*
|
||||
* "Now" is the live, editable garden — what is in the ground today. A year is a
|
||||
* read-only view of everything whose time in the ground overlapped that calendar
|
||||
* year, plops since removed included. They are genuinely different questions:
|
||||
* "what's growing" versus "what did I grow", and only the first one is editable.
|
||||
*
|
||||
* Only years the garden holds data for are offered. A free numeric field invites
|
||||
* a typo, and a typo'd year produces a confidently empty garden that reads as
|
||||
* data loss rather than a mistake.
|
||||
*/
|
||||
export function SeasonPicker({
|
||||
years,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
years: number[]
|
||||
value: number | null
|
||||
onChange: (year: number | null) => void
|
||||
}) {
|
||||
if (years.length === 0) return null
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span>Season</span>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
)}
|
||||
>
|
||||
<option value="">Now</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner that stops you thinking you're looking at now. Editing the past by
|
||||
* accident is the failure mode this whole feature introduces, so the state is
|
||||
* stated rather than implied by a dropdown you set a while ago — with the way
|
||||
* back to the live garden right next to it.
|
||||
*/
|
||||
export function SeasonBanner({ year, onExit }: { year: number; onExit: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-sm">
|
||||
<span className="font-medium text-amber-800 dark:text-amber-300">Viewing {year}</span>
|
||||
<span className="text-xs text-amber-800/80 dark:text-amber-300/80">
|
||||
read-only, including plantings since removed
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="ml-auto rounded px-1.5 py-0.5 text-xs font-medium text-amber-900 underline outline-none hover:no-underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-amber-200"
|
||||
>
|
||||
Back to now
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { PlantChip } from '@/components/plants/PlantChip'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* The Seed Tray strip in the focus-mode toolbar: a row of the garden's
|
||||
* kept-at-hand plants. Tap a chip to arm that plant for tap-to-place (the armed
|
||||
* chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
|
||||
* opens the full catalog picker. See useSeedTray for persistence.
|
||||
*/
|
||||
export function SeedTray({
|
||||
trayPlants,
|
||||
armedPlantId,
|
||||
onArm,
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: {
|
||||
trayPlants: Plant[]
|
||||
armedPlantId: number | null
|
||||
onArm: (plant: Plant) => void
|
||||
onRemove: (id: number) => void
|
||||
onOpenPicker: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{trayPlants.map((p) => {
|
||||
const active = p.id === armedPlantId
|
||||
return (
|
||||
<span key={p.id} className="inline-flex items-center">
|
||||
{/* Flat right edge so the remove button below seams into one pill. */}
|
||||
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(p.id)}
|
||||
aria-label={`Remove ${p.name} from tray`}
|
||||
className={cn(
|
||||
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
|
||||
: 'border-border bg-surface text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPicker}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs font-medium text-muted outline-none transition-colors hover:border-accent hover:text-accent-strong focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
+ Plant
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||
import { localToWorld, screenToWorld, snapPoint, snapValue, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import { useUpdateObject } from '@/lib/objects'
|
||||
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object
|
||||
const MIN_OBJ_CM = 1 // smallest allowed dimension
|
||||
const ROTATE_SNAP_DEG = 15
|
||||
|
||||
const corners: [number, number][] = [
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* Handles for the selected object: a transparent body to move, four corner
|
||||
* handles to resize (opposite corner stays put, honoring rotation via the
|
||||
* object-local frame), and a knob to rotate (snaps to 15°, free with Shift).
|
||||
* Each gesture updates liveObject for instant feedback and fires exactly one
|
||||
* PATCH on release.
|
||||
*/
|
||||
export function SelectionOverlay({
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
snap,
|
||||
gridCm,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
svgRef: RefObject<SVGSVGElement | null>
|
||||
// The garden grid: when snap is true, a move snaps the object's center to it and
|
||||
// a resize snaps width/height to whole grid steps (the opposite corner stays put).
|
||||
snap: boolean
|
||||
gridCm: number
|
||||
}) {
|
||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
||||
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
||||
const scale = useEditorStore((s) => s.viewport.scale)
|
||||
const update = useUpdateObject(gardenId)
|
||||
|
||||
// Detach for an in-flight gesture. If the overlay unmounts mid-drag (the
|
||||
// object is deleted or deselected), this runs on unmount so window listeners
|
||||
// don't leak and objectDragging can't stay stuck true (which would freeze pan).
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
const handleCm = HANDLE_PX / scale
|
||||
const rotateOffsetCm = ROTATE_OFFSET_PX / scale
|
||||
|
||||
// The svg's screen rect doesn't move during a drag, so snapshot it once at
|
||||
// gesture start rather than calling getBoundingClientRect (a layout reflow) on
|
||||
// every pointermove.
|
||||
const makePointerWorld = () => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return (e: { clientX: number; clientY: number }): Point => {
|
||||
const vp = useEditorStore.getState().viewport
|
||||
const local = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
||||
return screenToWorld(local, vp)
|
||||
}
|
||||
}
|
||||
|
||||
// Common gesture scaffolding: mark dragging, track the pointer on window, and
|
||||
// on release fire one PATCH built from the final liveObject. onMove receives
|
||||
// the live pointer event so modifier keys (Shift) reflect their current state.
|
||||
const begin = (
|
||||
e: ReactPointerEvent,
|
||||
onMove: (e: PointerEvent) => EditorObject,
|
||||
fields: (final: EditorObject) => Parameters<typeof update.mutate>[0],
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setObjectDragging(true)
|
||||
const move = (ev: PointerEvent) => setLiveObject(onMove(ev))
|
||||
const detach = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', finish)
|
||||
window.removeEventListener('pointercancel', finish)
|
||||
}
|
||||
const finish = () => {
|
||||
detach()
|
||||
cleanupRef.current = null
|
||||
const final = useEditorStore.getState().liveObject
|
||||
setObjectDragging(false)
|
||||
setLiveObject(null)
|
||||
if (final) update.mutate(fields(final))
|
||||
}
|
||||
// On unmount mid-gesture: detach and reset, but don't fire a PATCH.
|
||||
cleanupRef.current = () => {
|
||||
detach()
|
||||
setObjectDragging(false)
|
||||
setLiveObject(null)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', finish)
|
||||
window.addEventListener('pointercancel', finish)
|
||||
}
|
||||
|
||||
const base = { ...object }
|
||||
const center0: Point = { x: base.xCm, y: base.yCm }
|
||||
|
||||
const startMove = (e: ReactPointerEvent) => {
|
||||
const pointerWorld = makePointerWorld()
|
||||
const start = pointerWorld(e.nativeEvent)
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
const next: Point = { x: base.xCm + (world.x - start.x), y: base.yCm + (world.y - start.y) }
|
||||
const c = snap ? snapPoint(next, gridCm) : next
|
||||
return { ...base, xCm: c.x, yCm: c.y }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startResize = (e: ReactPointerEvent, sx: number, sy: number) => {
|
||||
// The corner opposite the dragged one stays fixed (in the object's local
|
||||
// frame, which is anchored at the original center).
|
||||
const oppositeLocal: Point = { x: -sx * halfW, y: -sy * halfH }
|
||||
const pointerWorld = makePointerWorld()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
const p = worldToLocal(world, center0, base.rotationDeg)
|
||||
let newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
|
||||
let newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
|
||||
// Snap dimensions to whole grid steps (at least one cell). The opposite
|
||||
// corner is the anchor, so it stays fixed while the dragged corner lands
|
||||
// on a grid-multiple size.
|
||||
if (snap) {
|
||||
newW = Math.max(gridCm, snapValue(newW, gridCm))
|
||||
newH = Math.max(gridCm, snapValue(newH, gridCm))
|
||||
}
|
||||
const draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH }
|
||||
const newCenterLocal: Point = {
|
||||
x: (oppositeLocal.x + draggedLocal.x) / 2,
|
||||
y: (oppositeLocal.y + draggedLocal.y) / 2,
|
||||
}
|
||||
const c = localToWorld(newCenterLocal, center0, base.rotationDeg)
|
||||
return { ...base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm, widthCm: f.widthCm, heightCm: f.heightCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startRotate = (e: ReactPointerEvent) => {
|
||||
const pointerWorld = makePointerWorld()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
// +90° because the knob points up (local -y) at rotation 0.
|
||||
let deg = (Math.atan2(world.y - center0.y, world.x - center0.x) * 180) / Math.PI + 90
|
||||
// Read Shift live off the move event so toggling mid-drag switches
|
||||
// between snapped and free rotation.
|
||||
if (!ev.shiftKey) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG
|
||||
deg = ((deg % 360) + 360) % 360
|
||||
return { ...base, rotationDeg: deg }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, rotationDeg: f.rotationDeg }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={objectTransform(object)}>
|
||||
{/* Transparent body: drag to move. */}
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||
)}
|
||||
|
||||
{/* Selection outline. */}
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
)}
|
||||
|
||||
{/* Rotate knob. */}
|
||||
<line x1={0} y1={-halfH} x2={0} y2={-halfH - rotateOffsetCm} stroke={SELECT_COLOR} strokeWidth={1} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
<circle cx={0} cy={-halfH - rotateOffsetCm} r={handleCm * 0.7} fill={SELECT_COLOR} style={{ cursor: 'grab' }} onPointerDown={startRotate} />
|
||||
|
||||
{/* Resize corners. */}
|
||||
{corners.map(([sx, sy]) => (
|
||||
<rect
|
||||
key={`${sx},${sy}`}
|
||||
x={sx * halfW - handleCm / 2}
|
||||
y={sy * halfH - handleCm / 2}
|
||||
width={handleCm}
|
||||
height={handleCm}
|
||||
fill="#ffffff"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ cursor: 'nwse-resize' }}
|
||||
onPointerDown={(e) => startResize(e, sx, sy)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react'
|
||||
import { ColorDot } from '@/components/plants/Monogram'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { FillLayout } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { formatQuantity, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatSize, formatSpacing, type UnitPref } from '@/lib/units'
|
||||
import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from './arming'
|
||||
import { KindSwatch } from './KindSwatch'
|
||||
import { OBJECT_KINDS } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/**
|
||||
* The desktop editor's left card. Out of focus: the seven object kinds as pill
|
||||
* rows — click to arm (then click the plan), or drag one straight onto it. In a
|
||||
* focused bed it swaps to the plant list with a search, same arm/drag behavior,
|
||||
* plus the bed's bulk tools (fill, clear, scan a packet).
|
||||
*/
|
||||
export function Toolkit({
|
||||
unit,
|
||||
plants,
|
||||
plantings,
|
||||
lotsByPlant,
|
||||
canEdit,
|
||||
focused,
|
||||
focusedPlopCount,
|
||||
canScan,
|
||||
filling,
|
||||
onBack,
|
||||
onFill,
|
||||
onClear,
|
||||
onScan,
|
||||
}: {
|
||||
unit: UnitPref
|
||||
plants: Plant[]
|
||||
plantings: EditorPlanting[]
|
||||
lotsByPlant: Map<number, SeedLot[]>
|
||||
canEdit: boolean
|
||||
focused: EditorObject | null
|
||||
focusedPlopCount: number
|
||||
canScan: boolean
|
||||
filling: boolean
|
||||
onBack: () => void
|
||||
onFill: (layout: FillLayout) => void
|
||||
onClear: () => void
|
||||
onScan: () => void
|
||||
}) {
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const setArmedLotId = useEditorStore((s) => s.setArmedLotId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
return (
|
||||
<div className="panel flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden p-3.5">
|
||||
{!focused ? (
|
||||
<>
|
||||
<h6 className="mx-1 mb-1.5 mt-1">Toolkit</h6>
|
||||
{OBJECT_KINDS.map((k) => {
|
||||
const armed = armedKind === k.kind
|
||||
return (
|
||||
<button
|
||||
key={k.kind}
|
||||
type="button"
|
||||
draggable={canEdit}
|
||||
disabled={!canEdit}
|
||||
onDragStart={(e) => e.dataTransfer.setData('text/plain', `kind:${k.kind}`)}
|
||||
onClick={() => toggleArmedKind(k.kind)}
|
||||
aria-pressed={armed}
|
||||
title="Drag onto the plan — or click to arm, then click the plan"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-full border px-2.5 py-[7px] text-left disabled:cursor-default disabled:opacity-60',
|
||||
armed ? 'border-accent-400 bg-accent-200' : 'border-transparent hover:bg-neutral-200',
|
||||
)}
|
||||
>
|
||||
<KindSwatch def={k} />
|
||||
<span className="flex flex-col items-start gap-px">
|
||||
<span className="whitespace-nowrap text-[13px] font-bold">{k.label}</span>
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-mute">{formatSize(k.widthCm, k.heightCm, unit)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="mt-auto px-1.5 pb-0.5 pt-2 text-xs leading-relaxed text-ink-mute">
|
||||
{canEdit ? 'Drag a shape onto the plan. Double-click any bed to plant it.' : 'You can look but not change this garden.'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 mt-0.5 flex items-center gap-2">
|
||||
<IconButton label="Back to the plan" icon="chevron-left" size={30} onClick={onBack} />
|
||||
<span className="min-w-0 truncate font-heading text-base leading-[1.15]" title={focused.name}>
|
||||
{focused.name}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && focused.plantable ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Find a plant…"
|
||||
aria-label="Find a plant"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{orderedPlants(plants, plantings, query).map((p) => {
|
||||
const armed = armedPlant?.id === p.id
|
||||
const choices = armed ? lotChoices(armedPlant, lotsByPlant) : []
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Dragging arms too, so the drop knows which plant (and lot).
|
||||
if (!armed) toggleArmedPlant(p, lotsByPlant)
|
||||
e.dataTransfer.setData('text/plain', `plant:${p.id}`)
|
||||
}}
|
||||
onClick={() => toggleArmedPlant(p, lotsByPlant)}
|
||||
aria-pressed={armed}
|
||||
title="Drag into the bed — or click to arm, then click the bed"
|
||||
className={cn(
|
||||
'flex items-center gap-[9px] rounded-full border px-3 py-2 text-left',
|
||||
armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg hover:border-neutral-400',
|
||||
)}
|
||||
>
|
||||
<ColorDot color={p.color} size={16} />
|
||||
<span className="min-w-0 truncate text-[13px] font-bold">{p.name}</span>
|
||||
<span className="ml-auto flex-none text-[11px] text-ink-mute">{formatSpacing(p.spacingCm, unit)} apart</span>
|
||||
</button>
|
||||
{choices.length > 0 && <LotChooser lots={choices} value={armedLotId} onChange={setArmedLotId} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{plants.length === 0 && <p className="px-1 text-xs text-ink-mute">No plants in the catalog yet.</p>}
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-2">
|
||||
{armedPlant && <FillRow plant={armedPlant} busy={filling} onFill={onFill} />}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{focusedPlopCount > 0 && (
|
||||
<Button variant="ghost" icon="eraser" iconSize={13} className="px-2.5 text-[13px] text-accent-700" onClick={onClear}>
|
||||
Clear the bed ({focusedPlopCount})
|
||||
</Button>
|
||||
)}
|
||||
{canScan && (
|
||||
<Button variant="ghost" icon="camera" iconSize={13} className="px-2.5 text-[13px]" onClick={onScan}>
|
||||
Scan a packet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="px-1 text-xs leading-relaxed text-ink-mute">
|
||||
{!canEdit ? 'You can look but not change this garden.' : `${focused.name} isn't plantable — turn that on in the inspector's details.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Which packet a planting comes out of, when a plant has more than one. */
|
||||
export function LotChooser({
|
||||
lots,
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
lots: SeedLot[]
|
||||
value: number | null
|
||||
onChange: (lotId: number | null) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-1', compact ? 'items-center' : 'pl-2')}>
|
||||
<span className="w-full text-[11px] font-bold text-ink-mute">Which packet?</span>
|
||||
{lots.map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
className="chip px-3 py-1 text-xs"
|
||||
aria-pressed={value === lot.id}
|
||||
onClick={() => onChange(lot.id)}
|
||||
title={`${formatQuantity(lot.remaining)} of ${formatQuantity(lot.quantity)} ${lot.unit} left`}
|
||||
>
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="chip px-3 py-1 text-xs" aria-pressed={value === null} onClick={() => onChange(null)}>
|
||||
No particular one
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fill the whole bed with the armed plant — rows you could plant from, or
|
||||
* clumps for a quick sketch (#77). */
|
||||
export function FillRow({ plant, busy, onFill }: { plant: Plant; busy: boolean; onFill: (layout: FillLayout) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-[18px] border border-divider bg-bg px-2.5 py-2">
|
||||
<span className="text-[11px] font-bold text-ink-mute">Fill the bed with {plant.name.toLowerCase()}:</span>
|
||||
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('grid')} title="Individual plants at true spacing">
|
||||
rows
|
||||
</button>
|
||||
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('clump')} title="Fat clumps — a quick sketch">
|
||||
clumps
|
||||
</button>
|
||||
{busy && <span className="text-[11px] text-ink-mute">filling…</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { UndoOutcome, UndoTarget, useUndo } from '@/lib/history'
|
||||
|
||||
/**
|
||||
* Undo, plus what happened. Paired with useUndo so the history list and the
|
||||
* agent turn's inline undo (#57) behave identically — including the part that
|
||||
* actually matters, which is reporting a partial result honestly rather than as
|
||||
* a success or a failure.
|
||||
*/
|
||||
export function UndoButton({
|
||||
changeSet,
|
||||
undo,
|
||||
className,
|
||||
label = 'Undo',
|
||||
}: {
|
||||
changeSet: UndoTarget
|
||||
undo: ReturnType<typeof useUndo>
|
||||
className?: string
|
||||
label?: string
|
||||
}) {
|
||||
const outcome = undo.outcomeFor(changeSet.id)
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-1', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
disabled={outcome?.tone === 'pending'}
|
||||
onClick={() => undo.undo(changeSet)}
|
||||
>
|
||||
{outcome?.tone === 'pending' ? 'Undoing…' : label}
|
||||
</Button>
|
||||
{outcome && outcome.tone !== 'pending' && <OutcomeNote outcome={outcome} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
|
||||
ok: 'text-muted',
|
||||
partial: 'text-amber-700 dark:text-amber-400',
|
||||
error: 'text-red-700 dark:text-red-400',
|
||||
}
|
||||
|
||||
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
||||
if (outcome.tone === 'pending') return null
|
||||
// No text alignment of its own: the container decides. The history list stacks
|
||||
// this to the right of an entry, the chat panel puts it under a left-aligned
|
||||
// message, and a hard-coded text-right made the chat's copy read against its
|
||||
// own column.
|
||||
return (
|
||||
<p role="status" className={cn('text-xs', TONE_CLASS[outcome.tone])}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Arming a plant for placement: shared by the desktop toolkit and the phone
|
||||
// strip so both pick the same lot rules and order their lists the same way.
|
||||
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { recentlyPlantedIds, type EditorPlanting } from '@/lib/plantings'
|
||||
import { attributableLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
/** The catalog ordered for planting: what this garden has been planted with
|
||||
* most recently first (so "more of the same" is the first chip), then the rest
|
||||
* alphabetically; narrowed by a name query. */
|
||||
export function orderedPlants(plants: readonly Plant[], plantings: readonly EditorPlanting[], query: string): Plant[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
const byId = new Map(plants.map((p) => [p.id, p]))
|
||||
const recent = recentlyPlantedIds([...plantings])
|
||||
.map((id) => byId.get(id))
|
||||
.filter((p): p is Plant => !!p)
|
||||
const seen = new Set(recent.map((p) => p.id))
|
||||
const rest = [...plants].filter((p) => !seen.has(p.id)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const all = [...recent, ...rest]
|
||||
return q ? all.filter((p) => p.name.toLowerCase().includes(q)) : all
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm a plant (tap again to disarm). One attributable lot attributes itself;
|
||||
* several leave the lot unset so the caller can ask which packet — never a
|
||||
* guess, since a planting's lot can't be changed after the fact. Arming a plant
|
||||
* disarms any kind and drops the selection, as the prototype does.
|
||||
*/
|
||||
export function toggleArmedPlant(plant: Plant, lotsByPlant: Map<number, SeedLot[]>): void {
|
||||
const st = useEditorStore.getState()
|
||||
if (st.armedPlant?.id === plant.id) {
|
||||
st.setArmedPlant(null)
|
||||
return
|
||||
}
|
||||
const lots = attributableLots(lotsByPlant.get(plant.id) ?? [])
|
||||
st.setArmedKind(null)
|
||||
st.setSel(null)
|
||||
st.setArmedPlant(plant, lots.length === 1 ? lots[0].id : null)
|
||||
}
|
||||
|
||||
/** The lots a just-armed plant could be attributed to, when that's a question
|
||||
* worth asking (more than one). */
|
||||
export function lotChoices(plant: Plant | null, lotsByPlant: Map<number, SeedLot[]>): SeedLot[] {
|
||||
if (!plant) return []
|
||||
const lots = attributableLots(lotsByPlant.get(plant.id) ?? [])
|
||||
return lots.length > 1 ? lots : []
|
||||
}
|
||||
|
||||
export function toggleArmedKind(kind: string): void {
|
||||
const st = useEditorStore.getState()
|
||||
const armed = st.armedKind === kind
|
||||
st.setArmedKind(armed ? null : kind)
|
||||
st.setArmedPlant(null)
|
||||
st.setSel(null)
|
||||
st.setGhost(null)
|
||||
}
|
||||
+54
-12
@@ -1,27 +1,26 @@
|
||||
import type { ObjectShapeKind } from './types'
|
||||
|
||||
// The seven placeable object kinds with their palette presentation and sensible
|
||||
// default sizes (cm), per the #11 brief.
|
||||
// The seven placeable object kinds: the design's default sizes (cm — a 3′×6′
|
||||
// bed, a 1′4″ grow bag…), whether plants go in them, and the stacking order at
|
||||
// placement (paths/structures/in-ground under beds, trees floating above).
|
||||
export interface KindDef {
|
||||
kind: string
|
||||
label: string
|
||||
icon: string
|
||||
shape: ObjectShapeKind
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
// Default z-index at placement. Paths/structures/in-ground sit under beds (0);
|
||||
// beds and containers sit at 1; trees float above (2). Explicit z still wins.
|
||||
plantable: boolean
|
||||
defaultZ: number
|
||||
}
|
||||
|
||||
export const OBJECT_KINDS: KindDef[] = [
|
||||
{ kind: 'bed', label: 'Bed', icon: '▭', shape: 'rect', widthCm: 120, heightCm: 240, defaultZ: 1 },
|
||||
{ kind: 'grow_bag', label: 'Grow bag', icon: '🛍️', shape: 'circle', widthCm: 40, heightCm: 40, defaultZ: 1 },
|
||||
{ kind: 'container', label: 'Container', icon: '🪴', shape: 'circle', widthCm: 60, heightCm: 60, defaultZ: 1 },
|
||||
{ kind: 'in_ground', label: 'In-ground', icon: '🟫', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||
{ kind: 'tree', label: 'Tree', icon: '🌳', shape: 'circle', widthCm: 300, heightCm: 300, defaultZ: 2 },
|
||||
{ kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300, defaultZ: 0 },
|
||||
{ kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||
{ kind: 'bed', label: 'Bed', shape: 'rect', widthCm: 91, heightCm: 183, plantable: true, defaultZ: 1 },
|
||||
{ kind: 'grow_bag', label: 'Grow bag', shape: 'circle', widthCm: 40, heightCm: 40, plantable: true, defaultZ: 1 },
|
||||
{ kind: 'container', label: 'Container', shape: 'circle', widthCm: 60, heightCm: 60, plantable: true, defaultZ: 1 },
|
||||
{ kind: 'in_ground', label: 'In-ground', shape: 'rect', widthCm: 200, heightCm: 200, plantable: true, defaultZ: 0 },
|
||||
{ kind: 'tree', label: 'Tree', shape: 'circle', widthCm: 300, heightCm: 300, plantable: false, defaultZ: 2 },
|
||||
{ kind: 'path', label: 'Path', shape: 'rect', widthCm: 100, heightCm: 300, plantable: false, defaultZ: 0 },
|
||||
{ kind: 'structure', label: 'Structure', shape: 'rect', widthCm: 200, heightCm: 200, plantable: false, defaultZ: 0 },
|
||||
]
|
||||
|
||||
export function kindDef(kind: string): KindDef | undefined {
|
||||
@@ -32,3 +31,46 @@ export function kindDef(kind: string): KindDef | undefined {
|
||||
export function objectDisplayName(o: { name: string; kind: string }): string {
|
||||
return o.name || kindDef(o.kind)?.label || 'Object'
|
||||
}
|
||||
|
||||
/** The plural noun for a count of a kind: "9 beds", "3 grow bags". */
|
||||
export function kindPlural(kind: string, n: number): string {
|
||||
const label = (kindDef(kind)?.label ?? kind.replace(/_/g, ' ')).toLowerCase()
|
||||
if (n === 1) return `1 ${label}`
|
||||
return `${n} ${label === 'in-ground' ? 'in-ground plots' : label.endsWith('h') ? `${label}es` : `${label}s`}`
|
||||
}
|
||||
|
||||
/** How a kind draws: fill + stroke from the canvas tokens (so they flip with
|
||||
* the theme), and a dash pattern in world cm for the soft-edged kinds. */
|
||||
export interface KindStyle {
|
||||
fill: string
|
||||
stroke: string
|
||||
dash?: string
|
||||
}
|
||||
|
||||
const STYLES: Record<string, KindStyle> = {
|
||||
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)' },
|
||||
}
|
||||
|
||||
export function kindStyle(kind: string): KindStyle {
|
||||
return STYLES[kind] ?? STYLES.structure
|
||||
}
|
||||
|
||||
/** An object's style: its own color override (a darker mix of it for the
|
||||
* stroke) or its kind's. */
|
||||
export function objectStyle(o: { kind: string; color?: string | null }): KindStyle {
|
||||
if (o.color) {
|
||||
return { fill: o.color, stroke: `color-mix(in srgb, ${o.color} 65%, #201e1d)`, dash: kindStyle(o.kind).dash }
|
||||
}
|
||||
return kindStyle(o.kind)
|
||||
}
|
||||
|
||||
/** The corner radius the design gives a rect of width w (cm). */
|
||||
export function rectRadius(widthCm: number): number {
|
||||
return Math.min(14, widthCm * 0.14)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useGardenYears } from '@/lib/objects'
|
||||
import { parsePlanName, planGardensOf } from '@/lib/plan'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
export interface SeasonOption {
|
||||
value: string
|
||||
/** "2026" / "2027 plan" / the base garden's name. */
|
||||
label: string
|
||||
/** The phone chip: "26" / "27 plan". */
|
||||
short: string
|
||||
kind: 'year' | 'plan' | 'base'
|
||||
}
|
||||
|
||||
/**
|
||||
* The season control. Years with planting data come from the API (the current
|
||||
* year is always among them): the current year is the live, editable garden;
|
||||
* any other is that season's read-only record. A "YYYY plan" entry is a whole-
|
||||
* garden copy named "<this garden> — YYYY" (see lib/plan.ts) and opens it; from
|
||||
* inside a plan, the control offers the way back to the garden it came from.
|
||||
*/
|
||||
export function useSeasons(garden: Garden, gardens: Garden[] | undefined) {
|
||||
const navigate = useNavigate()
|
||||
const years = useGardenYears(garden.id)
|
||||
const seasonYear = useEditorStore((s) => s.seasonYear)
|
||||
const setSeasonYear = useEditorStore((s) => s.setSeasonYear)
|
||||
const now = new Date().getFullYear()
|
||||
const plan = parsePlanName(garden.name)
|
||||
const baseGarden = plan ? gardens?.find((g) => g.name.trim() === plan.base) : undefined
|
||||
|
||||
let options: SeasonOption[]
|
||||
let value: string
|
||||
let banner: string | null = null
|
||||
if (plan && baseGarden) {
|
||||
options = [
|
||||
{ value: `garden:${baseGarden.id}`, label: baseGarden.name, short: '←', kind: 'base' },
|
||||
{ value: 'this', label: `${plan.year} plan`, short: `${String(plan.year).slice(2)} plan`, kind: 'plan' },
|
||||
]
|
||||
value = 'this'
|
||||
banner = `Editing the ${plan.year} plan — a separate copy. ${baseGarden.name} stays untouched.`
|
||||
} else {
|
||||
const ys = new Set<number>(years.data ?? [])
|
||||
ys.add(now)
|
||||
options = [...ys]
|
||||
.sort((a, b) => a - b)
|
||||
.map((y) => ({ value: String(y), label: String(y), short: String(y).slice(2), kind: 'year' as const }))
|
||||
for (const g of planGardensOf(garden.name, gardens ?? [])) {
|
||||
options.push({ value: `garden:${g.id}`, label: `${g.year} plan`, short: `${String(g.year).slice(2)} plan`, kind: 'plan' })
|
||||
}
|
||||
value = seasonYear != null ? String(seasonYear) : String(now)
|
||||
if (seasonYear != null) banner = seasonYear < now ? `${seasonYear} is a past season — read-only.` : `Viewing ${seasonYear} — read-only.`
|
||||
}
|
||||
|
||||
const select = (v: string) => {
|
||||
if (v === 'this') return
|
||||
if (v.startsWith('garden:')) {
|
||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: v.slice('garden:'.length) } })
|
||||
return
|
||||
}
|
||||
const y = Number(v)
|
||||
if (Number.isInteger(y)) setSeasonYear(y === now ? null : y)
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
const i = options.findIndex((o) => o.value === value)
|
||||
const next = options[(i + 1) % options.length]
|
||||
if (next) select(next.value)
|
||||
}
|
||||
|
||||
const current = options.find((o) => o.value === value)
|
||||
return { options, value, select, cycle, banner, short: current?.short ?? String(now).slice(2), isPlan: !!(plan && baseGarden) }
|
||||
}
|
||||
+123
-17
@@ -1,24 +1,130 @@
|
||||
// Shared editor UI constants + tiny helpers used across the object/plop markers
|
||||
// and overlays, so colors, handle sizes, and the object-local transform stay in
|
||||
// one place instead of drifting between files.
|
||||
// Shared editor constants + tiny geometry helpers, so the canvas, the
|
||||
// inspectors and the page agree on the same numbers.
|
||||
|
||||
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
|
||||
// Whether the primary pointer is a fingertip rather than a mouse — the one signal
|
||||
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
|
||||
// the editor), so they can't disagree about what "touch" means. Read once at
|
||||
// load; a device doesn't switch its primary pointer mid-session, and the optional
|
||||
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
|
||||
export const isCoarsePointer =
|
||||
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
|
||||
// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can
|
||||
// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but
|
||||
// frustrating for a thumb (#104).
|
||||
export const HANDLE_PX = isCoarsePointer ? 22 : 12
|
||||
export const MIN_RADIUS_CM = 1 // smallest plop radius
|
||||
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
|
||||
import { localToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/** Object drags snap their center to a 3-inch grid (unless the garden has its
|
||||
* own snap grid turned on). */
|
||||
export const SNAP_CM = 7.62
|
||||
/** Camera scale bounds, px per cm. */
|
||||
export const MIN_SCALE = 0.12
|
||||
export const MAX_SCALE = 8
|
||||
/** The container width below which the editor renders its phone chrome. */
|
||||
export const PHONE_BREAKPOINT = 760
|
||||
/** Smallest an object may be resized to (cm). */
|
||||
export const MIN_OBJECT_CM = 5
|
||||
/** Focus mode dims everything that isn't the focused bed. */
|
||||
export const DIM_OBJECT = 0.3
|
||||
export const DIM_PLOP = 0.22
|
||||
|
||||
export { type Point }
|
||||
|
||||
/** SVG transform placing content in an object's local frame: its center point
|
||||
* then its clockwise rotation. Plops and overlays render inside this. */
|
||||
export function objectTransform(o: { xCm: number; yCm: number; rotationDeg: number }): string {
|
||||
return `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
|
||||
}
|
||||
|
||||
export function toLocal(o: EditorObject, w: Point): Point {
|
||||
return worldToLocal(w, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||
}
|
||||
|
||||
export function toWorld(o: EditorObject, l: Point): Point {
|
||||
return localToWorld(l, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||
}
|
||||
|
||||
/** Half the height of an object's screen-aligned bounding box once rotated —
|
||||
* where a label above or below it should sit. */
|
||||
export function rotatedHalfHeight(o: EditorObject): number {
|
||||
const a = (o.rotationDeg * Math.PI) / 180
|
||||
return (Math.abs(o.widthCm * Math.sin(a)) + Math.abs(o.heightCm * Math.cos(a))) / 2
|
||||
}
|
||||
|
||||
/** Whether a world point is inside an object (its own frame). */
|
||||
export function objectContains(o: EditorObject, w: Point): boolean {
|
||||
const l = toLocal(o, w)
|
||||
if (o.shape === 'circle') {
|
||||
const rx = o.widthCm / 2
|
||||
const ry = o.heightCm / 2
|
||||
return rx > 0 && ry > 0 && (l.x * l.x) / (rx * rx) + (l.y * l.y) / (ry * ry) <= 1
|
||||
}
|
||||
return Math.abs(l.x) <= o.widthCm / 2 && Math.abs(l.y) <= o.heightCm / 2
|
||||
}
|
||||
|
||||
/** The topmost object under a world point (the list is in draw order). */
|
||||
export function objectAt(objects: readonly EditorObject[], w: Point): EditorObject | null {
|
||||
for (let i = objects.length - 1; i >= 0; i--) if (objectContains(objects[i], w)) return objects[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a plop's center inside its object, letting the patch overhang the edge
|
||||
* by up to half its radius — the spacing rule (DESIGN.md): a bed edge is
|
||||
* nobody's neighbour, so the outer plant owes it half a spacing, not a whole.
|
||||
*/
|
||||
export function clampPlopLocal(o: EditorObject, p: Point, r: number): Point {
|
||||
const hx = Math.max(0, o.widthCm / 2 - r / 2)
|
||||
const hy = Math.max(0, o.heightCm / 2 - r / 2)
|
||||
if (o.shape === 'circle') {
|
||||
// Ellipse: scale the point into the unit circle and pull it back to the rim.
|
||||
const nx = hx > 0 ? p.x / hx : 0
|
||||
const ny = hy > 0 ? p.y / hy : 0
|
||||
const d = Math.hypot(nx, ny)
|
||||
if (d <= 1) return p
|
||||
return { x: (nx / d) * hx, y: (ny / d) * hy }
|
||||
}
|
||||
return { x: Math.max(-hx, Math.min(hx, p.x)), y: Math.max(-hy, Math.min(hy, p.y)) }
|
||||
}
|
||||
|
||||
/** Snap a bed-local point to the bed's own grid (anchored at its top-left
|
||||
* corner, matching the lines the canvas draws), when the bed snaps. */
|
||||
export function snapPlopLocal(o: EditorObject, p: Point): Point {
|
||||
if (!o.snapToGrid || !(o.gridSizeCm > 0)) return p
|
||||
const step = o.gridSizeCm
|
||||
const hw = o.widthCm / 2
|
||||
const hh = o.heightCm / 2
|
||||
return {
|
||||
x: -hw + Math.round((p.x + hw) / step) * step,
|
||||
y: -hh + Math.round((p.y + hh) / step) * step,
|
||||
}
|
||||
}
|
||||
|
||||
/** The count a plop shows: its override, else derived live from its radius. */
|
||||
export function plopCount(p: EditorPlanting, plant: Plant | undefined): number {
|
||||
if (p.count != null) return p.count
|
||||
return plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
||||
}
|
||||
|
||||
/** The radius a fresh tap-placed plop gets: half the plant's spacing — one
|
||||
* plant per patch, the grid layout you could plant from. Fill (rows/clumps)
|
||||
* covers bulk. */
|
||||
export function defaultPlopRadius(plant: Plant): number {
|
||||
return Math.max(1, plant.spacingCm / 2)
|
||||
}
|
||||
|
||||
/** Whether keyboard focus is in a text control, so shortcuts stand down. */
|
||||
export function isTyping(): boolean {
|
||||
const el = document.activeElement
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable
|
||||
}
|
||||
|
||||
/** "3m ago" / "yesterday" for a UTC timestamp. */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = (Date.now() - then) / 1000
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
+91
-138
@@ -1,163 +1,116 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Viewport } from '@/lib/geometry'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
|
||||
// selection, the object being live-edited mid-gesture, and the palette kind
|
||||
// armed for tap-to-place. All server state stays in react-query.
|
||||
// Ephemeral editor state only (DESIGN § State): the camera, the selection, the
|
||||
// focused bed, what's armed for placement, the rail tab / phone mode, and any
|
||||
// in-flight drag geometry. All server state stays in react-query.
|
||||
|
||||
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
||||
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
||||
/** The camera: world (garden cm) → screen is translate(tx,ty) scale(s). */
|
||||
export interface Viewport {
|
||||
tx: number
|
||||
ty: number
|
||||
s: number
|
||||
}
|
||||
|
||||
// The editor's one primary activity (#99). On mobile this is the segmented
|
||||
// control at the bottom of the screen; it decides which tools dock there —
|
||||
// placing fixtures, placing plants, the journal, or the assistant — so the four
|
||||
// activities stop competing for the same cramped strip. Desktop keeps its
|
||||
// side-column layout and treats this as a lighter hint.
|
||||
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
|
||||
export type Selection = { type: 'object'; id: number } | { type: 'plop'; id: number }
|
||||
|
||||
// Where the editor starts, and where it returns on a garden switch.
|
||||
export const DEFAULT_MODE: EditorMode = 'fixtures'
|
||||
/** The right-hand rail's tabs (desktop). */
|
||||
export type RailTab = 'plot' | 'journal' | 'history' | 'chat'
|
||||
|
||||
/** The phone's primary mode — which tools dock under the canvas. */
|
||||
export type PhoneMode = 'build' | 'plants' | 'journal' | 'chat'
|
||||
|
||||
/** Which thing the journal list is narrowed to, when it is. The composer
|
||||
* attaches to the selection regardless; this is the reading filter. */
|
||||
export type JournalScope = { type: 'object'; id: number } | { type: 'plop'; id: number }
|
||||
|
||||
interface EditorState {
|
||||
viewport: Viewport
|
||||
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
||||
vp: Viewport
|
||||
/** True while the camera is animating (fit/focus); drags and zooms clear it. */
|
||||
anim: boolean
|
||||
setVp: (vp: Viewport, anim: boolean) => void
|
||||
setAnim: (anim: boolean) => void
|
||||
|
||||
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
|
||||
// last using is not a property of the garden.
|
||||
mode: EditorMode
|
||||
setMode: (mode: EditorMode) => void
|
||||
sel: Selection | null
|
||||
setSel: (sel: Selection | null) => void
|
||||
|
||||
// The selected object OR plop (mutually exclusive; selecting one clears the
|
||||
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
||||
selectedId: number | null
|
||||
select: (id: number | null) => void
|
||||
focusId: number | null
|
||||
setFocus: (id: number | null) => void
|
||||
|
||||
selectedPlantingId: number | null
|
||||
selectPlanting: (id: number | null) => void
|
||||
|
||||
focusedObjectId: number | null
|
||||
setFocusedObject: (id: number | null) => void
|
||||
|
||||
// Which rail tab is showing, or null when the rail is closed. Selecting an
|
||||
// object switches this to 'inspector' (see GardenEditorPage), so editing never
|
||||
// starts with a click on the rail itself.
|
||||
railTab: string | null
|
||||
setRailTab: (tab: string | null) => void
|
||||
|
||||
// Which season the canvas is showing: null is "now" (live and editable), a
|
||||
// year is a read-only view of what was in the ground that year. Ephemeral —
|
||||
// which year you were last looking at is not a property of the garden.
|
||||
seasonYear: number | null
|
||||
setSeasonYear: (year: number | null) => void
|
||||
|
||||
// Which bed the journal tab is filtered to, or null for the whole garden.
|
||||
// Separate from selectedId deliberately: you can scope the journal to a bed
|
||||
// and then select something else without the list moving under you.
|
||||
journalObjectId: number | null
|
||||
setJournalObjectId: (id: number | null) => void
|
||||
|
||||
// Which single plop the journal is filtered to, if any — the parallel of
|
||||
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
|
||||
// (the setters clear each other), so the journal filter is never ambiguous.
|
||||
journalPlantingId: number | null
|
||||
setJournalPlantingId: (id: number | null) => void
|
||||
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
// Which seed lot placements should be attributed to, when the armed plant has
|
||||
// one worth naming. Cleared with the plant.
|
||||
armedLotId: number | null
|
||||
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
|
||||
|
||||
// During a move/resize/rotate, the object's live geometry is held here so the
|
||||
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||
liveObject: EditorObject | null
|
||||
setLiveObject: (o: EditorObject | null) => void
|
||||
|
||||
// The same, for a plop being moved/resized.
|
||||
livePlanting: EditorPlanting | null
|
||||
setLivePlanting: (p: EditorPlanting | null) => void
|
||||
|
||||
// The palette kind armed for tap-to-place (mobile-friendly; also set while
|
||||
// dragging a kind from the palette). null when nothing is armed.
|
||||
armedKind: string | null
|
||||
setArmedKind: (kind: string | null) => void
|
||||
|
||||
// True while an object move/resize/rotate is in progress, so the viewport's
|
||||
// pan gesture stands down (both listen on the same svg).
|
||||
objectDragging: boolean
|
||||
setObjectDragging: (v: boolean) => void
|
||||
armedPlant: Plant | null
|
||||
/** Which seed lot placements are attributed to, when one is worth naming. */
|
||||
armedLotId: number | null
|
||||
setArmedPlant: (plant: Plant | null, lotId?: number | null) => void
|
||||
setArmedLotId: (lotId: number | null) => void
|
||||
|
||||
// Clear all transient (non-persisted) editor state at once — selection, focus,
|
||||
// armed plant/kind, and any in-flight live geometry — when entering a garden
|
||||
// view fresh (e.g. the public read-only page on mount). The viewport is left
|
||||
// alone; the canvas re-fits it from the loaded garden.
|
||||
resetTransient: () => void
|
||||
/** Ghost preview position (world cm, snapped) while a kind is armed. */
|
||||
ghost: { x: number; y: number } | null
|
||||
setGhost: (g: { x: number; y: number } | null) => void
|
||||
|
||||
/** null = now (live, editable); a year = that season, read-only. */
|
||||
seasonYear: number | null
|
||||
setSeasonYear: (year: number | null) => void
|
||||
|
||||
tab: RailTab
|
||||
setTab: (tab: RailTab) => void
|
||||
|
||||
mode: PhoneMode
|
||||
setMode: (mode: PhoneMode) => void
|
||||
|
||||
journalScope: JournalScope | null
|
||||
setJournalScope: (scope: JournalScope | null) => void
|
||||
|
||||
// During a drag/resize the live geometry is held here so the canvas renders
|
||||
// it instantly; the PATCH fires once on release.
|
||||
liveObject: EditorObject | null
|
||||
setLiveObject: (o: EditorObject | null) => void
|
||||
livePlanting: EditorPlanting | null
|
||||
setLivePlanting: (p: EditorPlanting | null) => void
|
||||
|
||||
/** Everything transient, cleared when entering or leaving a garden. The
|
||||
* camera is left alone; the canvas re-fits from the loaded garden. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const TRANSIENT = {
|
||||
sel: null,
|
||||
focusId: null,
|
||||
armedKind: null,
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
ghost: null,
|
||||
seasonYear: null,
|
||||
tab: 'plot' as RailTab,
|
||||
mode: 'build' as PhoneMode,
|
||||
journalScope: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
viewport: { tx: 0, ty: 0, scale: 1 },
|
||||
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||
vp: { tx: 40, ty: 40, s: 0.5 },
|
||||
anim: false,
|
||||
setVp: (vp, anim) => set({ vp, anim }),
|
||||
setAnim: (anim) => set({ anim }),
|
||||
|
||||
mode: DEFAULT_MODE,
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
selectedId: null,
|
||||
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
||||
|
||||
selectedPlantingId: null,
|
||||
selectPlanting: (id) => set({ selectedPlantingId: id, selectedId: null }),
|
||||
|
||||
focusedObjectId: null,
|
||||
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
||||
|
||||
railTab: null,
|
||||
setRailTab: (tab) => set({ railTab: tab }),
|
||||
|
||||
seasonYear: null,
|
||||
...TRANSIENT,
|
||||
setSel: (sel) => set({ sel }),
|
||||
setFocus: (id) => set({ focusId: id }),
|
||||
// Disarming drops the ghost too; arming keeps whatever the cursor last set.
|
||||
setArmedKind: (kind) => set((s) => ({ armedKind: kind, ghost: kind ? s.ghost : null })),
|
||||
setArmedPlant: (plant, lotId = null) => set({ armedPlant: plant, armedLotId: plant ? lotId : null }),
|
||||
setArmedLotId: (lotId) => set({ armedLotId: lotId }),
|
||||
setGhost: (ghost) => set({ ghost }),
|
||||
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||
|
||||
journalObjectId: null,
|
||||
// Scoping to a bed clears any plop scope, so only one is ever active.
|
||||
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
|
||||
|
||||
journalPlantingId: null,
|
||||
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
|
||||
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||
|
||||
liveObject: null,
|
||||
setTab: (tab) => set({ tab }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
setJournalScope: (scope) => set({ journalScope: scope }),
|
||||
setLiveObject: (o) => set({ liveObject: o }),
|
||||
|
||||
livePlanting: null,
|
||||
setLivePlanting: (p) => set({ livePlanting: p }),
|
||||
|
||||
armedKind: null,
|
||||
setArmedKind: (kind) => set({ armedKind: kind }),
|
||||
|
||||
objectDragging: false,
|
||||
setObjectDragging: (v) => set({ objectDragging: v }),
|
||||
|
||||
resetTransient: () =>
|
||||
set({
|
||||
selectedId: null,
|
||||
selectedPlantingId: null,
|
||||
focusedObjectId: null,
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
railTab: null,
|
||||
seasonYear: null,
|
||||
journalObjectId: null,
|
||||
journalPlantingId: null,
|
||||
mode: DEFAULT_MODE,
|
||||
}),
|
||||
reset: () => set({ ...TRANSIENT }),
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
|
||||
|
||||
/** The newest change set still in effect — not already reverted, and not itself
|
||||
* an undo (undoing an undo is a redo, which the History tab offers per entry). */
|
||||
function latestUndoable(sets: ChangeSet[]): ChangeSet | null {
|
||||
return sets.find((cs) => cs.revertedById == null && cs.revertsId == null && totalChanges(cs) > 0) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's one-button Undo. It re-reads the history before choosing what
|
||||
* to revert: the cached list can trail the canvas (a placement that just landed
|
||||
* isn't in it yet), and undoing the step *before* the one you meant is the
|
||||
* worst thing an undo button can do. Shares the useUndo instance with the
|
||||
* History tab so outcomes are reported identically.
|
||||
*/
|
||||
export function useUndoLast(gardenId: number, enabled: boolean) {
|
||||
const history = useGardenHistory(gardenId, enabled)
|
||||
const undo = useUndo(gardenId)
|
||||
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
|
||||
const target = latestUndoable(sets)
|
||||
const outcome = target ? undo.outcomeFor(target.id) : undefined
|
||||
|
||||
const undoLast = useCallback(async () => {
|
||||
const fresh = await history.refetch()
|
||||
// A failed refetch keeps the STALE pages in `data` (react-query doesn't
|
||||
// clear them), so falling through here would pick the step before the one
|
||||
// just made. Undoing nothing is the only safe answer until it can be read.
|
||||
if (fresh.status !== 'success') {
|
||||
toast.error("Couldn't re-read the history, so nothing was undone — try again.")
|
||||
return
|
||||
}
|
||||
const t = latestUndoable(fresh.data.pages.flatMap((p) => p.changeSets))
|
||||
if (t) undo.undo(t)
|
||||
}, [history, undo])
|
||||
|
||||
return {
|
||||
history,
|
||||
undo,
|
||||
sets,
|
||||
target,
|
||||
canUndo: !!target && outcome?.tone !== 'pending',
|
||||
undoLast,
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import { useGesture } from '@use-gesture/react'
|
||||
import {
|
||||
easeInOutCubic,
|
||||
lerp,
|
||||
zoomToFitRect,
|
||||
zoomViewportAt,
|
||||
type Point,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from '@/lib/geometry'
|
||||
import { MAX_SCALE, MIN_SCALE, useEditorStore } from './store'
|
||||
|
||||
const WHEEL_SENSITIVITY = 0.0015 // exponential zoom per wheel delta unit
|
||||
const FIT_PADDING = 32 // px margin around a fitted rect
|
||||
const FIT_DURATION_MS = 350
|
||||
|
||||
/**
|
||||
* Wires pan/zoom/pinch gestures onto the svg (via @use-gesture) and returns an
|
||||
* animated `fitToRect`. Wheel and pinch zoom toward the pointer; drag on empty
|
||||
* space pans (object dragging in #11 stops propagation). Scale is clamped to
|
||||
* [MIN_SCALE, MAX_SCALE]. The svg must set touch-action:none so the browser
|
||||
* doesn't hijack the gestures.
|
||||
*/
|
||||
export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
|
||||
const setViewport = useEditorStore((s) => s.setViewport)
|
||||
const animRef = useRef<number | null>(null)
|
||||
|
||||
const cancelAnim = useCallback(() => {
|
||||
if (animRef.current != null) {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
animRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Client (page) coords → coords within the svg, for anchoring zoom.
|
||||
const clientToCanvas = useCallback(
|
||||
(clientX: number, clientY: number): Point => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }
|
||||
},
|
||||
[svgRef],
|
||||
)
|
||||
|
||||
useGesture(
|
||||
{
|
||||
onDragStart: cancelAnim,
|
||||
onDrag: ({ delta: [dx, dy], pinching, cancel }) => {
|
||||
// An object move/resize/rotate owns this drag; don't also pan.
|
||||
if (useEditorStore.getState().objectDragging) return
|
||||
if (pinching) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
setViewport((vp) => ({ ...vp, tx: vp.tx + dx, ty: vp.ty + dy }))
|
||||
},
|
||||
onWheelStart: cancelAnim,
|
||||
onWheel: ({ event, delta: [, dy] }) => {
|
||||
event.preventDefault()
|
||||
if (!Number.isFinite(dy)) return // never let a bad delta corrupt the viewport
|
||||
const p = clientToCanvas(event.clientX, event.clientY)
|
||||
setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE))
|
||||
},
|
||||
onPinchStart: cancelAnim,
|
||||
onPinch: ({ origin: [ox, oy], offset: [scale] }) => {
|
||||
if (!Number.isFinite(scale)) return
|
||||
const p = clientToCanvas(ox, oy)
|
||||
setViewport((vp) => zoomViewportAt(vp, p, scale, MIN_SCALE, MAX_SCALE))
|
||||
},
|
||||
},
|
||||
{
|
||||
target: svgRef,
|
||||
eventOptions: { passive: false }, // so onWheel can preventDefault
|
||||
drag: { filterTaps: true },
|
||||
pinch: {
|
||||
// Seed the pinch offset with the current scale so offset[0] is absolute.
|
||||
from: () => [useEditorStore.getState().viewport.scale, 0],
|
||||
scaleBounds: { min: MIN_SCALE, max: MAX_SCALE },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const fitToRect = useCallback(
|
||||
(rect: Rect, canvasSize: Size) => {
|
||||
cancelAnim()
|
||||
const from = useEditorStore.getState().viewport
|
||||
const to = zoomToFitRect(rect, canvasSize, FIT_PADDING, MIN_SCALE, MAX_SCALE)
|
||||
const start = performance.now()
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / FIT_DURATION_MS)
|
||||
const e = easeInOutCubic(t)
|
||||
setViewport({ tx: lerp(from.tx, to.tx, e), ty: lerp(from.ty, to.ty, e), scale: lerp(from.scale, to.scale, e) })
|
||||
animRef.current = t < 1 ? requestAnimationFrame(step) : null
|
||||
}
|
||||
animRef.current = requestAnimationFrame(step)
|
||||
},
|
||||
[cancelAnim, setViewport],
|
||||
)
|
||||
|
||||
useEffect(() => cancelAnim, [cancelAnim]) // stop any tween on unmount
|
||||
|
||||
return { fitToRect }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { monogramFor, monogramMap, speciesName } from './monogram'
|
||||
|
||||
describe('speciesName', () => {
|
||||
it('drops a variety suffix and a parenthetical', () => {
|
||||
expect(speciesName('Tomato — Cherokee Purple')).toBe('Tomato')
|
||||
expect(speciesName('Melon - Hale’s Best')).toBe('Melon')
|
||||
expect(speciesName('Garlic (Music)')).toBe('Garlic')
|
||||
expect(speciesName('Kale')).toBe('Kale')
|
||||
})
|
||||
})
|
||||
|
||||
describe('monogramMap', () => {
|
||||
it('gives a bare initial where it is unique', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 1, name: 'Garlic' },
|
||||
{ id: 2, name: 'Tomato' },
|
||||
])
|
||||
expect(m.get(1)).toBe('G')
|
||||
expect(m.get(2)).toBe('T')
|
||||
})
|
||||
|
||||
it('resolves a shared initial by catalog order, second letter lowercase', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 6, name: 'Tomato' },
|
||||
{ id: 26, name: 'Thyme' },
|
||||
{ id: 32, name: 'Marigold' },
|
||||
{ id: 29, name: 'Mint' },
|
||||
])
|
||||
expect(m.get(6)).toBe('T') // first into the catalog keeps the initial
|
||||
expect(m.get(26)).toBe('Th')
|
||||
expect(m.get(29)).toBe('M')
|
||||
expect(m.get(32)).toBe('Ma')
|
||||
})
|
||||
|
||||
it('takes the next free second letter, never repeating the initial', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 5, name: 'Pea' },
|
||||
{ id: 7, name: 'Pepper' },
|
||||
{ id: 24, name: 'Parsley' },
|
||||
])
|
||||
expect(m.get(5)).toBe('P')
|
||||
expect(m.get(7)).toBe('Pe')
|
||||
expect(m.get(24)).toBe('Pa')
|
||||
const n = monogramMap([
|
||||
{ id: 1, name: 'Pea' },
|
||||
{ id: 2, name: 'Peach' },
|
||||
{ id: 3, name: 'Pepper' },
|
||||
])
|
||||
expect(n.get(2)).toBe('Pe')
|
||||
expect(n.get(3)).toBe('Pr') // "Pp" would repeat the initial
|
||||
})
|
||||
|
||||
it('uses the species for varieties and keeps the plain one first', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 6, name: 'Tomato' },
|
||||
{ id: 40, name: 'Tomato — Cherokee Purple' },
|
||||
])
|
||||
expect(m.get(6)).toBe('T')
|
||||
expect(m.get(40)).toBe('To')
|
||||
})
|
||||
|
||||
it('marks an unletterable name with a placeholder', () => {
|
||||
expect(monogramMap([{ id: 1, name: '123' }]).get(1)).toBe('?')
|
||||
expect(monogramFor('🌱')).toBe('?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// Plant markers are a solid circle in the plant's color with a 1–2 letter
|
||||
// monogram — the design's replacement for emoji icons. The letters are derived
|
||||
// from the name, never stored, so a renamed plant re-letters itself.
|
||||
//
|
||||
// Rule: the initial of the species (the part before a " — Variety" suffix).
|
||||
// Within one catalog, plants that share an initial are told apart by a second,
|
||||
// lowercase letter — Marigold → Ma, Mint → Mi. The bare initial goes to the
|
||||
// plant that entered the catalog first (lowest id): the built-ins are seeded
|
||||
// roughly commonest-first, so Tomato keeps T over Thyme, and a variety you add
|
||||
// later ("Tomato — Cherokee Purple") gets To rather than displacing it. The
|
||||
// collision set is the whole catalog, so a plant's letters are the same on
|
||||
// every screen.
|
||||
|
||||
/** "Tomato — Cherokee Purple" → "Tomato"; "Melon (Hale's Best)" → "Melon". */
|
||||
export function speciesName(name: string): string {
|
||||
const cut = name.split(/\s[—–-]\s|\s\(/)[0] ?? name
|
||||
return cut.trim() || name.trim()
|
||||
}
|
||||
|
||||
function letters(name: string): string[] {
|
||||
// Letters only; a leading digit or punctuation would make an unreadable mark.
|
||||
return speciesName(name)
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^A-Za-z]/g, '')
|
||||
.split('')
|
||||
}
|
||||
|
||||
/** The monogram for every plant in the catalog, keyed by id. */
|
||||
export function monogramMap<P extends { id: number; name: string }>(plants: readonly P[]): Map<number, string> {
|
||||
const out = new Map<number, string>()
|
||||
const taken = new Set<string>()
|
||||
const sorted = [...plants].sort((a, b) => a.id - b.id || a.name.localeCompare(b.name))
|
||||
for (const p of sorted) {
|
||||
const ls = letters(p.name)
|
||||
if (ls.length === 0) {
|
||||
out.set(p.id, '?')
|
||||
continue
|
||||
}
|
||||
const initial = ls[0].toUpperCase()
|
||||
// Prefer the bare initial, then initial + each following letter in turn —
|
||||
// skipping a repeat of the initial ("Pp" is not a mark anyone can read) —
|
||||
// then give up on uniqueness rather than grow past two letters.
|
||||
const candidates = [
|
||||
initial,
|
||||
...ls
|
||||
.slice(1)
|
||||
.filter((l) => l.toUpperCase() !== initial)
|
||||
.map((l) => initial + l.toLowerCase()),
|
||||
]
|
||||
const pick = candidates.find((c) => !taken.has(c)) ?? candidates[candidates.length - 1]
|
||||
taken.add(pick)
|
||||
out.set(p.id, pick)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A single plant's monogram when the catalog isn't at hand (a fallback, not the
|
||||
* collision-aware mark — prefer monogramMap where a list exists). */
|
||||
export function monogramFor(name: string): string {
|
||||
const ls = letters(name)
|
||||
return ls.length ? ls[0].toUpperCase() : '?'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
|
||||
|
||||
describe('plan names', () => {
|
||||
it('round-trips the default copy name', () => {
|
||||
expect(planNameFor('Home Garden', 2027)).toBe('Home Garden — 2027')
|
||||
expect(parsePlanName('Home Garden — 2027')).toEqual({ base: 'Home Garden', year: 2027 })
|
||||
})
|
||||
|
||||
it('accepts a plain hyphen or en dash with spaces around it', () => {
|
||||
expect(parsePlanName('Back forty - 2028')).toEqual({ base: 'Back forty', year: 2028 })
|
||||
expect(parsePlanName('Back forty – 2028')).toEqual({ base: 'Back forty', year: 2028 })
|
||||
})
|
||||
|
||||
it('does not read a bare year or an unspaced dash as a plan', () => {
|
||||
expect(parsePlanName('2027')).toBeNull()
|
||||
expect(parsePlanName('Plot-2027')).toBeNull()
|
||||
expect(parsePlanName('Home Garden')).toBeNull()
|
||||
})
|
||||
|
||||
it('tags only this year and later as plans', () => {
|
||||
const now = new Date(2026, 7, 22)
|
||||
expect(planYearOf('Home Garden — 2027', now)).toBe(2027)
|
||||
expect(planYearOf('Home Garden — 2026', now)).toBe(2026)
|
||||
expect(planYearOf('Home Garden — 2024', now)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds a garden’s plan copies in year order', () => {
|
||||
const gardens = [
|
||||
{ id: 1, name: 'Home Garden' },
|
||||
{ id: 2, name: 'Home Garden — 2028' },
|
||||
{ id: 3, name: 'Home Garden — 2027' },
|
||||
{ id: 4, name: 'Balcony — 2027' },
|
||||
]
|
||||
expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
// Season plans are whole-garden copies (POST /gardens/:id/copy): a separate
|
||||
// garden you rearrange freely while this year's stays put. The API keeps no link
|
||||
// between a copy and its source, so the relationship rides on the name the copy
|
||||
// is given by default — "Home Garden — 2027" — which is what the editor's season
|
||||
// control and the gardens list read back. Rename the copy and it is simply a
|
||||
// garden again; nothing breaks, it just stops being offered as a plan.
|
||||
|
||||
const PLAN_RE = /^(.+?)\s+[—–-]\s+(\d{4})$/
|
||||
|
||||
export interface PlanName {
|
||||
base: string
|
||||
year: number
|
||||
}
|
||||
|
||||
/** "Home Garden — 2027" → { base: "Home Garden", year: 2027 }, else null. */
|
||||
export function parsePlanName(name: string): PlanName | null {
|
||||
const m = name.trim().match(PLAN_RE)
|
||||
if (!m) return null
|
||||
return { base: m[1].trim(), year: Number(m[2]) }
|
||||
}
|
||||
|
||||
/** The default name for a plan copy of `base` for `year`. */
|
||||
export function planNameFor(base: string, year: number): string {
|
||||
return `${base} — ${year}`
|
||||
}
|
||||
|
||||
/** Whether a garden reads as a forward-looking plan (a year-suffixed copy for
|
||||
* this year or later). Past years are archives, not plans, and get no tag. */
|
||||
export function planYearOf(name: string, now = new Date()): number | null {
|
||||
const p = parsePlanName(name)
|
||||
return p && p.year >= now.getFullYear() ? p.year : null
|
||||
}
|
||||
|
||||
/** The plan copies of `base` among `gardens`, ascending by year. */
|
||||
export function planGardensOf<G extends { id: number; name: string }>(base: string, gardens: readonly G[]): (G & { year: number })[] {
|
||||
const out: (G & { year: number })[] = []
|
||||
for (const g of gardens) {
|
||||
const p = parsePlanName(g.name)
|
||||
if (p && p.base === base.trim()) out.push({ ...g, year: p.year })
|
||||
}
|
||||
return out.sort((a, b) => a.year - b.year)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// The Seed Tray: a small, deliberately-curated set of plants a gardener keeps at
|
||||
// hand while planting a bed, so repeat placement is a single tap instead of a
|
||||
// trip through the full catalog. It's a per-garden convenience, persisted in
|
||||
// localStorage (same rationale as the PlantPicker's "recent plants"): a nicety,
|
||||
// not authoritative state, so a quota/availability failure is swallowed.
|
||||
//
|
||||
// Only plant ids are stored; they resolve against the live catalog on read, so a
|
||||
// renamed plant stays current and a deleted one silently drops out of the tray.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { usePlants, type Plant } from './plants'
|
||||
|
||||
const KEY = (gardenId: number) => `pansy:seed-tray:${gardenId}`
|
||||
const TRAY_MAX = 24 // a working set, not a catalog; caps pathological growth
|
||||
|
||||
function loadIds(gardenId: number): number[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]')
|
||||
const ids = Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
||||
return ids.slice(-TRAY_MAX) // honor the cap even if storage was hand-edited
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveIds(gardenId: number, ids: number[]) {
|
||||
try {
|
||||
localStorage.setItem(KEY(gardenId), JSON.stringify(ids))
|
||||
} catch {
|
||||
// The tray is a convenience; ignore quota/availability failures.
|
||||
}
|
||||
}
|
||||
|
||||
export interface SeedTrayState {
|
||||
/** Tray plants in insertion order, resolved against the live catalog. */
|
||||
trayPlants: Plant[]
|
||||
/** Add a plant to the end of the tray (no-op if already present; when the tray
|
||||
* is full the oldest entry is evicted). */
|
||||
add: (plant: Plant) => void
|
||||
/** Remove a plant from the tray. */
|
||||
remove: (id: number) => void
|
||||
}
|
||||
|
||||
/** Per-garden Seed Tray backed by localStorage and the plant catalog. */
|
||||
export function useSeedTray(gardenId: number): SeedTrayState {
|
||||
const plants = usePlants()
|
||||
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
||||
|
||||
// Re-read when switching gardens (the route param changes without remounting).
|
||||
useEffect(() => {
|
||||
setIds(loadIds(gardenId))
|
||||
}, [gardenId])
|
||||
|
||||
const byId = useMemo(() => new Map((plants.data ?? []).map((p) => [p.id, p])), [plants.data])
|
||||
|
||||
// Resolve ids → plants, dropping any no longer in the catalog; preserve order.
|
||||
const trayPlants = useMemo(
|
||||
() => ids.map((id) => byId.get(id)).filter((p): p is Plant => !!p),
|
||||
[ids, byId],
|
||||
)
|
||||
|
||||
const add = useCallback(
|
||||
(plant: Plant) =>
|
||||
setIds((prev) => {
|
||||
if (prev.includes(plant.id)) return prev
|
||||
const next = [...prev, plant.id].slice(-TRAY_MAX)
|
||||
saveIds(gardenId, next)
|
||||
return next
|
||||
}),
|
||||
[gardenId],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
(id: number) =>
|
||||
setIds((prev) => {
|
||||
const next = prev.filter((x) => x !== id)
|
||||
saveIds(gardenId, next)
|
||||
return next
|
||||
}),
|
||||
[gardenId],
|
||||
)
|
||||
|
||||
return { trayPlants, add, remove }
|
||||
}
|
||||
+25
-9
@@ -1,9 +1,10 @@
|
||||
// Instance settings data layer (#79): admin-only, instance-wide configuration.
|
||||
//
|
||||
// The GET/PATCH return both the stored settings and a read-only "effective" view
|
||||
// — what's actually in force after layering the DB over the environment — so the
|
||||
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
|
||||
// whether the API key is present, without the key ever crossing the wire.
|
||||
// The GET/PATCH return the stored settings, a read-only "effective" view — what's
|
||||
// actually in force after layering the DB over the environment — and a read-only
|
||||
// view of the sign-in configuration, so the Settings page can say "inheriting
|
||||
// ollama-cloud/glm-5.2:cloud from the environment", whether the API key is
|
||||
// present (never its value), and who gets in.
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
@@ -15,6 +16,8 @@ export const instanceSettingsSchema = z.object({
|
||||
agentModel: z.string(),
|
||||
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
|
||||
agentEnabled: z.boolean().nullable(),
|
||||
// '' means "inherit PANSY_VISION_MODEL" (and with nothing there, no scanning).
|
||||
visionModel: z.string().default(''),
|
||||
version: z.number(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -25,12 +28,26 @@ export const effectiveAgentSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
hasApiKey: z.boolean(),
|
||||
agentLive: z.boolean(),
|
||||
visionModel: z.string().default(''),
|
||||
visionReady: z.boolean().default(false),
|
||||
})
|
||||
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
|
||||
|
||||
// Environment-driven sign-in config, reported read-only so the "Who gets in"
|
||||
// card shows what's in force. Defaults keep an older server's response parsing.
|
||||
export const authViewSchema = z.object({
|
||||
registration: z.enum(['open', 'closed']).catch('open'),
|
||||
localAuth: z.boolean().default(true),
|
||||
oidc: z.boolean().default(false),
|
||||
oidcIssuer: z.string().default(''),
|
||||
oidcLabel: z.string().default(''),
|
||||
})
|
||||
export type AuthView = z.infer<typeof authViewSchema>
|
||||
|
||||
export const settingsResponseSchema = z.object({
|
||||
settings: instanceSettingsSchema,
|
||||
effective: effectiveAgentSchema,
|
||||
auth: authViewSchema.default({ registration: 'open', localAuth: true, oidc: false, oidcIssuer: '', oidcLabel: '' }),
|
||||
})
|
||||
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
|
||||
|
||||
@@ -38,8 +55,7 @@ export const settingsKey = ['settings'] as const
|
||||
|
||||
export const settingsQueryOptions = queryOptions({
|
||||
queryKey: settingsKey,
|
||||
queryFn: async (): Promise<SettingsResponse> =>
|
||||
settingsResponseSchema.parse(await api.get('/settings')),
|
||||
queryFn: async (): Promise<SettingsResponse> => settingsResponseSchema.parse(await api.get('/settings')),
|
||||
})
|
||||
|
||||
export function useSettings() {
|
||||
@@ -49,6 +65,7 @@ export function useSettings() {
|
||||
export interface SettingsUpdate {
|
||||
agentModel: string
|
||||
agentEnabled: boolean | null
|
||||
visionModel: string
|
||||
version: number
|
||||
}
|
||||
|
||||
@@ -59,9 +76,8 @@ export function useUpdateSettings() {
|
||||
settingsResponseSchema.parse(await api.patch('/settings', input)),
|
||||
onSuccess: (res) => {
|
||||
qc.setQueryData(settingsKey, res)
|
||||
// The save may have turned the assistant on or off; the editor keys its
|
||||
// chat tab off /capabilities, so make it re-read rather than trust its
|
||||
// cached answer.
|
||||
// The save may have turned the assistant or scanning on or off; the editor
|
||||
// keys those off /capabilities, so make it re-read.
|
||||
qc.invalidateQueries({ queryKey: capabilitiesKey })
|
||||
},
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user