Sharing UI: invite by email, roles, read-only viewer mode (#17)
Build image / build-and-push (push) Successful in 8s
Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #36.
This commit is contained in:
@@ -34,11 +34,13 @@ export function GardenCanvas({
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
canEdit,
|
||||
}: {
|
||||
garden: EditorGarden
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -108,7 +110,9 @@ export function GardenCanvas({
|
||||
// or exit focus mode, or just deselect.
|
||||
function onCanvasPointerDown(e: ReactPointerEvent) {
|
||||
const armed = useEditorStore.getState().armedKind
|
||||
if (armed) {
|
||||
// Defense in depth: a viewer can't place objects even if a stale armed kind
|
||||
// slipped through (the palette isn't rendered for them).
|
||||
if (armed && canEdit) {
|
||||
useEditorStore.getState().setArmedKind(null)
|
||||
const def = kindDef(armed)
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
@@ -134,7 +138,7 @@ export function GardenCanvas({
|
||||
// armed for repeat-placement until Escape / Done).
|
||||
function onPlace(e: ReactPointerEvent) {
|
||||
e.stopPropagation()
|
||||
if (!focusedObject || !armedPlant || !focusedObject.plantable) return
|
||||
if (!canEdit || !focusedObject || !armedPlant || !focusedObject.plantable) return
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||
@@ -188,14 +192,16 @@ export function GardenCanvas({
|
||||
onSelectPlop={selectPlanting}
|
||||
/>
|
||||
|
||||
{selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />}
|
||||
{selectedPlop && selectedPlopObject && (
|
||||
{/* Edit handles are mounted only for editors/owners; viewers can still
|
||||
select to inspect (read-only), but never move/resize. */}
|
||||
{canEdit && selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />}
|
||||
{canEdit && selectedPlop && selectedPlopObject && (
|
||||
<PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} />
|
||||
)}
|
||||
|
||||
{/* Placement capture: a transparent sheet over the focused object while a
|
||||
plant is armed, so taps drop plops instead of selecting the object. */}
|
||||
{focusedObject && armedPlant && focusedObject.plantable && (
|
||||
{canEdit && focusedObject && armedPlant && focusedObject.plantable && (
|
||||
<g transform={objectTransform(focusedObject)}>
|
||||
<rect
|
||||
x={-halfFW}
|
||||
|
||||
+144
-131
@@ -27,11 +27,13 @@ export function Inspector({
|
||||
gardenId,
|
||||
unit,
|
||||
onFocus,
|
||||
readOnly = false,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
onFocus?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const update = useUpdateObject(gardenId)
|
||||
const del = useDeleteObject(gardenId)
|
||||
@@ -64,8 +66,10 @@ export function Inspector({
|
||||
setColor(object.color ?? DEFAULT_COLOR)
|
||||
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit])
|
||||
|
||||
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) =>
|
||||
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. Compare at
|
||||
@@ -96,148 +100,157 @@ export function Inspector({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{object.plantable && onFocus && (
|
||||
{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>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => name !== object.name && patch({ name })}
|
||||
/>
|
||||
{/* 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="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
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="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
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="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={x}
|
||||
onChange={(e) => setX(e.target.value)}
|
||||
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
||||
/>
|
||||
<TextField
|
||||
label={`Y (${u})`}
|
||||
name="y"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
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 className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Width (${u})`}
|
||||
name="width"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
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="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
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="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={x}
|
||||
onChange={(e) => setX(e.target.value)}
|
||||
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
||||
/>
|
||||
<TextField
|
||||
label={`Y (${u})`}
|
||||
name="y"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={y}
|
||||
onChange={(e) => setY(e.target.value)}
|
||||
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
|
||||
/>
|
||||
</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"
|
||||
<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 })
|
||||
}}
|
||||
/>
|
||||
Plantable
|
||||
</label>
|
||||
|
||||
<TextArea
|
||||
label="Notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
||||
/>
|
||||
|
||||
{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 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>
|
||||
) : (
|
||||
<Button variant="ghost" className="text-red-600 dark:text-red-400" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete object
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function PlopInspector({
|
||||
unit,
|
||||
onChangePlant,
|
||||
onClose,
|
||||
readOnly = false,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant?: Plant
|
||||
@@ -32,6 +33,7 @@ export function PlopInspector({
|
||||
unit: UnitPref
|
||||
onChangePlant: () => void
|
||||
onClose: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
const remove = useRemovePlanting(gardenId)
|
||||
@@ -56,8 +58,10 @@ export function PlopInspector({
|
||||
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'>) =>
|
||||
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
|
||||
@@ -104,6 +108,10 @@ export function PlopInspector({
|
||||
</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" />
|
||||
@@ -111,66 +119,72 @@ export function PlopInspector({
|
||||
<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
|
||||
{!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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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