Sharing UI: invite by email, roles, read-only viewer mode (#17) #36
@@ -1,20 +1,30 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import type { Garden } from '@/lib/gardens'
|
import type { Garden } from '@/lib/gardens'
|
||||||
import { formatDimensions } from '@/lib/units'
|
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
|
* 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).
|
||||||
|
* Ownership is the authoritative ownerId==me check, not the my_role hint.
|
||||||
*/
|
*/
|
||||||
export function GardenCard({
|
export function GardenCard({
|
||||||
garden,
|
garden,
|
||||||
|
currentUserId,
|
||||||
|
onShare,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onLeave,
|
||||||
}: {
|
}: {
|
||||||
garden: Garden
|
garden: Garden
|
||||||
|
currentUserId?: number
|
||||||
|
onShare: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onLeave: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||||
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 +32,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>
|
||||||
|
gitea-actions
commented
⚪ Badge prints raw lowercase enum myRole; casing/wording inconsistent with role labels in ShareGardenModal maintainability · flagged by 1 model 🪰 Gadfly · advisory ⚪ **Badge prints raw lowercase enum myRole; casing/wording inconsistent with role labels in ShareGardenModal**
_maintainability · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
<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={cardActionClass}>
|
||||||
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={cardActionClass}>
|
||||||
</button>
|
Edit
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||||
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={cardDangerClass}>
|
||||||
|
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}>
|
||||||
|
gitea-actions
commented
🟡 Non-401 useMe failure leaves Leave button disabled with no user-facing message error-handling · flagged by 1 model
🪰 Gadfly · advisory 🟡 **Non-401 useMe failure leaves Leave button disabled with no user-facing message**
_error-handling · flagged by 1 model_
- **`web/src/components/gardens/LeaveGardenModal.tsx:40` — a non-401 `useMe` failure leaves the user stuck with a disabled button and no explanation.** Verified in `web/src/lib/auth.ts:29-44`: `meQueryOptions.queryFn` returns `null` only on `ApiError.isUnauthorized` (401); any other error is rethrown, leaving `me.data` undefined. `LeaveGardenModal.tsx:40` sets `disabled={remove.isPending || !me.data}` with no error/loading state for `me`, so a non-401 `useMe` failure keeps "Leave" permanently di…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
{remove.isPending ? 'Leaving…' : 'Leave'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<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.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.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>
|
||||||
|
gitea-actions
commented
🟡 Empty ul rendered during loading and error states maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **Empty ul rendered during loading and error states**
_maintainability · flagged by 1 model_
- `web/src/components/gardens/ShareGardenModal.tsx:84` — The `<ul className="flex flex-col gap-2">` is rendered unconditionally, so it sits empty in the DOM during loading and error states alongside the "Loading…" / error messages. Move it inside the `shares.isSuccess && shares.data.length > 0` branch.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
)}
|
||||||
|
<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>
|
||||||
|
gitea-actions
commented
🔴 updateRole.mutate / remove.mutate silently swallow errors — no onError, no user feedback on failed role change or revoke correctness, error-handling, maintainability · flagged by 5 models
🪰 Gadfly · advisory 🔴 **updateRole.mutate / remove.mutate silently swallow errors — no onError, no user feedback on failed role change or revoke**
_correctness, error-handling, maintainability · flagged by 5 models_
- `web/src/components/gardens/ShareGardenModal.tsx:91` — Uses a raw `<select>` element (with manually imported `fieldControlClass` and `cn`) for the inline role changer, while the invite form 20 lines above uses the project's `<Select>` component. This creates two styling paths for the same control and duplicates the `viewer`/`editor` options already defined in the module-level `roleOptions` array. Prefer `<Select>` (or map `roleOptions`) to keep the UI consistent and DRY.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={sh.role}
|
||||||
|
onChange={(e) => {
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { PlantIcon } from './PlantIcon'
|
import { PlantIcon } from './PlantIcon'
|
||||||
|
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
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,
|
* 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
|
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
|
||||||
@@ -53,16 +47,16 @@ export function PlantCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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 type="button" onClick={onDuplicate} className={actionClass}>
|
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
{!builtin && (
|
{!builtin && (
|
||||||
<button type="button" onClick={onEdit} className={actionClass}>
|
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!builtin && (
|
{!builtin && (
|
||||||
<button type="button" onClick={onDelete} className={dangerClass}>
|
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -64,8 +66,10 @@ export function Inspector({
|
|||||||
setColor(object.color ?? DEFAULT_COLOR)
|
setColor(object.color ?? DEFAULT_COLOR)
|
||||||
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit])
|
}, [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 })
|
update.mutate({ id: object.id, version: object.version, ...fields })
|
||||||
|
}
|
||||||
|
|
||||||
// Commit a dimension/position field. `positive` gates width/height (must be
|
// 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
|
// ≥ the server minimum) but not x/y, which may be zero or negative. Compare at
|
||||||
@@ -96,148 +100,157 @@ 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
|
||||||
|
gitea-actions
commented
🟠 Inconsistent indentation inside fieldset wrapper makes JSX hierarchy unreadable maintainability · flagged by 5 models
🪰 Gadfly · advisory 🟠 **Inconsistent indentation inside fieldset wrapper makes JSX hierarchy unreadable**
_maintainability · flagged by 5 models_
- `web/src/editor/Inspector.tsx:113` — The `<fieldset>` wrapper added for read-only mode contains children at wildly inconsistent indentation (e.g. the grid div at line 122 sits at the same level as the fieldset tag, while the Name field at line 114 and Notes field at line 219 are indented +2). This makes the JSX hierarchy unreadable and invites future edits that break the intended nesting. Re-indent everything inside the fieldset uniformly.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
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}
|
||||||
|
gitea-actions
commented
🟠 onBlur handlers in disabled fieldset can fire mutations after role downgrade error-handling · flagged by 1 model
🪰 Gadfly · advisory 🟠 **onBlur handlers in disabled fieldset can fire mutations after role downgrade**
_error-handling · flagged by 1 model_
* **`web/src/editor/Inspector.tsx:119`** and **`web/src/editor/PlopInspector.tsx:138`** — Every `onBlur` handler inside the `<fieldset disabled={readOnly}>` calls `patch(...)` without checking `readOnly`. If the user’s role is downgraded mid-edit (owner changes them to viewer, or a concurrent refetch reveals a new `myRole`), React re-renders with `readOnly=true`, the fieldset disables, focused inputs blur, and the blur handlers fire unauthorized mutations that will 403. **Fix:** Guard the `patch…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
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
|
||||||
label={`Width (${u})`}
|
label={`Width (${u})`}
|
||||||
name="width"
|
name="width"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
value={width}
|
value={width}
|
||||||
onChange={(e) => setWidth(e.target.value)}
|
onChange={(e) => setWidth(e.target.value)}
|
||||||
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
|
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={`Height (${u})`}
|
label={`Height (${u})`}
|
||||||
name="height"
|
name="height"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
value={height}
|
value={height}
|
||||||
onChange={(e) => setHeight(e.target.value)}
|
onChange={(e) => setHeight(e.target.value)}
|
||||||
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
|
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={`X (${u})`}
|
label={`X (${u})`}
|
||||||
name="x"
|
name="x"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
value={x}
|
value={x}
|
||||||
onChange={(e) => setX(e.target.value)}
|
onChange={(e) => setX(e.target.value)}
|
||||||
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={`Y (${u})`}
|
label={`Y (${u})`}
|
||||||
name="y"
|
name="y"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
value={y}
|
value={y}
|
||||||
onChange={(e) => setY(e.target.value)}
|
onChange={(e) => setY(e.target.value)}
|
||||||
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
|
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>
|
</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">
|
<TextField
|
||||||
<input
|
label="Rotation (°)"
|
||||||
type="checkbox"
|
name="rotation"
|
||||||
checked={object.plantable}
|
type="number"
|
||||||
onChange={(e) => patch({ plantable: e.target.checked })}
|
inputMode="numeric"
|
||||||
className="h-4 w-4 rounded border-border"
|
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
|
<div className="flex items-end gap-2">
|
||||||
label="Notes"
|
<div className="flex flex-col gap-1.5">
|
||||||
name="notes"
|
<label htmlFor="obj-color" className="text-sm font-medium text-fg">
|
||||||
rows={2}
|
Color
|
||||||
value={notes}
|
</label>
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
<input
|
||||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
id="obj-color"
|
||||||
/>
|
type="color"
|
||||||
|
value={color}
|
||||||
{confirmingDelete ? (
|
// onChange fires continuously while dragging in the native picker;
|
||||||
<div className="flex items-center gap-2">
|
// track it locally for the live swatch and commit one PATCH on blur.
|
||||||
<Button
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setColor(e.target.value)}
|
||||||
variant="danger"
|
onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
|
||||||
className="flex-1"
|
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
|
||||||
disabled={del.isPending}
|
/>
|
||||||
onClick={() => {
|
</div>
|
||||||
select(null)
|
{object.color && (
|
||||||
del.mutate(object.id)
|
<Button
|
||||||
}}
|
variant="ghost"
|
||||||
>
|
className="px-2 py-1.5 text-xs"
|
||||||
Confirm delete
|
onClick={() => {
|
||||||
</Button>
|
setColor(DEFAULT_COLOR)
|
||||||
<Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
|
patch({ color: null })
|
||||||
Cancel
|
}}
|
||||||
</Button>
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Button variant="ghost" className="text-red-600 dark:text-red-400" onClick={() => setConfirmingDelete(true)}>
|
<label className="flex items-center gap-2 text-sm text-fg">
|
||||||
Delete object
|
<input
|
||||||
</Button>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -56,8 +58,10 @@ export function PlopInspector({
|
|||||||
setPlanted(p.plantedAt ?? '')
|
setPlanted(p.plantedAt ?? '')
|
||||||
}, [p.version, p.radiusCm, p.count, p.label, p.plantedAt, unit])
|
}, [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 })
|
update.mutate({ id: plop.id, version: plop.version, ...fields })
|
||||||
|
}
|
||||||
|
|
||||||
const u = spacingUnitLabel(unit)
|
const u = spacingUnitLabel(unit)
|
||||||
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
||||||
@@ -104,6 +108,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,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="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}>
|
||||||
|
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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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<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). */
|
||||||
|
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({
|
||||||
|
gitea-actions
commented
🔴 useAddShare parses the POST /shares response with a schema requiring email/displayName, but the backend's AddShare returns a bare GardenShare without them — every successful invite throws and shows a false 'Could not share the garden' error correctness · flagged by 1 model 🪰 Gadfly · advisory 🔴 **useAddShare parses the POST /shares response with a schema requiring email/displayName, but the backend's AddShare returns a bare GardenShare without them — every successful invite throws and shows a false 'Could not share the garden' error**
_correctness · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
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) })
|
||||||
|
gitea-actions
commented
🟡 useRemoveShare invalidates ['gardens'] on every remove, causing avoidable gardens-list refetches in the owner-revoke path performance · flagged by 1 model
🪰 Gadfly · advisory 🟡 **useRemoveShare invalidates ['gardens'] on every remove, causing avoidable gardens-list refetches in the owner-revoke path**
_performance · flagged by 1 model_
- **`web/src/lib/shares.ts:66`** — `useRemoveShare.onSuccess` unconditionally invalidates `['gardens']` on every remove. This is correct and necessary for the *leave* path (the garden leaves the recipient's list), but it also fires for the *owner revoke* path in `ShareGardenModal`. When the owner manages shares from `GardensPage` (where the gardens query is active), each ✕ click triggers a full gardens-list refetch in addition to the shares refetch — so removing N shares means N extra gardens-li…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
qc.invalidateQueries({ queryKey: ['gardens'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 { useMe } from '@/lib/auth'
|
||||||
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
|
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
|
||||||
import { toEditorPlanting } from '@/lib/plantings'
|
import { toEditorPlanting } from '@/lib/plantings'
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ export function GardenEditorPage() {
|
|||||||
const { focus } = routeApi.useSearch()
|
const { focus } = routeApi.useSearch()
|
||||||
const navigate = routeApi.useNavigate()
|
const navigate = routeApi.useNavigate()
|
||||||
const full = useGardenFull(gid)
|
const full = useGardenFull(gid)
|
||||||
|
const me = useMe()
|
||||||
|
|
||||||
const selectedId = useEditorStore((s) => s.selectedId)
|
const selectedId = useEditorStore((s) => s.selectedId)
|
||||||
const select = useEditorStore((s) => s.select)
|
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;
|
// 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 +124,11 @@ export function GardenEditorPage() {
|
|||||||
heightCm: g.heightCm,
|
heightCm: g.heightCm,
|
||||||
unitPref: g.unitPref,
|
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 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
|
||||||
@@ -127,6 +136,7 @@ export function GardenEditorPage() {
|
|||||||
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
||||||
|
|
||||||
function onPickPlant(plantId: number) {
|
function onPickPlant(plantId: number) {
|
||||||
|
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||||
const plant = plantsById.get(plantId)
|
const plant = plantsById.get(plantId)
|
||||||
if (!plant) return
|
if (!plant) return
|
||||||
if (picker === 'change' && selectedPlop) {
|
if (picker === 'change' && selectedPlop) {
|
||||||
@@ -143,7 +153,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 +173,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 +198,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 +212,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 +227,8 @@ export function GardenEditorPage() {
|
|||||||
onSelect={(p) => onPickPlant(p.id)}
|
onSelect={(p) => onPickPlant(p.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ 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 { useMe } from '@/lib/auth'
|
||||||
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()
|
||||||
|
const me = useMe()
|
||||||
const [dialog, setDialog] = useState<Dialog>(null)
|
const [dialog, setDialog] = useState<Dialog>(null)
|
||||||
const close = () => setDialog(null)
|
const close = () => setDialog(null)
|
||||||
const hasGardens = !!gardens.data && gardens.data.length > 0
|
const hasGardens = !!gardens.data && gardens.data.length > 0
|
||||||
@@ -42,8 +52,11 @@ export function GardensPage() {
|
|||||||
<GardenCard
|
<GardenCard
|
||||||
key={g.id}
|
key={g.id}
|
||||||
garden={g}
|
garden={g}
|
||||||
|
currentUserId={me.data?.id}
|
||||||
|
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 +66,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
🟡 actionClass/dangerClass duplicated verbatim from PlantCard — should be shared
maintainability · flagged by 3 models
actionClass/dangerClassconstants —web/src/components/gardens/GardenCard.tsx:5-10defines the exact same two class strings asweb/src/components/plants/PlantCard.tsx:5-10(verified identical character-for-character). This is copy-paste that should be shared — e.g. extract toweb/src/components/ui/button.tsor acardActionshelper. As more card components appear, this drift will diverge.🪰 Gadfly · advisory