Files
pansy/web/src/editor/Inspector.tsx
T
steveandClaude Opus 4.8 d94c967310 Journal UI: write and read season notes where you're already looking (#53)
happens. 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 journal is a tab in the rail #49 settled, and selecting a bed puts a
"📝 Add note" button in the inspector that scopes the journal to that bed and
switches to it. The composer is already open in there rather than behind an
"add" button, which makes it two taps from a selected bed to typing.

The observed date defaults to today in the VIEWER's timezone, not UTC — "today"
means the day you are standing in the garden — and stays editable, because you
write up Saturday's observations on Sunday.

Discoverability is the other half. A log you have to remember exists is a log
nobody reads, so the inspector button carries the note count for that bed and
the Journal tab carries the garden's total: a garden with a season's notes in it
no longer looks identical to an empty one. Those counts come from their own
endpoint, deliberately — the indicator has to work while the journal panel is
closed, which is exactly when the entry list hasn't loaded.

Permissions match the service: anyone who can edit the garden can write, the
author can edit their own text, and the author or the garden owner can delete.
A viewer sees the entries and the count but no composer.

The empty state says what the journal is for rather than just "no entries",
since it starts empty for a whole season's worth of gardens and the reason to
start is the thing worth saying.

Closes #53

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:55:14 -04:00

324 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}