import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, getRouteApi } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { ScanPacketDialog } from '@/components/plants/ScanPacketDialog' import { Nav } from '@/components/layout/Nav' import { ThemeButton } from '@/components/layout/ThemeButton' import { Alert } from '@/components/ui/Alert' import { Button, IconButton } from '@/components/ui/Button' import { Icon, type IconName } from '@/components/ui/Icon' import { Seg } from '@/components/ui/Seg' import { ColorDot } from '@/components/plants/Monogram' import { AssistantTab } from '@/editor/AssistantTab' import { Canvas, type CanvasHandle } from '@/editor/Canvas' import { ClearBedDialog } from '@/editor/ClearBedDialog' import { HistoryTab } from '@/editor/HistoryTab' import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector' import { JournalTab, type JournalAttach } from '@/editor/JournalTab' import { KindSwatch } from '@/editor/KindSwatch' import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds' import { useSeasons } from '@/editor/seasons' import { PHONE_BREAKPOINT, isTyping } from '@/editor/shared' import { useEditorStore, type PhoneMode, type RailTab } from '@/editor/store' import { FillRow, LotChooser, Toolkit } from '@/editor/Toolkit' import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from '@/editor/arming' import type { EditorGarden, EditorObject } from '@/editor/types' import { useUndoLast } from '@/editor/useUndoLast' import { useCapabilities } from '@/lib/agent' import { ApiError } from '@/lib/api' import { useMe } from '@/lib/auth' import { cn } from '@/lib/cn' import { useGardens, type Garden } from '@/lib/gardens' import { historyKey } from '@/lib/history' import { useJournalCounts } from '@/lib/journal' import { forgetLastGarden, rememberLastGarden } from '@/lib/lastGarden' import { monogramMap } from '@/lib/monogram' import { toEditorObject, useDeleteObject, useEnsurePlantInFull, useFillObject, useGardenFull, useGardenSeason, useRemovePlanting, useUpdateObject, useUpdatePlanting, type FullGarden, } from '@/lib/objects' import { toEditorPlanting } from '@/lib/plantings' import { usePlants, type Plant } from '@/lib/plants' import { lotsByPlant, useSeedLots } from '@/lib/seedLots' import { formatSize } from '@/lib/units' import { usePageTitle } from '@/lib/usePageTitle' const routeApi = getRouteApi('/gardens/$gardenId') /** Loads the garden (live, or a past season) and guards the edge cases; the * Editor below is everything else. */ export function GardenEditorPage() { const { gardenId } = routeApi.useParams() const gid = Number(gardenId) const { focus } = routeApi.useSearch() const navigate = routeApi.useNavigate() const seasonYear = useEditorStore((s) => s.seasonYear) // Two queries, one shown: the live one stays mounted so returning to "now" is // instant and the optimistic mutation cache it owns is never displaced. const live = useGardenFull(gid) const season = useGardenSeason(gid, seasonYear) const full = seasonYear === null ? live : season const me = useMe() usePageTitle(full.data?.garden.name ?? 'Garden') // Keyed on the LIVE query: whether the garden EXISTS doesn't depend on the // season being viewed. const gardenGone = live.error instanceof ApiError && live.error.isNotFound // Fresh transient state on entering/switching gardens, adopting the URL's // focus. `focus` is read once here on purpose. useEffect(() => { const st = useEditorStore.getState() st.reset() st.setFocus(focus ?? null) return () => useEditorStore.getState().reset() // eslint-disable-next-line react-hooks/exhaustive-deps }, [gid]) useEffect(() => { if (live.isSuccess) rememberLastGarden(gid) }, [live.isSuccess, gid]) useEffect(() => { if (gardenGone) { forgetLastGarden(gid) navigate({ to: '/gardens' }) } }, [gardenGone, gid, navigate]) if (full.isPending) return

Loading the garden…

if (full.isError) { return (
{gardenGone ? 'That garden is no longer available — taking you to your gardens…' : 'Could not load this garden.'}
) } const g = full.data.garden const canEdit = seasonYear === null && (g.ownerId === me.data?.id || g.myRole === 'editor') const isOwner = me.data != null && g.ownerId === me.data.id return } const MODES: { id: PhoneMode; label: string; icon: IconName }[] = [ { id: 'build', label: 'Build', icon: 'shovel' }, { id: 'plants', label: 'Plants', icon: 'sprout' }, { id: 'journal', label: 'Journal', icon: 'notebook' }, { id: 'chat', label: 'Assistant', icon: 'message-circle' }, ] const TABS: { id: RailTab; label: string }[] = [ { id: 'toolkit', label: 'Toolkit' }, { id: 'plot', label: 'Plot' }, { id: 'journal', label: 'Journal' }, { id: 'history', label: 'History' }, { id: 'chat', label: 'Assistant' }, ] /** * The editor: one canvas, two chromes. Above 760px of container width it is the * two-card workspace — the plan, and a rail whose first tab is the toolkit * (the handoff drew it as a third card on the left; folding it into the rail * gave the plan and the rail its width); below, a phone layout with the canvas * as the whole screen, a peek panel that docks between canvas and mode bar, * and a tool strip for the current mode. Same state, same components. */ function Editor({ gid, data, canEdit, isOwner, currentUserId, }: { gid: number data: FullGarden canEdit: boolean isOwner: boolean currentUserId?: number }) { const navigate = routeApi.useNavigate() const g: Garden = data.garden const garden: EditorGarden = useMemo( () => ({ id: g.id, name: g.name, widthCm: g.widthCm, heightCm: g.heightCm, unitPref: g.unitPref, gridSizeCm: g.gridSizeCm, snapToGrid: g.snapToGrid }), [g.id, g.name, g.widthCm, g.heightCm, g.unitPref, g.gridSizeCm, g.snapToGrid], ) const unit = g.unitPref const gardens = useGardens() const capabilities = useCapabilities() const catalog = usePlants() const seedLots = useSeedLots() const journalCounts = useJournalCounts(gid) const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data]) const sel = useEditorStore((s) => s.sel) const setSel = useEditorStore((s) => s.setSel) const focusId = useEditorStore((s) => s.focusId) const setFocus = useEditorStore((s) => s.setFocus) const armedPlant = useEditorStore((s) => s.armedPlant) const armedLotId = useEditorStore((s) => s.armedLotId) const setArmedLotId = useEditorStore((s) => s.setArmedLotId) const armedKind = useEditorStore((s) => s.armedKind) const tab = useEditorStore((s) => s.tab) const setTab = useEditorStore((s) => s.setTab) const mode = useEditorStore((s) => s.mode) const setMode = useEditorStore((s) => s.setMode) const setJournalScope = useEditorStore((s) => s.setJournalScope) const updateObject = useUpdateObject(gid) const updatePlanting = useUpdatePlanting(gid) const deleteObject = useDeleteObject(gid) const removePlanting = useRemovePlanting(gid) const fillObject = useFillObject(gid) const ensurePlant = useEnsurePlantInFull(gid) const undoLast = useUndoLast(gid, true) const seasons = useSeasons(g, gardens.data) // Every successful mutation is a history entry, and the history list is // always mounted here (the Undo button reads it), so it never gets the // on-open refetch it used to rely on. Refresh it whenever anything lands. const qc = useQueryClient() useEffect( () => qc.getMutationCache().subscribe((event) => { if (event.type === 'updated' && event.action.type === 'success') void qc.invalidateQueries({ queryKey: historyKey(gid) }) }), [qc, gid], ) const objects = useMemo(() => data.objects.map(toEditorObject), [data.objects]) const plantings = useMemo(() => data.plantings.map(toEditorPlanting), [data.plantings]) // The full payload carries the garden's referenced plants; the catalog, the // rest. Markers need both (a just-armed plant isn't referenced yet), and the // monogram collision set is the whole catalog so a letter means the same // thing on every screen. const plantsById = useMemo(() => { const m = new Map() for (const p of data.plants) m.set(p.id, p) for (const p of catalog.data ?? []) m.set(p.id, p) return m }, [data.plants, catalog.data]) const plants = useMemo(() => [...plantsById.values()], [plantsById]) const letters = useMemo(() => monogramMap(plants), [plants]) // The container's width, not the viewport's, decides the chrome. const rootRef = useRef(null) const [vw, setVw] = useState(() => (typeof window !== 'undefined' ? window.innerWidth : 1024)) useEffect(() => { const el = rootRef.current if (!el) return const ro = new ResizeObserver(([entry]) => setVw(entry.contentRect.width)) ro.observe(el) return () => ro.disconnect() }, []) const isMobile = vw < PHONE_BREAKPOINT const canvasRef = useRef(null) const [clearing, setClearing] = useState(false) const [scanning, setScanning] = useState(false) const focused = focusId != null ? (objects.find((o) => o.id === focusId) ?? null) : null const focusedPlops = useMemo(() => (focusId != null ? plantings.filter((p) => p.objectId === focusId) : []), [plantings, focusId]) const selectedObject = sel?.type === 'object' ? (objects.find((o) => o.id === sel.id) ?? null) : null const selectedPlop = sel?.type === 'plop' ? (plantings.find((p) => p.id === sel.id) ?? null) : null const hasAssistant = !!capabilities.data?.agent const canScan = !!capabilities.data?.vision // Mirror focus → ?focus so a bed is deep-linkable and survives reload. useEffect(() => { navigate({ search: (prev) => ({ ...prev, focus: focusId ?? undefined }), replace: true }) }, [focusId, navigate]) // A focused bed that vanished (deleted, or a stale ?focus) must not leave the // canvas dimmed with no way out. useEffect(() => { if (focusId != null && !objects.some((o) => o.id === focusId)) { setFocus(null) canvasRef.current?.zoomFit() } }, [focusId, objects, setFocus]) // The phone's mode follows focus: inside a bed you're planting. useEffect(() => { const cur = useEditorStore.getState().mode if (focusId != null) { if (cur === 'build') setMode('plants') } else if (cur === 'plants') setMode('build') }, [focusId, setMode]) // A plant chosen from the catalog may not be in this garden's payload yet; // make sure its plops render with a color the moment they're placed. useEffect(() => { if (armedPlant) ensurePlant(armedPlant) }, [armedPlant, ensurePlant]) // If the assistant is turned off under us, don't strand the UI on its tab. useEffect(() => { if (capabilities.data && !capabilities.data.agent) { if (useEditorStore.getState().tab === 'chat') setTab('plot') if (useEditorStore.getState().mode === 'chat') setMode('build') } }, [capabilities.data, setTab, setMode]) const exitFocus = useCallback(() => { const st = useEditorStore.getState() st.setFocus(null) st.setArmedPlant(null) st.setSel(null) if (isMobile) st.setMode('build') canvasRef.current?.zoomFit() }, [isMobile]) const plantThis = (o: EditorObject) => canvasRef.current?.focusObject(o) const openNotes = (scope: { type: 'object' | 'plop'; id: number }) => { setJournalScope(scope) setTab('journal') if (isMobile) { setSel(null) setMode('journal') } } // Latest values for the window keydown handler, which mounts once. const latest = useRef({ canEdit, objects, plantings, selectedObject, selectedPlop, focused, exitFocus, undoLast, updateObject, updatePlanting, deleteObject, removePlanting }) latest.current = { canEdit, objects, plantings, selectedObject, selectedPlop, focused, exitFocus, undoLast, updateObject, updatePlanting, deleteObject, removePlanting } const nudgeTimer = useRef(null) const nudgeCommit = useRef<(() => void) | null>(null) useEffect(() => { const DIRS: Record = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } function onKey(e: KeyboardEvent) { if (isTyping()) return const st = useEditorStore.getState() const L = latest.current if (e.key === 'Escape') { if (st.armedKind || st.armedPlant) { st.setArmedKind(null) st.setArmedPlant(null) st.setGhost(null) } else if (st.sel) st.setSel(null) else if (st.focusId != null) L.exitFocus() return } if ((e.key === 'Delete' || e.key === 'Backspace') && st.sel && L.canEdit) { e.preventDefault() if (st.sel.type === 'object') { if (st.focusId === st.sel.id) L.exitFocus() st.setSel(null) L.deleteObject.mutate(st.sel.id) } else if (L.selectedPlop) { st.setSel(null) L.removePlanting.mutate({ id: L.selectedPlop.id, version: L.selectedPlop.version }) } return } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) { if (L.canEdit && L.undoLast.canUndo) { e.preventDefault() L.undoLast.undoLast() } return } const dir = DIRS[e.key] if (!dir || !st.sel || !L.canEdit) return e.preventDefault() const step = e.shiftKey ? 10 : 1 const dx = dir[0] * step const dy = dir[1] * step // Live geometry now, one PATCH after the burst settles. if (st.sel.type === 'object') { const base = st.liveObject?.id === st.sel.id ? st.liveObject : L.objects.find((o) => o.id === st.sel!.id) if (!base) return st.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy }) nudgeCommit.current = () => { const live = useEditorStore.getState().liveObject if (!live) return L.updateObject.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm }) useEditorStore.getState().setLiveObject(null) } } else { const base = st.livePlanting?.id === st.sel.id ? st.livePlanting : L.plantings.find((p) => p.id === st.sel!.id) if (!base) return st.setLivePlanting({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy }) nudgeCommit.current = () => { const live = useEditorStore.getState().livePlanting if (!live) return L.updatePlanting.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm }) useEditorStore.getState().setLivePlanting(null) } } if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) nudgeTimer.current = window.setTimeout(() => { nudgeTimer.current = null const fn = nudgeCommit.current nudgeCommit.current = null fn?.() }, 400) } window.addEventListener('keydown', onKey) return () => { window.removeEventListener('keydown', onKey) if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) nudgeCommit.current?.() // flush a pending nudge so it isn't lost nudgeCommit.current = null } }, []) const banner = seasons.banner ?? (!canEdit ? 'You can look but not change this garden — ask the owner for editor access.' : null) // What a new journal note attaches to: selection, else the focused bed, else the garden. const attach: JournalAttach = selectedPlop ? { plantingId: selectedPlop.id, label: plantsById.get(selectedPlop.plantId)?.name ?? 'this planting' } : selectedObject ? { objectId: selectedObject.id, label: objectDisplayName(selectedObject) } : focused ? { objectId: focused.id, label: objectDisplayName(focused) } : { label: null } const inspector = selectedObject ? ( plantThis(selectedObject)} onNotes={() => openNotes({ type: 'object', id: selectedObject.id })} onDeleted={() => { if (focusId === selectedObject.id) exitFocus() setSel(null) }} /> ) : selectedPlop ? ( openNotes({ type: 'plop', id: selectedPlop.id })} onRemoved={() => setSel(null)} /> ) : null const journal = ( ) const assistant = hasAssistant ? : null const canvas = ( ) const dialogs = ( <> {scanning && setScanning(false)} />} {clearing && focused && ( setClearing(false)} /> )} ) // ── phone ─────────────────────────────────────────────────────────────── if (isMobile) { const peekInsp = !!inspector const peekJournal = !inspector && mode === 'journal' const peekChat = !inspector && mode === 'chat' && hasAssistant const peek = peekInsp || peekJournal || peekChat const stripKinds = !peek && mode === 'build' const stripPlants = !peek && mode === 'plants' const modes = MODES.filter((m) => m.id !== 'chat' || hasAssistant) const closePeek = () => { setSel(null) if (mode === 'journal' || mode === 'chat') setMode('build') } const selectMode = (id: PhoneMode) => { const st = useEditorStore.getState() st.setMode(st.mode === id && (id === 'journal' || id === 'chat') ? 'build' : id) st.setSel(null) st.setArmedKind(null) st.setGhost(null) if (id !== 'plants') st.setArmedPlant(null) } const choices = lotChoices(armedPlant, lotsByPlantId) return (
{focused ? ( ) : ( )} {focused?.name ?? g.name} {seasons.options.length > 1 && ( )}
{banner &&
{banner}
}
{canvas} canvasRef.current?.zoomFit()} />
{peek && (
{peekInsp ? (selectedObject ? 'Selected' : 'Planting') : peekJournal ? 'Journal' : 'Assistant'}
{peekInsp && inspector} {peekJournal && journal} {peekChat && assistant}
)} {(stripKinds || stripPlants) && (
{stripPlants && choices.length > 0 && } {stripPlants && armedPlant && focused && canEdit && fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })} />}
{!canEdit && View only — you can look but not change this garden.} {stripPlants && focused && ( )} {stripKinds && canEdit && OBJECT_KINDS.map((k) => ( ))} {stripPlants && canEdit && orderedPlants(plants, plantings, '').map((p) => ( ))} {stripPlants && canEdit && focused && focusedPlops.length > 0 && ( )} {stripPlants && canEdit && canScan && ( )}
)}
{modes.map((m) => { const active = mode === m.id && !inspector return ( ) })}
{dialogs}
) } // ── desktop ───────────────────────────────────────────────────────────── const tabs = TABS.filter((t) => t.id !== 'chat' || hasAssistant) const plot = inspector ?? const toolkit = ( focused && armedPlant && fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })} onClear={() => setClearing(true)} onScan={() => setScanning(true)} /> ) return (
) } function chipClass(armed: boolean): string { return cn( 'flex min-h-11 flex-none items-center gap-2 rounded-full border px-3.5 py-2.5', armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-neutral-100', ) }