import { useEffect, useMemo, useRef, useState } from 'react' import { getRouteApi } from '@tanstack/react-router' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' import { GardenCanvas } from '@/editor/GardenCanvas' import { Inspector } from '@/editor/Inspector' import { PlopInspector } from '@/editor/PlopInspector' import { PlantPicker } from '@/editor/PlantPicker' import { Palette } from '@/editor/Palette' import { SeedTray } from '@/editor/SeedTray' import { ClearBedModal } from '@/editor/ClearBedModal' import { EditorHint } from '@/editor/EditorHint' import { objectDisplayName } 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, useEnsurePlantInFull, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import { toEditorPlanting } from '@/lib/plantings' import type { Plant } from '@/lib/plants' import { useSeedTray } from '@/lib/seedTray' import { usePageTitle } from '@/lib/usePageTitle' const routeApi = getRouteApi('/gardens/$gardenId') export function GardenEditorPage() { const { gardenId } = routeApi.useParams() const gid = Number(gardenId) const { focus } = routeApi.useSearch() const navigate = routeApi.useNavigate() const full = useGardenFull(gid) const me = useMe() usePageTitle(full.data?.garden.name ?? 'Garden') const selectedId = useEditorStore((s) => s.selectedId) const select = useEditorStore((s) => s.select) const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId) const selectPlanting = useEditorStore((s) => s.selectPlanting) const focusedObjectId = useEditorStore((s) => s.focusedObjectId) const setFocusedObject = useEditorStore((s) => s.setFocusedObject) const armedPlant = useEditorStore((s) => s.armedPlant) const setArmedPlant = useEditorStore((s) => s.setArmedPlant) const setArmedKind = useEditorStore((s) => s.setArmedKind) const setLiveObject = useEditorStore((s) => s.setLiveObject) const setLivePlanting = useEditorStore((s) => s.setLivePlanting) const updatePlanting = useUpdatePlanting(gid) const updateObject = useUpdateObject(gid) const ensurePlant = useEnsurePlantInFull(gid) const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid) // 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 [clearing, setClearing] = useState(false) const nudgeTimer = useRef(null) const nudgeFire = useRef<(() => void) | null>(null) const serverObjects = full.data?.objects const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects]) const serverPlantings = full.data?.plantings const plantings = useMemo(() => serverPlantings?.map(toEditorPlanting) ?? [], [serverPlantings]) const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants]) const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants]) // Role gating, computed before the effects/returns so the nudge handler can use // it. Ownership is the authoritative ownerId==me check. const gd = full.data?.garden const canEdit = gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor') const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id // Latest values for the mount-once nudge keydown handler to read, so its effect // never re-subscribes (which would cancel a pending debounced commit). const nudgeCtx = useRef({ canEdit, objects, plantings, updateObject, updatePlanting }) nudgeCtx.current = { canEdit, objects, plantings, updateObject, updatePlanting } // Adopt the URL's focus and clear transient editor state on entering/switching // gardens (and on leaving). `focus` is intentionally read once here, not a dep, // so URL syncs below don't retrigger a full reset. useEffect(() => { const clear = () => { select(null) selectPlanting(null) setArmedKind(null) setArmedPlant(null) setLiveObject(null) setLivePlanting(null) } clear() setFocusedObject(focus ?? null) return () => { clear() setFocusedObject(null) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [gid]) // Mirror focusedObjectId → ?focus so focus mode is deep-linkable and survives // reload. Declared after the reset effect so the initial adopt wins. useEffect(() => { navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true }) }, [focusedObjectId, navigate]) // If the focused object vanished — deleted, or a stale ?focus id on load — leave // focus mode so the canvas isn't stuck dimmed with no way out. useEffect(() => { if (focusedObjectId != null && full.data && !objects.some((o) => o.id === focusedObjectId)) { setFocusedObject(null) } }, [focusedObjectId, objects, full.data, setFocusedObject]) const exitFocus = () => { setFocusedObject(null) setArmedPlant(null) select(null) selectPlanting(null) } // Escape peels back one layer: stop placing → deselect plop → exit focus → deselect. useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key !== 'Escape') return const s = useEditorStore.getState() if (s.armedPlant) s.setArmedPlant(null) else if (s.selectedPlantingId != null) s.selectPlanting(null) else if (s.focusedObjectId != null) exitFocus() else if (s.selectedId != null) s.select(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift = // 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam. // Plops nudge in their object's local frame and clamp to its (local) bounds. // Mounted once, reading live values from nudgeCtx so a data refetch can't // re-subscribe and cancel a pending commit; the pending commit is flushed on // unmount, and a fire only commits if its live value is still present (a drag // that cleared it already committed its own PATCH). useEffect(() => { const DIRS: Record = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], } const commitLater = (fire: () => void) => { nudgeFire.current = fire if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) nudgeTimer.current = window.setTimeout(() => { nudgeTimer.current = null const fn = nudgeFire.current nudgeFire.current = null fn?.() }, 400) } function onKey(e: KeyboardEvent) { const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } = nudgeCtx.current if (!canNudge) return const dir = DIRS[e.key] if (!dir) return const el = document.activeElement if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return const s = useEditorStore.getState() if (s.objectDragging) return // don't fight an active pointer drag const step = e.shiftKey ? 10 : 1 if (s.selectedId != null) { const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId) if (!base) return e.preventDefault() s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step }) commitLater(() => { const live = useEditorStore.getState().liveObject if (live?.id !== s.selectedId) return // a drag cleared it and committed uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm }) useEditorStore.getState().setLiveObject(null) }) } else if (s.selectedPlantingId != null) { const base = s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId) if (!base) return e.preventDefault() const obj = objs.find((o) => o.id === base.objectId) let nx = base.xCm + dir[0] * step let ny = base.yCm + dir[1] * step if (obj) { nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx)) ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny)) } s.setLivePlanting({ ...base, xCm: nx, yCm: ny }) commitLater(() => { const live = useEditorStore.getState().livePlanting if (live?.id !== s.selectedPlantingId) return up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm }) useEditorStore.getState().setLivePlanting(null) }) } } window.addEventListener('keydown', onKey) return () => { window.removeEventListener('keydown', onKey) if (nudgeTimer.current != null) { window.clearTimeout(nudgeTimer.current) nudgeTimer.current = null } const fn = nudgeFire.current // flush a pending move so it isn't lost nudgeFire.current = null fn?.() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (full.isPending) return

Loading garden…

if (full.isError) return (
Could not load this garden.
) const g = full.data.garden const garden: EditorGarden = { id: g.id, name: g.name, widthCm: g.widthCm, heightCm: g.heightCm, unitPref: g.unitPref, } const selectedObject = objects.find((o) => o.id === selectedId) ?? null const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null // Committed selected plop; PlopInspector applies live drag geometry itself. const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : [] // Arm a plant for tap-to-place. The full catalog carries plants not yet in this // garden's /full payload, so make sure the chosen one is in the cache first — // otherwise its placed plops render without an icon/color until a refetch. function armPlant(plant: Plant) { ensurePlant(plant) setArmedPlant(plant) } // Removing the armed plant from the tray also disarms it, so placement doesn't // silently continue for a plant the user just cleared out. function removeFromTrayAndDisarm(id: number) { removeFromTray(id) if (armedPlant?.id === id) setArmedPlant(null) } // The picker hands back the full Plant (from the whole catalog), so use it // directly rather than re-resolving against the garden's referenced-plant map — // a not-yet-placed plant isn't in that map, which used to silently abort the // pick (nothing armed, picker left open). Picking (place OR change) makes sure // the plant renders and drops it into this garden's tray for quick reuse. function onPickPlant(plant: Plant) { if (!canEdit) return // defense in depth: viewers can't reach the picker anyway ensurePlant(plant) addToTray(plant) if (picker === 'change' && selectedPlop) { updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id }) } else { setArmedPlant(plant) // 'place' mode: arm for repeat placement } setPicker(null) } return (

{garden.name}

{isOwner && ( )} {!canEdit && (

👁 View only

)} {focusedObjectId == null && canEdit && }
{focusedObject && (
{objectDisplayName(focusedObject)} {canEdit && (focusedObject.plantable ? ( <> setPicker('place')} /> {armedPlant && ( )} {focusedPlops.length > 0 && ( )} ) : ( Not plantable ))}
)} {/* Empty-state hints (non-interactive overlays). */} {canEdit && focusedObjectId == null && objects.length === 0 && ( Pick a shape from the palette, then tap the field to place your first bed. )} {canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && ( Pick a plant from the tray, then tap inside the bed to place it. )}
{(selectedObject || selectedPlop) && (
{selectedObject && ( { setFocusedObject(selectedObject.id) select(null) }} /> )} {selectedPlop && ( setPicker('change')} onClose={() => selectPlanting(null)} /> )}
)} {picker && ( setPicker(null)} onSelect={onPickPlant} /> )} {sharing && setSharing(false)} />} {clearing && focusedObject && ( ({ id: p.id, version: p.version }))} gardenId={gid} onClose={() => setClearing(false)} /> )}
) }