diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx
index 61828f5..56f6f06 100644
--- a/web/src/components/gardens/GardenCard.tsx
+++ b/web/src/components/gardens/GardenCard.tsx
@@ -1,20 +1,33 @@
import { Link } from '@tanstack/react-router'
-import type { Garden } from '@/lib/gardens'
+import { isOwnerRole, type Garden } from '@/lib/gardens'
import { formatDimensions } 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 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).
*/
export function GardenCard({
garden,
+ onShare,
onEdit,
onDelete,
+ onLeave,
}: {
garden: Garden
+ onShare: () => void
onEdit: () => void
onDelete: () => void
+ onLeave: () => void
}) {
+ const owner = isOwnerRole(garden.myRole)
return (
)
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..c9c6bf1
--- /dev/null
+++ b/web/src/components/gardens/ShareGardenModal.tsx
@@ -0,0 +1,121 @@
+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.'))
+ }
+ }
+
+ return (
+
+
+
+
+
+
Shared with
+ {shares.isPending &&
Loading…
}
+ {shares.isError && Could not load who this garden is shared with.}
+ {shares.isSuccess && shares.data.length === 0 && (
+
Not shared with anyone yet.
+ )}
+
+ {shares.data?.map((sh) => (
+
+
+
{sh.displayName}
+
{sh.email}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
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)
@@ -96,19 +98,26 @@ 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). */}
+