Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
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,
|
|
readOnly = false,
|
|
}: {
|
|
plop: EditorPlanting
|
|
plant?: Plant
|
|
gardenId: number
|
|
unit: UnitPref
|
|
onChangePlant: () => void
|
|
onClose: () => 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>
|
|
|
|
{!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>
|
|
)
|
|
}
|