Add sharing UI: invite by email, roles, read-only viewer mode (#17)
Build image / build-and-push (push) Successful in 14s
Gadfly review (reusable) / review (pull_request) Successful in 7m21s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m21s

- lib/shares.ts: zod Share shape + hooks (useShares/useAddShare/
  useUpdateShareRole/useRemoveShare over /gardens/:id/shares).
- lib/gardens.ts: myRole on the garden schema + canEditRole/isOwnerRole helpers
  (role always comes from the server's my_role, never guessed).
- ShareGardenModal (owner only): invite an existing user by email as viewer/
  editor, change a share's role inline, remove; surfaces the backend's friendly
  "no account with that email". Reachable from the garden card and the editor.
- GardenCard: "shared · viewer/editor" badge on non-owned gardens; owner gets
  Share/Edit/Delete, a recipient gets Leave (LeaveGardenModal removes their own
  share).
- Read-only viewer mode in the editor, gated on a single canEdit flag:
  viewers get no palette, no select/move/resize/rotate handles, no plop overlay,
  no placement, and read-only inspectors (a disabled <fieldset> makes every field
  read-only in one shot; Change/Delete/Remove/Plant-here hidden), plus a "View
  only" indicator. Defense in depth: the canvas create/place handlers also check
  canEdit, so a missed gate can't fire a mutation that would 403.

tsc --noEmit clean; 24/24 vitest; production build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 23:57:59 -04:00
co-authored by Claude Opus 4.8
parent 2a86f87b50
commit be5ed18db0
10 changed files with 433 additions and 97 deletions
+41 -18
View File
@@ -1,20 +1,33 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { Garden } from '@/lib/gardens' import { isOwnerRole, type Garden } from '@/lib/gardens'
import { formatDimensions } from '@/lib/units' 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 * One garden as a card: the body links into the editor. The footer differs by
* footer has edit/delete actions (kept out of the link so they don't navigate). * 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({ export function GardenCard({
garden, garden,
onShare,
onEdit, onEdit,
onDelete, onDelete,
onLeave,
}: { }: {
garden: Garden garden: Garden
onShare: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onLeave: () => void
}) { }) {
const owner = isOwnerRole(garden.myRole)
return ( return (
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50"> <div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
<Link <Link
@@ -22,27 +35,37 @@ export function GardenCard({
params={{ gardenId: String(garden.id) }} params={{ gardenId: String(garden.id) }}
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
<h3 className="truncate font-semibold text-fg">{garden.name}</h3> <div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
{!owner && garden.myRole && (
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
shared · {garden.myRole}
</span>
)}
</div>
<p className="mt-1 text-sm text-muted"> <p className="mt-1 text-sm text-muted">
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)} {formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
</p> </p>
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>} {garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
</Link> </Link>
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5"> <div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
<button {owner ? (
type="button" <>
onClick={onEdit} <button type="button" onClick={onShare} className={actionClass}>
className="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" Share
> </button>
Edit <button type="button" onClick={onEdit} className={actionClass}>
</button> Edit
<button </button>
type="button" <button type="button" onClick={onDelete} className={dangerClass}>
onClick={onDelete} Delete
className="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" </button>
> </>
Delete ) : (
</button> <button type="button" onClick={onLeave} className={dangerClass}>
Leave
</button>
)}
</div> </div>
</div> </div>
) )
@@ -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<string | null>(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 (
<Modal title="Leave garden" onClose={onClose} busy={remove.isPending}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
shares it with you again.
</p>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={remove.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={remove.isPending || !me.data}>
{remove.isPending ? 'Leaving' : 'Leave'}
</Button>
</div>
</div>
</Modal>
)
}
@@ -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<ShareRole>('viewer')
const [error, setError] = useState<string | null>(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 (
<Modal title="Share garden" onClose={onClose} busy={add.isPending}>
<div className="flex flex-col gap-4">
<form onSubmit={onInvite} className="flex flex-col gap-2">
<TextField
label="Invite by email"
name="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className="flex items-end gap-2">
<Select
label="Role"
name="role"
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
options={roleOptions}
className="flex-1"
/>
<Button type="submit" disabled={add.isPending}>
{add.isPending ? 'Sharing…' : 'Share'}
</Button>
</div>
{error && <Alert>{error}</Alert>}
</form>
<div>
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
{shares.isPending && <p className="text-sm text-muted">Loading</p>}
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
{shares.isSuccess && shares.data.length === 0 && (
<p className="text-sm text-muted">Not shared with anyone yet.</p>
)}
<ul className="flex flex-col gap-2">
{shares.data?.map((sh) => (
<li key={sh.userId} className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
<p className="truncate text-xs text-muted">{sh.email}</p>
</div>
<select
value={sh.role}
onChange={(e) => updateRole.mutate({ userId: sh.userId, role: e.target.value as ShareRole })}
aria-label={`Role for ${sh.displayName}`}
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<button
type="button"
onClick={() => remove.mutate(sh.userId)}
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"
>
</button>
</li>
))}
</ul>
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>
Done
</Button>
</div>
</div>
</Modal>
)
}
+11 -5
View File
@@ -34,11 +34,13 @@ export function GardenCanvas({
objects, objects,
plantings, plantings,
plantsById, plantsById,
canEdit,
}: { }: {
garden: EditorGarden garden: EditorGarden
objects: EditorObject[] objects: EditorObject[]
plantings: EditorPlanting[] plantings: EditorPlanting[]
plantsById: Map<number, Plant> plantsById: Map<number, Plant>
canEdit: boolean
}) { }) {
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -108,7 +110,9 @@ export function GardenCanvas({
// or exit focus mode, or just deselect. // or exit focus mode, or just deselect.
function onCanvasPointerDown(e: ReactPointerEvent) { function onCanvasPointerDown(e: ReactPointerEvent) {
const armed = useEditorStore.getState().armedKind 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) useEditorStore.getState().setArmedKind(null)
const def = kindDef(armed) const def = kindDef(armed)
const rect = svgRef.current?.getBoundingClientRect() const rect = svgRef.current?.getBoundingClientRect()
@@ -134,7 +138,7 @@ export function GardenCanvas({
// armed for repeat-placement until Escape / Done). // armed for repeat-placement until Escape / Done).
function onPlace(e: ReactPointerEvent) { function onPlace(e: ReactPointerEvent) {
e.stopPropagation() e.stopPropagation()
if (!focusedObject || !armedPlant || !focusedObject.plantable) return if (!canEdit || !focusedObject || !armedPlant || !focusedObject.plantable) return
const rect = svgRef.current?.getBoundingClientRect() const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return if (!rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
@@ -188,14 +192,16 @@ export function GardenCanvas({
onSelectPlop={selectPlanting} onSelectPlop={selectPlanting}
/> />
{selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />} {/* Edit handles are mounted only for editors/owners; viewers can still
{selectedPlop && selectedPlopObject && ( 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} /> <PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} />
)} )}
{/* Placement capture: a transparent sheet over the focused object while a {/* Placement capture: a transparent sheet over the focused object while a
plant is armed, so taps drop plops instead of selecting the object. */} 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)}> <g transform={objectTransform(focusedObject)}>
<rect <rect
x={-halfFW} x={-halfFW}
+48 -37
View File
@@ -27,11 +27,13 @@ export function Inspector({
gardenId, gardenId,
unit, unit,
onFocus, onFocus,
readOnly = false,
}: { }: {
object: EditorObject object: EditorObject
gardenId: number gardenId: number
unit: UnitPref unit: UnitPref
onFocus?: () => void onFocus?: () => void
readOnly?: boolean
}) { }) {
const update = useUpdateObject(gardenId) const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId) const del = useDeleteObject(gardenId)
@@ -96,19 +98,26 @@ export function Inspector({
</button> </button>
</div> </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"> <Button onClick={onFocus} className="w-full">
🌱 Plant here 🌱 Plant here
</Button> </Button>
)} )}
<TextField {/* A disabled fieldset makes every control below read-only for viewers in
label="Name" one shot (no per-input disabled). */}
name="name" <fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
value={name} <TextField
onChange={(e) => setName(e.target.value)} label="Name"
onBlur={() => name !== object.name && patch({ 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"> <div className="grid grid-cols-2 gap-2">
<TextField <TextField
@@ -207,37 +216,39 @@ export function Inspector({
Plantable Plantable
</label> </label>
<TextArea <TextArea
label="Notes" label="Notes"
name="notes" name="notes"
rows={2} rows={2}
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })} onBlur={() => notes !== object.notes && patch({ notes })}
/> />
</fieldset>
{confirmingDelete ? ( {!readOnly &&
<div className="flex items-center gap-2"> (confirmingDelete ? (
<Button <div className="flex items-center gap-2">
variant="danger" <Button
className="flex-1" variant="danger"
disabled={del.isPending} className="flex-1"
onClick={() => { disabled={del.isPending}
select(null) onClick={() => {
del.mutate(object.id) select(null)
}} del.mutate(object.id)
> }}
Confirm delete >
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> </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> </div>
) )
} }
+34 -22
View File
@@ -25,6 +25,7 @@ export function PlopInspector({
unit, unit,
onChangePlant, onChangePlant,
onClose, onClose,
readOnly = false,
}: { }: {
plop: EditorPlanting plop: EditorPlanting
plant?: Plant plant?: Plant
@@ -32,6 +33,7 @@ export function PlopInspector({
unit: UnitPref unit: UnitPref
onChangePlant: () => void onChangePlant: () => void
onClose: () => void onClose: () => void
readOnly?: boolean
}) { }) {
const update = useUpdatePlanting(gardenId) const update = useUpdatePlanting(gardenId)
const remove = useRemovePlanting(gardenId) const remove = useRemovePlanting(gardenId)
@@ -104,6 +106,10 @@ export function PlopInspector({
</button> </button>
</div> </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"> <div className="flex items-center gap-2 rounded-lg border border-border p-2">
{plant ? ( {plant ? (
<PlantIcon color={plant.color} icon={plant.icon} className="h-9 w-9 rounded-md text-xl" /> <PlantIcon color={plant.color} icon={plant.icon} className="h-9 w-9 rounded-md text-xl" />
@@ -111,11 +117,14 @@ export function PlopInspector({
<span className="grid h-9 w-9 place-items-center rounded-md bg-border/50 text-muted">?</span> <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> <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}> {!readOnly && (
Change <Button variant="ghost" className="px-2 py-1 text-xs" onClick={onChangePlant}>
</Button> Change
</Button>
)}
</div> </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"> <div className="grid grid-cols-2 gap-2">
<TextField <TextField
label={`Radius (${u})`} label={`Radius (${u})`}
@@ -151,26 +160,29 @@ export function PlopInspector({
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })} onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
/> />
<TextField <TextField
label="Planted" label="Planted"
name="planted" name="planted"
type="date" type="date"
value={planted} value={planted}
onChange={(e) => setPlanted(e.target.value)} onChange={(e) => setPlanted(e.target.value)}
onBlur={commitPlanted} onBlur={commitPlanted}
/> />
</fieldset>
<Button {!readOnly && (
variant="ghost" <Button
className="text-red-600 dark:text-red-400" variant="ghost"
disabled={remove.isPending} className="text-red-600 dark:text-red-400"
onClick={() => { disabled={remove.isPending}
onClose() onClick={() => {
remove.mutate({ id: plop.id, version: plop.version }) onClose()
}} remove.mutate({ id: plop.id, version: plop.version })
> }}
Remove plant >
</Button> Remove plant
</Button>
)}
</div> </div>
) )
} }
+16
View File
@@ -8,6 +8,9 @@ import type { UnitPref } from './units'
const unitPrefSchema = z.enum(['metric', 'imperial']) const unitPrefSchema = z.enum(['metric', 'imperial'])
export const gardenRoleSchema = z.enum(['owner', 'editor', 'viewer'])
export type GardenRole = z.infer<typeof gardenRoleSchema>
export const gardenSchema = z.object({ export const gardenSchema = z.object({
id: z.number(), id: z.number(),
ownerId: z.number(), ownerId: z.number(),
@@ -19,9 +22,22 @@ export const gardenSchema = z.object({
version: z.number(), version: z.number(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: 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<typeof gardenSchema> export type Garden = z.infer<typeof gardenSchema>
/** 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 const gardensKey = ['gardens'] as const
export const gardensQueryOptions = queryOptions({ export const gardensQueryOptions = queryOptions({
+69
View File
@@ -0,0 +1,69 @@
// 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<typeof shareRoleSchema>
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<typeof shareSchema>
const sharesKey = (gardenId: number) => ['shares', gardenId] as const
export function sharesQueryOptions(gardenId: number) {
return queryOptions({
queryKey: sharesKey(gardenId),
queryFn: async (): Promise<Share[]> => z.array(shareSchema).parse(await api.get(`/gardens/${gardenId}/shares`)),
})
}
/** The shares on a garden (owner-only endpoint; pass enabled=false to skip). */
export function useShares(gardenId: number, enabled = true) {
return useQuery({ ...sharesQueryOptions(gardenId), enabled })
}
export function useAddShare(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: { email: string; role: ShareRole }): Promise<Share> =>
shareSchema.parse(await api.post(`/gardens/${gardenId}/shares`, input)),
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
})
}
export function useUpdateShareRole(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ userId, role }: { userId: number; role: ShareRole }): Promise<Share> =>
shareSchema.parse(await 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'] })
},
})
}
+32 -13
View File
@@ -10,6 +10,8 @@ import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds' import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store' import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types' import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { canEditRole, isOwnerRole } from '@/lib/gardens'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects' import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings' import { toEditorPlanting } from '@/lib/plantings'
@@ -39,6 +41,7 @@ export function GardenEditorPage() {
// Which plant-picker flow is open: 'place' arms a plant for repeat placement; // Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant. // 'change' swaps the selected plop's plant.
const [picker, setPicker] = useState<'place' | 'change' | null>(null) const [picker, setPicker] = useState<'place' | 'change' | null>(null)
const [sharing, setSharing] = useState(false)
const serverObjects = full.data?.objects const serverObjects = full.data?.objects
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects]) const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
@@ -120,6 +123,9 @@ export function GardenEditorPage() {
heightCm: g.heightCm, heightCm: g.heightCm,
unitPref: g.unitPref, unitPref: g.unitPref,
} }
// Role-driven gating from the server's my_role — never guessed client-side.
const canEdit = canEditRole(g.myRole)
const isOwner = isOwnerRole(g.myRole)
const selectedObject = objects.find((o) => o.id === selectedId) ?? null const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
@@ -143,7 +149,15 @@ export function GardenEditorPage() {
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}> <h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name} {garden.name}
</h1> </h1>
{focusedObjectId == null && <Palette />} {isOwner && (
<Button variant="ghost" className="mb-2 w-full text-sm" onClick={() => setSharing(true)}>
Share
</Button>
)}
{!canEdit && (
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
)}
{focusedObjectId == null && canEdit && <Palette />}
</div> </div>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
@@ -155,20 +169,21 @@ export function GardenEditorPage() {
<span className="max-w-[8rem] truncate text-muted"> <span className="max-w-[8rem] truncate text-muted">
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'} {focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
</span> </span>
{!focusedObject.plantable ? ( {canEdit &&
<span className="text-xs text-muted">Not plantable</span> (!focusedObject.plantable ? (
) : armedPlant ? ( <span className="text-xs text-muted">Not plantable</span>
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}> ) : armedPlant ? (
Placing {armedPlant.icon} Done <Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
</Button> Placing {armedPlant.icon} Done
) : ( </Button>
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}> ) : (
+ Add plant <Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
</Button> + Add plant
)} </Button>
))}
</div> </div>
)} )}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} /> <GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
</div> </div>
{(selectedObject || selectedPlop) && ( {(selectedObject || selectedPlop) && (
@@ -179,6 +194,7 @@ export function GardenEditorPage() {
object={selectedObject} object={selectedObject}
gardenId={gid} gardenId={gid}
unit={garden.unitPref} unit={garden.unitPref}
readOnly={!canEdit}
onFocus={() => { onFocus={() => {
setFocusedObject(selectedObject.id) setFocusedObject(selectedObject.id)
select(null) select(null)
@@ -192,6 +208,7 @@ export function GardenEditorPage() {
plant={plantsById.get(selectedPlop.plantId)} plant={plantsById.get(selectedPlop.plantId)}
gardenId={gid} gardenId={gid}
unit={garden.unitPref} unit={garden.unitPref}
readOnly={!canEdit}
onChangePlant={() => setPicker('change')} onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)} onClose={() => selectPlanting(null)}
/> />
@@ -206,6 +223,8 @@ export function GardenEditorPage() {
onSelect={(p) => onPickPlant(p.id)} onSelect={(p) => onPickPlant(p.id)}
/> />
)} )}
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
</div> </div>
) )
} }
+14 -2
View File
@@ -4,10 +4,18 @@ import { Button } from '@/components/ui/Button'
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal' import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
import { GardenCard } from '@/components/gardens/GardenCard' import { GardenCard } from '@/components/gardens/GardenCard'
import { GardenFormModal } from '@/components/gardens/GardenFormModal' import { GardenFormModal } from '@/components/gardens/GardenFormModal'
import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useGardens, type Garden } from '@/lib/gardens' import { useGardens, type Garden } from '@/lib/gardens'
// Which modal is open, if any. `edit`/`delete` carry the target garden. // 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 } | null type Dialog =
| { kind: 'create' }
| { kind: 'edit'; garden: Garden }
| { kind: 'delete'; garden: Garden }
| { kind: 'share'; garden: Garden }
| { kind: 'leave'; garden: Garden }
| null
export function GardensPage() { export function GardensPage() {
const gardens = useGardens() const gardens = useGardens()
@@ -42,8 +50,10 @@ export function GardensPage() {
<GardenCard <GardenCard
key={g.id} key={g.id}
garden={g} garden={g}
onShare={() => setDialog({ kind: 'share', garden: g })}
onEdit={() => setDialog({ kind: 'edit', garden: g })} onEdit={() => setDialog({ kind: 'edit', garden: g })}
onDelete={() => setDialog({ kind: 'delete', garden: g })} onDelete={() => setDialog({ kind: 'delete', garden: g })}
onLeave={() => setDialog({ kind: 'leave', garden: g })}
/> />
))} ))}
</div> </div>
@@ -53,6 +63,8 @@ export function GardensPage() {
{dialog?.kind === 'create' && <GardenFormModal onClose={close} />} {dialog?.kind === 'create' && <GardenFormModal onClose={close} />}
{dialog?.kind === 'edit' && <GardenFormModal garden={dialog.garden} onClose={close} />} {dialog?.kind === 'edit' && <GardenFormModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'delete' && <DeleteGardenModal garden={dialog.garden} onClose={close} />} {dialog?.kind === 'delete' && <DeleteGardenModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'share' && <ShareGardenModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'leave' && <LeaveGardenModal garden={dialog.garden} onClose={close} />}
</section> </section>
) )
} }