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}
}
-
- Edit
-
-
- Delete
-
+ {owner ? (
+ <>
+
+ Share
+
+
+ Edit
+
+
+ Delete
+
+ >
+ ) : (
+
+ Leave
+
+ )}
)
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} }
+
+
+ Cancel
+
+
+ {remove.isPending ? 'Leaving…' : 'Leave'}
+
+
+
+
+ )
+}
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 (
+
+
+
+
+
+
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}
+
+ {
+ 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')}
+ >
+ Viewer
+ Editor
+
+ {
+ setError(null)
+ remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
+ }}
+ aria-label={`Remove ${sh.displayName}`}
+ className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
+ >
+ ✕
+
+
+ ))}
+
+
+
+
+
+ Done
+
+
+
+
+ )
+}
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({
/>
-
+
Duplicate
{!builtin && (
-
+
Edit
)}
{!builtin && (
-
+
Delete
)}
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 && (
🌱 Plant here
)}
- 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 })
- }}
- />
-
-
-
-
- Color
-
-
) => 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 && (
-
{
- setColor(DEFAULT_COLOR)
- patch({ color: null })
- }}
- >
- Clear
-
- )}
-
-
- patch({ plantable: e.target.checked })}
- className="h-4 w-4 rounded border-border"
+ setRotation(e.target.value)}
+ onBlur={() => {
+ const v = parseFloat(rotation)
+ if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
+ }}
/>
- Plantable
-
-
+ {readOnly && (
+ View only — you can't edit this garden.
+ )}
+
{plant ? (
@@ -111,66 +119,72 @@ export function PlopInspector({
?
)}
{plant?.name ?? 'Unknown plant'}
-
- Change
+ {!readOnly && (
+
+ Change
+
+ )}
+
+
+
+
+ setRadius(e.target.value)}
+ onBlur={commitRadius}
+ />
+ setCount(e.target.value)}
+ onBlur={commitCount}
+ hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
+ />
+
+
+ setLabel(e.target.value)}
+ onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
+ />
+
+ setPlanted(e.target.value)}
+ onBlur={commitPlanted}
+ />
+
+
+ {!readOnly && (
+ {
+ onClose()
+ remove.mutate({ id: plop.id, version: plop.version })
+ }}
+ >
+ Remove plant
-
-
-
- setRadius(e.target.value)}
- onBlur={commitRadius}
- />
- setCount(e.target.value)}
- onBlur={commitCount}
- hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
- />
-
-
- setLabel(e.target.value)}
- onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
- />
-
- setPlanted(e.target.value)}
- onBlur={commitPlanted}
- />
-
- {
- onClose()
- remove.mutate({ id: plop.id, version: plop.version })
- }}
- >
- Remove plant
-
+ )}
)
}
diff --git a/web/src/lib/gardens.ts b/web/src/lib/gardens.ts
index a95410b..0f943e9 100644
--- a/web/src/lib/gardens.ts
+++ b/web/src/lib/gardens.ts
@@ -8,6 +8,9 @@ import type { UnitPref } from './units'
const unitPrefSchema = z.enum(['metric', 'imperial'])
+export const gardenRoleSchema = z.enum(['owner', 'editor', 'viewer'])
+export type GardenRole = z.infer
+
export const gardenSchema = z.object({
id: z.number(),
ownerId: z.number(),
@@ -19,9 +22,22 @@ export const gardenSchema = z.object({
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
+ // The actor's effective role on this garden (owner/editor/viewer). Present on
+ // every accessible response; optional defensively.
+ myRole: gardenRoleSchema.optional(),
})
export type Garden = z.infer
+/** Whether a role may edit garden contents (objects/plops). Unknown → false. */
+export function canEditRole(role?: GardenRole): boolean {
+ return role === 'owner' || role === 'editor'
+}
+
+/** Whether a role owns the garden (may edit metadata, manage shares, delete). */
+export function isOwnerRole(role?: GardenRole): boolean {
+ return role === 'owner'
+}
+
const gardensKey = ['gardens'] as const
export const gardensQueryOptions = queryOptions({
diff --git a/web/src/lib/shares.ts b/web/src/lib/shares.ts
new file mode 100644
index 0000000..dcbe3d3
--- /dev/null
+++ b/web/src/lib/shares.ts
@@ -0,0 +1,70 @@
+// Garden sharing data layer: zod shapes for /gardens/:id/shares plus the
+// react-query hooks the share dialog and "leave garden" action use.
+
+import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { z } from 'zod'
+import { api } from './api'
+
+export const shareRoleSchema = z.enum(['viewer', 'editor'])
+export type ShareRole = z.infer
+
+export const shareSchema = z.object({
+ id: z.number(),
+ gardenId: z.number(),
+ userId: z.number(),
+ role: shareRoleSchema,
+ createdBy: z.number(),
+ version: z.number(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ email: z.string(),
+ displayName: z.string(),
+})
+export type Share = z.infer
+
+const sharesKey = (gardenId: number) => ['shares', gardenId] as const
+
+export function sharesQueryOptions(gardenId: number) {
+ return queryOptions({
+ queryKey: sharesKey(gardenId),
+ queryFn: async (): Promise => z.array(shareSchema).parse(await api.get(`/gardens/${gardenId}/shares`)),
+ })
+}
+
+/** The shares on a garden (owner-only endpoint). */
+export function useShares(gardenId: number) {
+ return useQuery(sharesQueryOptions(gardenId))
+}
+
+// The add/update responses are a bare GardenShare (no email/displayName), so we
+// don't parse them into the list's Share shape; onSuccess refetches the list.
+export function useAddShare(gardenId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (input: { email: string; role: ShareRole }) => api.post(`/gardens/${gardenId}/shares`, input),
+ onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
+ })
+}
+
+export function useUpdateShareRole(gardenId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ userId, role }: { userId: number; role: ShareRole }) =>
+ api.patch(`/gardens/${gardenId}/shares/${userId}`, { role }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
+ })
+}
+
+/** Remove a share. Used both by the owner (revoke) and by a recipient leaving a
+ * garden (their own userId). Refreshes the garden list too, since a leave
+ * changes what the current user can see. */
+export function useRemoveShare(gardenId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (userId: number) => api.delete(`/gardens/${gardenId}/shares/${userId}`),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: sharesKey(gardenId) })
+ qc.invalidateQueries({ queryKey: ['gardens'] })
+ },
+ })
+}
diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx
index cf5b18d..daed6ee 100644
--- a/web/src/pages/GardenEditorPage.tsx
+++ b/web/src/pages/GardenEditorPage.tsx
@@ -10,6 +10,8 @@ import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
+import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
+import { useMe } from '@/lib/auth'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
@@ -21,6 +23,7 @@ export function GardenEditorPage() {
const { focus } = routeApi.useSearch()
const navigate = routeApi.useNavigate()
const full = useGardenFull(gid)
+ const me = useMe()
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
@@ -39,6 +42,7 @@ export function GardenEditorPage() {
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
+ const [sharing, setSharing] = useState(false)
const serverObjects = full.data?.objects
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
@@ -120,6 +124,11 @@ export function GardenEditorPage() {
heightCm: g.heightCm,
unitPref: g.unitPref,
}
+ // Role-driven gating. Ownership is the authoritative ownerId==me check (so a
+ // missing my_role can never lock an owner out); edit access is owner or an
+ // editor share.
+ const isOwner = me.data != null && g.ownerId === me.data.id
+ const canEdit = isOwner || g.myRole === 'editor'
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
@@ -127,6 +136,7 @@ export function GardenEditorPage() {
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
function onPickPlant(plantId: number) {
+ if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
const plant = plantsById.get(plantId)
if (!plant) return
if (picker === 'change' && selectedPlop) {
@@ -143,7 +153,15 @@ export function GardenEditorPage() {
{garden.name}
- {focusedObjectId == null && }
+ {isOwner && (
+ setSharing(true)}>
+ Share
+
+ )}
+ {!canEdit && (
+ 👁 View only
+ )}
+ {focusedObjectId == null && canEdit && }
@@ -155,20 +173,21 @@ export function GardenEditorPage() {
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
- {!focusedObject.plantable ? (
- Not plantable
- ) : armedPlant ? (
- setArmedPlant(null)}>
- Placing {armedPlant.icon} — Done
-
- ) : (
- setPicker('place')}>
- + Add plant
-
- )}
+ {canEdit &&
+ (!focusedObject.plantable ? (
+ Not plantable
+ ) : armedPlant ? (
+ setArmedPlant(null)}>
+ Placing {armedPlant.icon} — Done
+
+ ) : (
+ setPicker('place')}>
+ + Add plant
+
+ ))}
)}
-
+
{(selectedObject || selectedPlop) && (
@@ -179,6 +198,7 @@ export function GardenEditorPage() {
object={selectedObject}
gardenId={gid}
unit={garden.unitPref}
+ readOnly={!canEdit}
onFocus={() => {
setFocusedObject(selectedObject.id)
select(null)
@@ -192,6 +212,7 @@ export function GardenEditorPage() {
plant={plantsById.get(selectedPlop.plantId)}
gardenId={gid}
unit={garden.unitPref}
+ readOnly={!canEdit}
onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)}
/>
@@ -206,6 +227,8 @@ export function GardenEditorPage() {
onSelect={(p) => onPickPlant(p.id)}
/>
)}
+
+ {sharing && setSharing(false)} />}
)
}
diff --git a/web/src/pages/GardensPage.tsx b/web/src/pages/GardensPage.tsx
index 847d9ec..808280e 100644
--- a/web/src/pages/GardensPage.tsx
+++ b/web/src/pages/GardensPage.tsx
@@ -4,13 +4,23 @@ import { Button } from '@/components/ui/Button'
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
import { GardenCard } from '@/components/gardens/GardenCard'
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
+import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
+import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
+import { useMe } from '@/lib/auth'
import { useGardens, type Garden } from '@/lib/gardens'
-// Which modal is open, if any. `edit`/`delete` carry the target garden.
-type Dialog = { kind: 'create' } | { kind: 'edit'; garden: Garden } | { kind: 'delete'; garden: Garden } | null
+// Which modal is open, if any. edit/delete/share/leave carry the target garden.
+type Dialog =
+ | { kind: 'create' }
+ | { kind: 'edit'; garden: Garden }
+ | { kind: 'delete'; garden: Garden }
+ | { kind: 'share'; garden: Garden }
+ | { kind: 'leave'; garden: Garden }
+ | null
export function GardensPage() {
const gardens = useGardens()
+ const me = useMe()
const [dialog, setDialog] = useState(null)
const close = () => setDialog(null)
const hasGardens = !!gardens.data && gardens.data.length > 0
@@ -42,8 +52,11 @@ export function GardensPage() {
setDialog({ kind: 'share', garden: g })}
onEdit={() => setDialog({ kind: 'edit', garden: g })}
onDelete={() => setDialog({ kind: 'delete', garden: g })}
+ onLeave={() => setDialog({ kind: 'leave', garden: g })}
/>
))}
@@ -53,6 +66,8 @@ export function GardensPage() {
{dialog?.kind === 'create' && }
{dialog?.kind === 'edit' && }
{dialog?.kind === 'delete' && }
+ {dialog?.kind === 'share' && }
+ {dialog?.kind === 'leave' && }
)
}