Files
pansy/web/src/pages/GardenEditorPage.tsx
T
steve 8dbbc5439d
Build image / build-and-push (push) Successful in 6s
Chat panel in the garden editor (#57) (#71)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:43:22 +00:00

538 lines
22 KiB
TypeScript

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 { EditorRail, type RailTab } from '@/editor/EditorRail'
import { HistoryPanel } from '@/editor/HistoryPanel'
import { ChatPanel } from '@/editor/ChatPanel'
import { JournalPanel } from '@/editor/JournalPanel'
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 { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
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,
useGardenSeason,
useGardenYears,
useUpdateObject,
useUpdatePlanting,
} from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useCapabilities } from '@/lib/agent'
import { useJournalCounts } from '@/lib/journal'
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
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 seasonYear = useEditorStore((s) => s.seasonYear)
const setSeasonYear = useEditorStore((s) => s.setSeasonYear)
// Two queries, one shown. The live one stays mounted so returning to "now" is
// instant and so 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 years = useGardenYears(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 railTab = useEditorStore((s) => s.railTab)
const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalCounts = useJournalCounts(gid)
const capabilities = useCapabilities()
const journalTotal = useMemo(
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
[journalCounts.data],
)
const armedPlant = useEditorStore((s) => s.armedPlant)
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
const updatePlanting = useUpdatePlanting(gid)
const updateObject = useUpdateObject(gid)
const ensurePlant = useEnsurePlantInFull(gid)
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
const seedLots = useSeedLots()
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
// 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<number | null>(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
// A past season is read-only: it is a record of what happened, and editing it
// would be editing the past. This is the single gate — the palette, inspector,
// nudging, placement and drag handles all key off canEdit.
const canEdit =
seasonYear === null && 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(() => {
// resetTransient is the single list of ephemeral editor state, so new state
// (the rail tab did exactly this) can't be forgotten here.
const clear = () => useEditorStore.getState().resetTransient()
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])
// Selecting anything lands you in the inspector without a click — the rail
// must never be something you operate before you can edit. Keyed off the
// selected ids rather than a "something is selected" boolean, so selecting a
// DIFFERENT object while History is open still brings the inspector forward.
// Deselecting drops back out of the inspector but leaves History/Chat open if
// that's where you were, since those aren't about the selection.
useEffect(() => {
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector')
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
}, [selectedId, selectedPlantingId, setRailTab])
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<string, [number, number]> = {
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 <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError)
return (
<div className="p-6">
<Alert>Could not load this garden.</Alert>
</div>
)
const g = full.data.garden
const garden: EditorGarden = {
id: g.id,
name: g.name,
widthCm: g.widthCm,
heightCm: g.heightCm,
unitPref: g.unitPref,
gridSizeCm: g.gridSizeCm,
snapToGrid: g.snapToGrid,
}
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.
// The Seed Tray arms a plant directly, without going through the picker, so it
// has to do the picker's single-lot auto-attribution itself — otherwise the
// quickest way to place a plant is the one path that silently loses which
// packet it came from. Several lots is genuinely ambiguous, so that case stays
// unattributed rather than guessing; the picker is where you choose.
function armPlant(plant: Plant, lot?: SeedLot) {
ensurePlant(plant)
if (lot === undefined) {
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
lot = lots.length === 1 ? lots[0] : undefined
}
setArmedPlant(plant, lot?.id ?? null)
}
// 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, lot?: SeedLot) {
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, lot?.id ?? null) // 'place' mode: arm for repeat placement
}
setPicker(null)
}
// One rail, tabs inside it — the layout decision #49 settles for the journal
// (#53) and chat (#57) panels too: they add a tab here rather than each
// claiming their own strip of screen.
const railTabs: RailTab[] = [
{
id: 'inspector',
label: 'Inspector',
render: () =>
selectedObject ? (
<Inspector
key={`obj-${selectedObject.id}`}
object={selectedObject}
gardenId={gid}
unit={garden.unitPref}
readOnly={!canEdit}
onFocus={() => {
setFocusedObject(selectedObject.id)
select(null)
}}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
onAddNote={() => {
// Two taps from a selected bed to typing: scope the journal to it
// and switch tabs. The composer is already open in there.
setJournalObjectId(selectedObject.id)
setRailTab('journal')
}}
/>
) : selectedPlop ? (
<PlopInspector
key={`plop-${selectedPlop.id}`}
plop={selectedPlop}
plant={plantsById.get(selectedPlop.plantId)}
gardenId={gid}
unit={garden.unitPref}
readOnly={!canEdit}
onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)}
/>
) : (
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
),
},
{
id: 'journal',
label: 'Journal',
// The total on the tab is the discoverability half: a garden with a
// season's notes in it shouldn't look identical to an empty one.
badge: journalTotal,
render: () => (
<JournalPanel
gardenId={gid}
canEdit={canEdit}
currentUserId={me.data?.id}
isOwner={isOwner}
objects={objects}
scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId}
/>
),
},
{
id: 'history',
label: 'History',
render: () => <HistoryPanel gardenId={gid} canEdit={canEdit} />,
},
]
// Only when the instance actually has the assistant configured. A tab that
// opens onto an apology is worse than no tab.
if (capabilities.data?.agent) {
railTabs.push({
id: 'chat',
label: 'Assistant',
render: () => <ChatPanel gardenId={gid} canEdit={canEdit} />,
})
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40">
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
{isOwner && (
<Button variant="ghost" className="mb-2 w-full text-sm" onClick={() => setSharing(true)}>
Share
</Button>
)}
{!canEdit && seasonYear === null && (
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
)}
{years.data && years.data.length > 0 && (
<div className="mb-2">
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
</div>
)}
{focusedObjectId == null && canEdit && <Palette />}
<Button
variant="ghost"
className="mt-2 w-full text-sm"
onClick={() => {
setJournalObjectId(null)
setRailTab(railTab === 'journal' ? null : 'journal')
}}
>
Journal
</Button>
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
>
History
</Button>
{capabilities.data?.agent && (
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'chat' ? null : 'chat')}
>
💬 Assistant
</Button>
)}
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
{focusedObject && (
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{canEdit &&
(focusedObject.plantable ? (
<>
<SeedTray
trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
onArm={armPlant}
onRemove={removeFromTrayAndDisarm}
onOpenPicker={() => setPicker('place')}
/>
{armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Done
</Button>
)}
{focusedPlops.length > 0 && (
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={() => setClearing(true)}
>
Clear ({focusedPlops.length})
</Button>
)}
</>
) : (
<span className="text-xs text-muted">Not plantable</span>
))}
</div>
)}
<div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
</div>
{/* Empty-state hints (non-interactive overlays). */}
{canEdit && focusedObjectId == null && objects.length === 0 && (
<EditorHint>Pick a shape from the palette, then tap the field to place your first bed.</EditorHint>
)}
{canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && (
<EditorHint position="top">Pick a plant from the tray, then tap inside the bed to place it.</EditorHint>
)}
</div>
{railTab && (
<EditorRail
tabs={railTabs}
activeId={railTab}
onActivate={setRailTab}
onClose={() => {
// Only the inspector is *about* the selection, so only closing it
// deselects; dismissing History leaves the canvas as you had it.
if (railTab === 'inspector') {
select(null)
selectPlanting(null)
}
setRailTab(null)
}}
/>
)}
{picker && (
<PlantPicker
unit={garden.unitPref}
onClose={() => setPicker(null)}
onSelect={onPickPlant}
/>
)}
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
{clearing && focusedObject && (
<ClearBedModal
objectName={objectDisplayName(focusedObject)}
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
gardenId={gid}
onClose={() => setClearing(false)}
/>
)}
</div>
)
}