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 { cmFromSpacing, dimensionUnitLabel, dimensionInputMode, formatDimensionInput, MIN_DIMENSION_CM, parseDimension, spacingFromCm, spacingUnitLabel, type UnitPref, } from '@/lib/units' import { kindDef } from './kinds' import { useEditorStore } from './store' import type { EditorObject } from './types' const DEFAULT_COLOR = '#8a8a8a' /** * 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. */ export function Inspector({ object, gardenId, unit, onFocus, onAddNote, noteCount = 0, readOnly = false, }: { 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 }) { const update = useUpdateObject(gardenId) const del = useDeleteObject(gardenId) const select = useEditorStore((s) => s.select) // Local field state (initialized once; committed on blur/change). const [name, setName] = useState(object.name) const [notes, setNotes] = useState(object.notes) 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(null) // 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. 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]) const patch = (fields: Partial>) => { if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit 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 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 !== 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 const cm = cmFromSpacing(v, unit) if (cm >= MIN_DIMENSION_CM && cm !== object.gridSizeCm) patch({ gridSizeCm: cm }) } const u = dimensionUnitLabel(unit) const inputMode = dimensionInputMode(unit) return (

{kindDef(object.kind)?.label ?? object.kind}

{readOnly && (

View only — you can't edit this garden.

)} {!readOnly && object.plantable && onFocus && ( )} {onAddNote && ( )} {/* A disabled fieldset makes every control below read-only for viewers in one shot (no per-input disabled). */}
setName(e.target.value)} onBlur={() => name !== object.name && patch({ name })} />
setWidth(e.target.value)} onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)} /> setHeight(e.target.value)} onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)} /> setX(e.target.value)} onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))} /> setY(e.target.value)} onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))} />
setRotation(e.target.value)} onBlur={() => { const v = parseFloat(rotation) if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v }) }} />
) => 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" />
{object.color && ( )}
{/* Bed grid: only plantable beds place plants, so the plant-snapping grid is shown just for them. */} {object.plantable && (
setGridSize(e.target.value)} onBlur={commitGrid} />
)}