Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
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<HTMLDivElement>(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<Omit<EditorObject, 'id' | 'version'>>) => {
|
||
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 (
|
||
<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>
|
||
</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
|
||
</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"
|
||
/>
|
||
Plantable
|
||
</label>
|
||
|
||
{/* 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">
|
||
<TextField
|
||
label={`Bed grid (${spacingUnitLabel(unit)})`}
|
||
name="gridSize"
|
||
type="number"
|
||
inputMode="decimal"
|
||
step="any"
|
||
min="0"
|
||
value={gridSize}
|
||
onChange={(e) => setGridSize(e.target.value)}
|
||
onBlur={commitGrid}
|
||
/>
|
||
</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>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|