Sharing UI: invite by email, roles, read-only viewer mode (#17) #36
@@ -1,33 +1,30 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { isOwnerRole, 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'
|
||||||
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. The footer differs by
|
* 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"
|
* 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).
|
* 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,
|
onShare,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onLeave,
|
onLeave,
|
||||||
}: {
|
}: {
|
||||||
garden: Garden
|
garden: Garden
|
||||||
|
currentUserId?: number
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onLeave: () => void
|
onLeave: () => void
|
||||||
}) {
|
}) {
|
||||||
const owner = isOwnerRole(garden.myRole)
|
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
|
||||||
@@ -51,18 +48,18 @@ export function GardenCard({
|
|||||||
<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">
|
||||||
{owner ? (
|
{owner ? (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={onShare} className={actionClass}>
|
<button type="button" onClick={onShare} className={cardActionClass}>
|
||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onEdit} className={actionClass}>
|
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onDelete} className={dangerClass}>
|
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={onLeave} className={dangerClass}>
|
<button type="button" onClick={onLeave} className={cardDangerClass}>
|
||||||
Leave
|
Leave
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Share garden" onClose={onClose} busy={add.isPending}>
|
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -90,7 +92,13 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
|||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={sh.role}
|
value={sh.role}
|
||||||
onChange={(e) => updateRole.mutate({ userId: sh.userId, role: e.target.value as ShareRole })}
|
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}`}
|
aria-label={`Role for ${sh.displayName}`}
|
||||||
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
||||||
>
|
>
|
||||||
@@ -99,7 +107,10 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove.mutate(sh.userId)}
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
||||||
|
}}
|
||||||
aria-label={`Remove ${sh.displayName}`}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'
|
||||||
+103
-101
@@ -66,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
|
||||||
@@ -119,111 +121,111 @@ export function Inspector({
|
|||||||
onBlur={() => name !== object.name && patch({ name })}
|
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}
|
||||||
|
// 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>
|
||||||
|
{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">
|
||||||
|
<input
|
||||||
|
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>
|
</fieldset>
|
||||||
|
|
||||||
{!readOnly &&
|
{!readOnly &&
|
||||||
|
|||||||
@@ -58,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
|
||||||
@@ -125,40 +127,40 @@ export function PlopInspector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
<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})`}
|
||||||
name="radius"
|
name="radius"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
value={radius}
|
value={radius}
|
||||||
onChange={(e) => setRadius(e.target.value)}
|
onChange={(e) => setRadius(e.target.value)}
|
||||||
onBlur={commitRadius}
|
onBlur={commitRadius}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Count"
|
label="Count"
|
||||||
name="count"
|
name="count"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
step="1"
|
step="1"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder={`${derived} (auto)`}
|
placeholder={`${derived} (auto)`}
|
||||||
value={count}
|
value={count}
|
||||||
onChange={(e) => setCount(e.target.value)}
|
onChange={(e) => setCount(e.target.value)}
|
||||||
onBlur={commitCount}
|
onBlur={commitCount}
|
||||||
hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
|
hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Label (optional)"
|
label="Label (optional)"
|
||||||
name="label"
|
name="label"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
|
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Planted"
|
label="Planted"
|
||||||
|
|||||||
@@ -31,16 +31,17 @@ export function sharesQueryOptions(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The shares on a garden (owner-only endpoint; pass enabled=false to skip). */
|
/** The shares on a garden (owner-only endpoint). */
|
||||||
export function useShares(gardenId: number, enabled = true) {
|
export function useShares(gardenId: number) {
|
||||||
return useQuery({ ...sharesQueryOptions(gardenId), enabled })
|
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) {
|
export function useAddShare(gardenId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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: async (input: { email: string; role: ShareRole }): Promise<Share> =>
|
mutationFn: (input: { email: string; role: ShareRole }) => api.post(`/gardens/${gardenId}/shares`, input),
|
||||||
shareSchema.parse(await api.post(`/gardens/${gardenId}/shares`, input)),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -48,8 +49,8 @@ export function useAddShare(gardenId: number) {
|
|||||||
export function useUpdateShareRole(gardenId: number) {
|
export function useUpdateShareRole(gardenId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ userId, role }: { userId: number; role: ShareRole }): Promise<Share> =>
|
mutationFn: ({ userId, role }: { userId: number; role: ShareRole }) =>
|
||||||
shareSchema.parse(await api.patch(`/gardens/${gardenId}/shares/${userId}`, { role })),
|
api.patch(`/gardens/${gardenId}/shares/${userId}`, { role }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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 { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { canEditRole, isOwnerRole } from '@/lib/gardens'
|
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'
|
||||||
|
|
||||||
@@ -23,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)
|
||||||
@@ -123,9 +124,11 @@ 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.
|
// Role-driven gating. Ownership is the authoritative ownerId==me check (so a
|
||||||
const canEdit = canEditRole(g.myRole)
|
// missing my_role can never lock an owner out); edit access is owner or an
|
||||||
const isOwner = isOwnerRole(g.myRole)
|
// 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
|
||||||
@@ -133,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) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
|
||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
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/share/leave carry the target garden.
|
// Which modal is open, if any. edit/delete/share/leave carry the target garden.
|
||||||
@@ -19,6 +20,7 @@ type Dialog =
|
|||||||
|
|
||||||
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
|
||||||
@@ -50,6 +52,7 @@ 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 })}
|
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 })}
|
||||||
|
|||||||
Reference in New Issue
Block a user
🟡 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