Sharing UI: invite by email, roles, read-only viewer mode (#17)
Build image / build-and-push (push) Successful in 8s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #36.
This commit is contained in:
2026-07-19 04:14:30 +00:00
committed by steve
parent 2a86f87b50
commit c2dd93a93d
12 changed files with 598 additions and 238 deletions
+36 -13
View File
@@ -10,6 +10,8 @@ import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
@@ -21,6 +23,7 @@ export function GardenEditorPage() {
const { focus } = routeApi.useSearch()
const navigate = routeApi.useNavigate()
const full = useGardenFull(gid)
const me = useMe()
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
@@ -39,6 +42,7 @@ export function GardenEditorPage() {
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
const [sharing, setSharing] = useState(false)
const serverObjects = full.data?.objects
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
@@ -120,6 +124,11 @@ export function GardenEditorPage() {
heightCm: g.heightCm,
unitPref: g.unitPref,
}
// Role-driven gating. Ownership is the authoritative ownerId==me check (so a
// missing my_role can never lock an owner out); edit access is owner or an
// editor share.
const isOwner = me.data != null && g.ownerId === me.data.id
const canEdit = isOwner || g.myRole === 'editor'
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
@@ -127,6 +136,7 @@ export function GardenEditorPage() {
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
function onPickPlant(plantId: number) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
const plant = plantsById.get(plantId)
if (!plant) return
if (picker === 'change' && selectedPlop) {
@@ -143,7 +153,15 @@ export function GardenEditorPage() {
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name}
</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 className="relative min-h-0 flex-1">
@@ -155,20 +173,21 @@ export function GardenEditorPage() {
<span className="max-w-[8rem] truncate text-muted">
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
</span>
{!focusedObject.plantable ? (
<span className="text-xs text-muted">Not plantable</span>
) : armedPlant ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Placing {armedPlant.icon} Done
</Button>
) : (
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
+ Add plant
</Button>
)}
{canEdit &&
(!focusedObject.plantable ? (
<span className="text-xs text-muted">Not plantable</span>
) : armedPlant ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Placing {armedPlant.icon} Done
</Button>
) : (
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
+ Add plant
</Button>
))}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} />
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
</div>
{(selectedObject || selectedPlop) && (
@@ -179,6 +198,7 @@ export function GardenEditorPage() {
object={selectedObject}
gardenId={gid}
unit={garden.unitPref}
readOnly={!canEdit}
onFocus={() => {
setFocusedObject(selectedObject.id)
select(null)
@@ -192,6 +212,7 @@ export function GardenEditorPage() {
plant={plantsById.get(selectedPlop.plantId)}
gardenId={gid}
unit={garden.unitPref}
readOnly={!canEdit}
onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)}
/>
@@ -206,6 +227,8 @@ export function GardenEditorPage() {
onSelect={(p) => onPickPlant(p.id)}
/>
)}
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
</div>
)
}