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 (