From c2dd93a93de0c08ab8c5de8c6e56c0de670a7ddf Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 04:14:30 +0000 Subject: [PATCH] Sharing UI: invite by email, roles, read-only viewer mode (#17) Co-authored-by: Steve Dudenhoeffer --- web/src/components/gardens/GardenCard.tsx | 54 ++-- .../components/gardens/LeaveGardenModal.tsx | 47 +++ .../components/gardens/ShareGardenModal.tsx | 132 +++++++++ web/src/components/plants/PlantCard.tsx | 14 +- web/src/components/ui/cardActions.ts | 10 + web/src/editor/GardenCanvas.tsx | 16 +- web/src/editor/Inspector.tsx | 275 +++++++++--------- web/src/editor/PlopInspector.tsx | 134 +++++---- web/src/lib/gardens.ts | 16 + web/src/lib/shares.ts | 70 +++++ web/src/pages/GardenEditorPage.tsx | 49 +++- web/src/pages/GardensPage.tsx | 19 +- 12 files changed, 598 insertions(+), 238 deletions(-) create mode 100644 web/src/components/gardens/LeaveGardenModal.tsx create mode 100644 web/src/components/gardens/ShareGardenModal.tsx create mode 100644 web/src/components/ui/cardActions.ts create mode 100644 web/src/lib/shares.ts diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx index 61828f5..ebab3c2 100644 --- a/web/src/components/gardens/GardenCard.tsx +++ b/web/src/components/gardens/GardenCard.tsx @@ -1,20 +1,30 @@ import { Link } from '@tanstack/react-router' import type { Garden } from '@/lib/gardens' import { formatDimensions } from '@/lib/units' +import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions' /** - * One garden as a card: the body links into the editor (/gardens/:id); the - * footer has edit/delete actions (kept out of the link so they don't navigate). + * One garden as a card: the body links into the editor. The footer differs by + * role — the owner gets Share / Edit / Delete; a recipient sees a "shared · role" + * badge and a Leave action (garden metadata edit + sharing are owner-only). + * Ownership is the authoritative ownerId==me check, not the my_role hint. */ export function GardenCard({ garden, + currentUserId, + onShare, onEdit, onDelete, + onLeave, }: { garden: Garden + currentUserId?: number + onShare: () => void onEdit: () => void onDelete: () => void + onLeave: () => void }) { + const owner = currentUserId != null && garden.ownerId === currentUserId return (
-

{garden.name}

+
+

{garden.name}

+ {!owner && garden.myRole && ( + + shared · {garden.myRole} + + )} +

{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}

{garden.notes &&

{garden.notes}

}
- - + {owner ? ( + <> + + + + + ) : ( + + )}
) diff --git a/web/src/components/gardens/LeaveGardenModal.tsx b/web/src/components/gardens/LeaveGardenModal.tsx new file mode 100644 index 0000000..85c9dce --- /dev/null +++ b/web/src/components/gardens/LeaveGardenModal.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { Modal } from '@/components/ui/Modal' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { errorMessage } from '@/lib/api' +import { useMe } from '@/lib/auth' +import type { Garden } from '@/lib/gardens' +import { useRemoveShare } from '@/lib/shares' + +/** Confirmation for a recipient leaving a garden shared with them (removes their + * own share). */ +export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { + const me = useMe() + const remove = useRemoveShare(garden.id) + const [error, setError] = useState(null) + + async function onConfirm() { + if (!me.data) return + setError(null) + try { + await remove.mutateAsync(me.data.id) + onClose() + } catch (err) { + setError(errorMessage(err, 'Could not leave the garden.')) + } + } + + return ( + +
+

+ Leave {garden.name}? You'll lose access until the owner + shares it with you again. +

+ {error && {error}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/gardens/ShareGardenModal.tsx b/web/src/components/gardens/ShareGardenModal.tsx new file mode 100644 index 0000000..a4a706d --- /dev/null +++ b/web/src/components/gardens/ShareGardenModal.tsx @@ -0,0 +1,132 @@ +import { useState, type FormEvent } from 'react' +import { Modal } from '@/components/ui/Modal' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { Select } from '@/components/ui/Select' +import { TextField } from '@/components/ui/TextField' +import { cn } from '@/lib/cn' +import { fieldControlClass } from '@/components/ui/field' +import { errorMessage } from '@/lib/api' +import type { Garden } from '@/lib/gardens' +import { useAddShare, useRemoveShare, useShares, useUpdateShareRole, type ShareRole } from '@/lib/shares' + +const roleOptions = [ + { value: 'viewer', label: 'Viewer (read-only)' }, + { value: 'editor', label: 'Editor (can edit)' }, +] + +/** + * Owner-only dialog to manage a garden's shares: invite an existing user by + * email as viewer/editor, change a share's role, or remove it. Targets existing + * accounts only (v1 has no invitation emails) — an unknown email surfaces a + * friendly "no account with that email". + */ +export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { + const shares = useShares(garden.id) + const add = useAddShare(garden.id) + const updateRole = useUpdateShareRole(garden.id) + const remove = useRemoveShare(garden.id) + + const [email, setEmail] = useState('') + const [role, setRole] = useState('viewer') + const [error, setError] = useState(null) + + async function onInvite(e: FormEvent) { + e.preventDefault() + setError(null) + if (!email.trim()) { + setError('Enter an email address.') + return + } + try { + await add.mutateAsync({ email: email.trim(), role }) + setEmail('') + } catch (err) { + setError(errorMessage(err, 'Could not share the garden.')) + } + } + + const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback)) + + return ( + +
+
+ setEmail(e.target.value)} + /> +
+ { + setError(null) + updateRole.mutate( + { userId: sh.userId, role: e.target.value as ShareRole }, + { onError: onMutationError('Could not change that role.') }, + ) + }} + aria-label={`Role for ${sh.displayName}`} + className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')} + > + + + + + + ))} + +
+ +
+ +
+
+
+ ) +} diff --git a/web/src/components/plants/PlantCard.tsx b/web/src/components/plants/PlantCard.tsx index c772e9d..fe56d6b 100644 --- a/web/src/components/plants/PlantCard.tsx +++ b/web/src/components/plants/PlantCard.tsx @@ -1,14 +1,8 @@ import { PlantIcon } from './PlantIcon' +import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions' import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants' import { formatSpacing, type UnitPref } from '@/lib/units' -const actionClass = - 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + - 'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40' -const dangerClass = - 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + - 'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400' - /** * One catalog plant as a card: icon tile tinted with the plant's color, name, * category + mature spacing (unit-aware), and actions. Built-ins are badged and @@ -53,16 +47,16 @@ export function PlantCard({ />
- {!builtin && ( - )} {!builtin && ( - )} diff --git a/web/src/components/ui/cardActions.ts b/web/src/components/ui/cardActions.ts new file mode 100644 index 0000000..241498e --- /dev/null +++ b/web/src/components/ui/cardActions.ts @@ -0,0 +1,10 @@ +// Shared footer-action button styling for list cards (garden/plant), so the +// verbatim class strings don't drift between them. + +export const cardActionClass = + 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + + 'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40' + +export const cardDangerClass = + 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + + 'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400' diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index 5b27758..ca23c68 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -34,11 +34,13 @@ export function GardenCanvas({ objects, plantings, plantsById, + canEdit, }: { garden: EditorGarden objects: EditorObject[] plantings: EditorPlanting[] plantsById: Map + canEdit: boolean }) { const svgRef = useRef(null) const containerRef = useRef(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 && } - {selectedPlop && selectedPlopObject && ( + {/* Edit handles are mounted only for editors/owners; viewers can still + select to inspect (read-only), but never move/resize. */} + {canEdit && selectedObject && } + {canEdit && selectedPlop && selectedPlopObject && ( )} {/* 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 && ( 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>) => + 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. Compare at @@ -96,148 +100,157 @@ export function Inspector({
- {object.plantable && onFocus && ( + {readOnly && ( +

View only — you can't edit this garden.

+ )} + + {!readOnly && object.plantable && onFocus && ( )} - 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). */} +
+ 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" +
+ 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 }))} />
- {object.color && ( - - )} -
- -