Add plops editor: focus mode, place/move/resize, semantic zoom (#15)
Pansy's core vision: click into a bed, plop plants in its corners, zoom out and still see what's planted where. Built on the field editor (#11), PlantPicker (#13), and plantings API (#14). Data layer - lib/plantings.ts: ServerPlanting zod shape, EditorPlanting, effectiveCount, and a client mirror of the derived-count formula (computeDerivedCount) for live resize display; unit-tested. - lib/objects.ts: /full now types plantings + plants; useCreatePlanting / useUpdatePlanting / useRemovePlanting patch the same FullGarden cache with the #11 optimistic + 409-rollback contract. A soft-remove drops the plop from the active list. Editor - store: selectedPlantingId / livePlanting / armedPlant (mutually-exclusive object vs plop selection). - Focus mode: "Plant here" (object inspector) → focusedObjectId; the canvas animates fitToRect to the object and dims the rest; a breadcrumb / Escape / empty-tap exits. focusedObjectId mirrors to ?focus (deep-linkable, survives reload). - Placing: "+ Add plant" opens the PlantPicker; the chosen plant stays armed for repeat placement; tapping inside the focused object drops a plop (default radius max(1.5·spacing, 15cm)) in the object's local frame. - Manipulating: PlopOverlay (drag to move, edge handle to resize, one PATCH per gesture) + PlopInspector (change plant, radius, count override vs live derived, label, planted date, soft-remove). - PlopMarker semantic zoom (px/cm bands): far = flat color patch + a dominant- plant tint on the object; mid = circle + emoji; near = + name + count. Plops render inside each object's rotated group, so moving/rotating a bed carries them with no planting writes. tsc --noEmit clean; 24/24 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
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 { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
|
||||
const MIN_RADIUS_CM = 1
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function PlopInspector({
|
||||
plop,
|
||||
plant,
|
||||
gardenId,
|
||||
unit,
|
||||
onChangePlant,
|
||||
onClose,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant?: Plant
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
onChangePlant: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
const remove = useRemovePlanting(gardenId)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [radius, setRadius] = useState(String(spacingFromCm(plop.radiusCm, unit)))
|
||||
const [count, setCount] = useState(plop.count != null ? String(plop.count) : '')
|
||||
const [label, setLabel] = useState(plop.label ?? '')
|
||||
const [planted, setPlanted] = useState(plop.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(plop.radiusCm, unit)))
|
||||
setCount(plop.count != null ? String(plop.count) : '')
|
||||
setLabel(plop.label ?? '')
|
||||
setPlanted(plop.plantedAt ?? '')
|
||||
}, [plop.version, plop.radiusCm, plop.count, plop.label, plop.plantedAt, unit])
|
||||
|
||||
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) =>
|
||||
update.mutate({ id: plop.id, version: plop.version, ...fields })
|
||||
|
||||
const u = spacingUnitLabel(unit)
|
||||
const derived = plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount
|
||||
|
||||
function commitRadius() {
|
||||
const v = parseFloat(radius)
|
||||
if (!Number.isFinite(v)) return
|
||||
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
|
||||
if (cm !== plop.radiusCm) patch({ radiusCm: cm })
|
||||
}
|
||||
|
||||
function commitCount() {
|
||||
if (count.trim() === '') {
|
||||
if (plop.count != null) patch({ count: null }) // restore derived
|
||||
return
|
||||
}
|
||||
const n = Number(count)
|
||||
if (!Number.isInteger(n) || n < 1) return
|
||||
if (n !== plop.count) patch({ count: n })
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onChangePlant}>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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={plop.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 !== (plop.label ?? '') && patch({ label: label.trim() || null })}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Planted"
|
||||
name="planted"
|
||||
type="date"
|
||||
value={planted}
|
||||
onChange={(e) => setPlanted(e.target.value)}
|
||||
onBlur={() => planted !== (plop.plantedAt ?? '') && planted !== '' && patch({ plantedAt: planted })}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user