Sharing UI: invite by email, roles, read-only viewer mode (#17)
Build image / build-and-push (push) Successful in 8s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,23 @@ import { Button } from '@/components/ui/Button'
|
||||
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
|
||||
import { GardenCard } from '@/components/gardens/GardenCard'
|
||||
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
|
||||
import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
|
||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { useGardens, type Garden } from '@/lib/gardens'
|
||||
|
||||
// Which modal is open, if any. `edit`/`delete` carry the target garden.
|
||||
type Dialog = { kind: 'create' } | { kind: 'edit'; garden: Garden } | { kind: 'delete'; garden: Garden } | null
|
||||
// Which modal is open, if any. edit/delete/share/leave carry the target garden.
|
||||
type Dialog =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'edit'; garden: Garden }
|
||||
| { kind: 'delete'; garden: Garden }
|
||||
| { kind: 'share'; garden: Garden }
|
||||
| { kind: 'leave'; garden: Garden }
|
||||
| null
|
||||
|
||||
export function GardensPage() {
|
||||
const gardens = useGardens()
|
||||
const me = useMe()
|
||||
const [dialog, setDialog] = useState<Dialog>(null)
|
||||
const close = () => setDialog(null)
|
||||
const hasGardens = !!gardens.data && gardens.data.length > 0
|
||||
@@ -42,8 +52,11 @@ export function GardensPage() {
|
||||
<GardenCard
|
||||
key={g.id}
|
||||
garden={g}
|
||||
currentUserId={me.data?.id}
|
||||
onShare={() => setDialog({ kind: 'share', garden: g })}
|
||||
onEdit={() => setDialog({ kind: 'edit', garden: g })}
|
||||
onDelete={() => setDialog({ kind: 'delete', garden: g })}
|
||||
onLeave={() => setDialog({ kind: 'leave', garden: g })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -53,6 +66,8 @@ export function GardensPage() {
|
||||
{dialog?.kind === 'create' && <GardenFormModal onClose={close} />}
|
||||
{dialog?.kind === 'edit' && <GardenFormModal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user