Build image / build-and-push (push) Successful in 11s
Review nit: capabilities.data?.vision was read inline at all three Plants-mode scan entry points. Read it once into `canScan` so the gating is consistent. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
971 lines
40 KiB
TypeScript
971 lines
40 KiB
TypeScript
import { useCallback, 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 { RecentPlants } from '@/editor/RecentPlants'
|
||
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
|
||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||
import { EditorHint } from '@/editor/EditorHint'
|
||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||
import { objectDisplayName } from '@/editor/kinds'
|
||
import { useEditorStore, type EditorMode } from '@/editor/store'
|
||
import { isCoarsePointer } from '@/editor/shared'
|
||
import { cn } from '@/lib/cn'
|
||
import type { EditorGarden } from '@/editor/types'
|
||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||
import { useMe } from '@/lib/auth'
|
||
import {
|
||
toEditorObject,
|
||
useEnsurePlantInFull,
|
||
useFillObject,
|
||
useGardenFull,
|
||
useGardenSeason,
|
||
useGardenYears,
|
||
useUpdateObject,
|
||
useUpdatePlanting,
|
||
type FillLayout,
|
||
} from '@/lib/objects'
|
||
import { recentlyPlantedIds, 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'
|
||
import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden'
|
||
import { ApiError } from '@/lib/api'
|
||
|
||
const routeApi = getRouteApi('/gardens/$gardenId')
|
||
|
||
// How many recently-planted chips the Plants-mode quick strip shows — a working
|
||
// set, not the whole history; the picker covers the long tail.
|
||
const RECENT_PLANTS_MAX = 8
|
||
|
||
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')
|
||
|
||
// The garden itself is gone (deleted, or access revoked) — keyed on the LIVE
|
||
// query, since whether the garden EXISTS doesn't depend on the season being
|
||
// viewed. Both the bounce effect and the "gone" render message read this one
|
||
// value, so the promise ("taking you to your gardens…") and the redirect can't
|
||
// disagree — e.g. a season-view error while the live garden is fine must not
|
||
// claim a redirect that never fires.
|
||
const gardenGone = live.error instanceof ApiError && live.error.isNotFound
|
||
|
||
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 mode = useEditorStore((s) => s.mode)
|
||
const setMode = useEditorStore((s) => s.setMode)
|
||
|
||
const updatePlanting = useUpdatePlanting(gid)
|
||
const fillObject = useFillObject(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 [scanning, setScanning] = useState(false)
|
||
// Whether to offer packet scanning — a vision model is configured. Read once
|
||
// here so all three Plants-mode entry points gate identically.
|
||
const canScan = !!capabilities.data?.vision
|
||
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])
|
||
|
||
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
|
||
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
|
||
const recentPlants = useMemo(
|
||
() =>
|
||
recentlyPlantedIds(plantings)
|
||
.slice(0, RECENT_PLANTS_MAX)
|
||
.map((id) => plantsById.get(id))
|
||
.filter((p): p is Plant => !!p),
|
||
[plantings, plantsById],
|
||
)
|
||
|
||
// 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])
|
||
|
||
// Remember this garden as the device's last-opened, so `/` resumes here next
|
||
// visit. Keyed on the live query: which garden EXISTS doesn't depend on the
|
||
// season being viewed.
|
||
useEffect(() => {
|
||
if (live.isSuccess) rememberLastGarden(gid)
|
||
}, [live.isSuccess, gid])
|
||
|
||
// A last garden that 404s (deleted, or access revoked) must not strand the user
|
||
// on an error screen the `/` resume keeps returning to: forget it (only if it's
|
||
// this one, so a bad direct link can't wipe a good resume target) and bounce to
|
||
// the list. Transient errors (500/network) fall through to the retryable screen.
|
||
useEffect(() => {
|
||
if (gardenGone) {
|
||
forgetLastGarden(gid)
|
||
navigate({ to: '/gardens' })
|
||
}
|
||
}, [gardenGone, gid, 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])
|
||
|
||
// The canvas mode follows focus: inside a bed you're placing Plants, out of it
|
||
// you're arranging Fixtures — so the bottom strip can't show the object palette
|
||
// while you're in a bed, or the seed tray while you're not. A panel mode
|
||
// (journal/assistant) is set explicitly and left alone here. (Inert on desktop,
|
||
// where the mode bar isn't shown.)
|
||
useEffect(() => {
|
||
const cur = useEditorStore.getState().mode
|
||
if (focusedObjectId != null) {
|
||
if (cur !== 'journal' && cur !== 'assistant') setMode('plants')
|
||
} else if (cur === 'plants') {
|
||
setMode('fixtures')
|
||
}
|
||
}, [focusedObjectId, setMode])
|
||
|
||
// 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. Selecting is a
|
||
// canvas interaction, so it also leaves a panel mode — otherwise closing the
|
||
// inspector would strand the mode bar on Journal/Assistant with nothing open.
|
||
useEffect(() => {
|
||
if (selectedId != null || selectedPlantingId != null) {
|
||
setRailTab('inspector')
|
||
const s = useEditorStore.getState()
|
||
if (s.mode === 'journal' || s.mode === 'assistant') {
|
||
setMode(s.focusedObjectId != null ? 'plants' : 'fixtures')
|
||
}
|
||
} else if (useEditorStore.getState().railTab === 'inspector') {
|
||
setRailTab(null)
|
||
}
|
||
}, [selectedId, selectedPlantingId, setRailTab, setMode])
|
||
|
||
// Clearing the selection also closes the inspector via the selection effect
|
||
// above; shared by exitFocus, the mode bar, and the rail's close so the
|
||
// deselect logic lives in one place.
|
||
const clearSelection = () => {
|
||
select(null)
|
||
selectPlanting(null)
|
||
}
|
||
|
||
const exitFocus = () => {
|
||
setFocusedObject(null)
|
||
setArmedPlant(null)
|
||
clearSelection()
|
||
}
|
||
|
||
// The mobile mode bar. Journal/Assistant are panel modes, so they open the rail
|
||
// as a peek; Fixtures/Plants are canvas modes — a fresh intent — so they close
|
||
// whatever's in the rail AND drop any lingering selection (which the canvas
|
||
// would otherwise keep highlighted, e.g. after routing a selection through
|
||
// Journal and back). Fixtures also steps out of a focused bed.
|
||
const selectMode = (m: EditorMode) => {
|
||
setMode(m)
|
||
if (m === 'journal') {
|
||
setJournalObjectId(null)
|
||
setRailTab('journal')
|
||
} else if (m === 'assistant') {
|
||
setRailTab('chat')
|
||
} else {
|
||
clearSelection()
|
||
setRailTab(null)
|
||
if (m === 'fixtures' && focusedObjectId != null) exitFocus()
|
||
}
|
||
}
|
||
|
||
// Safety for a live capability flip: if the admin turns the assistant off while
|
||
// it's the active mode, drop back to a canvas mode so the bar isn't stuck on a
|
||
// tab that no longer exists (and close the now-orphaned chat rail).
|
||
useEffect(() => {
|
||
if (!capabilities.data?.agent && useEditorStore.getState().mode === 'assistant') {
|
||
setMode('fixtures')
|
||
if (useEditorStore.getState().railTab === 'chat') setRailTab(null)
|
||
}
|
||
}, [capabilities.data?.agent, setMode, setRailTab])
|
||
|
||
// 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
|
||
}, [])
|
||
|
||
// Move the selected object/plop by (dx, dy) cm: apply live geometry instantly,
|
||
// then commit ONE debounced PATCH so a burst of nudges (a held arrow key, or
|
||
// repeated taps of the touch pad) doesn't spam the server. Plops clamp to their
|
||
// object's local bounds. Reads live values from getState/nudgeCtx so it's
|
||
// correct whichever surface calls it; a commit only fires if its live value is
|
||
// still present (a drag that cleared it already committed its own PATCH).
|
||
// Stable across renders (empty deps): both read live values through refs
|
||
// (nudgeCtx) / getState, never through closed-over props, so a mount-once
|
||
// consumer (the keydown effect) and a memo-friendly one (NudgePad) both get a
|
||
// function that stays current without a new identity each render.
|
||
const commitLater = useCallback((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)
|
||
}, [])
|
||
|
||
const nudgeSelected = useCallback((dx: number, dy: number) => {
|
||
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
|
||
nudgeCtx.current
|
||
if (!canNudge) return
|
||
const s = useEditorStore.getState()
|
||
if (s.objectDragging) return // don't fight an active pointer drag
|
||
if (s.selectedId != null) {
|
||
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
|
||
if (!base) return
|
||
s.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
|
||
commitLater(() => {
|
||
const live = useEditorStore.getState().liveObject
|
||
if (live?.id !== s.selectedId) return
|
||
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
|
||
const obj = objs.find((o) => o.id === base.objectId)
|
||
let nx = base.xCm + dx
|
||
let ny = base.yCm + dy
|
||
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)
|
||
})
|
||
}
|
||
}, [commitLater])
|
||
|
||
// Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the
|
||
// same nudgeSelected the touch pad uses. Mounted once; a pending commit is
|
||
// flushed on unmount so a nudge in flight isn't lost.
|
||
useEffect(() => {
|
||
const DIRS: Record<string, [number, number]> = {
|
||
ArrowUp: [0, -1],
|
||
ArrowDown: [0, 1],
|
||
ArrowLeft: [-1, 0],
|
||
ArrowRight: [1, 0],
|
||
}
|
||
function onKey(e: KeyboardEvent) {
|
||
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.selectedId == null && s.selectedPlantingId == null) return
|
||
e.preventDefault()
|
||
const step = e.shiftKey ? 10 : 1
|
||
nudgeSelected(dir[0] * step, dir[1] * step)
|
||
}
|
||
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) {
|
||
// Only promise the bounce when the GARDEN is gone (the effect above keys on
|
||
// the same gardenGone). A season-view error while the live garden is fine is a
|
||
// different, non-redirecting failure and gets the generic message.
|
||
return (
|
||
<div className="p-6">
|
||
<Alert>
|
||
{gardenGone
|
||
? 'That garden is no longer available — taking you to your gardens…'
|
||
: '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)
|
||
}
|
||
|
||
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
|
||
// #77) — the UI's way to run the region fill that was agent-only before. Guards
|
||
// are belt-and-braces: the control only shows with a plant armed in a bed.
|
||
function fillBed(layout: FillLayout) {
|
||
if (!canEdit || focusedObject == null || armedPlant == null) return
|
||
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
|
||
}
|
||
|
||
// 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} />,
|
||
})
|
||
}
|
||
|
||
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||
// the canvas bottom + Fit button under the browser chrome (#85).
|
||
return (
|
||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||
{/* Desktop-only control column. On mobile these move to the bottom mode bar
|
||
+ a slim top strip so the canvas — the point of the screen — isn't shoved
|
||
into a corner by a stack of controls (#99). */}
|
||
<div className="hidden shrink-0 md:block 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">
|
||
{/* Mobile top strip: the garden identity / season / share that live in the
|
||
desktop left column. md:hidden. */}
|
||
<div className="flex items-center gap-2 md:hidden">
|
||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
|
||
{garden.name}
|
||
</h1>
|
||
{!canEdit && seasonYear === null && (
|
||
<span className="shrink-0 rounded-md bg-border/40 px-2 py-0.5 text-xs text-muted">👁 View only</span>
|
||
)}
|
||
{years.data && years.data.length > 0 && (
|
||
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
|
||
)}
|
||
{isOwner && (
|
||
<Button variant="ghost" className="shrink-0 px-2 py-1 text-xs" onClick={() => setSharing(true)}>
|
||
Share
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
||
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the
|
||
bottom Plants-mode strip so they don't wrap over the canvas. */}
|
||
{focusedObject && (
|
||
<div className="absolute left-2 top-2 z-20 hidden 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 md:flex">
|
||
<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 ? (
|
||
<PlantPlacementTools
|
||
recentPlants={recentPlants}
|
||
trayPlants={trayPlants}
|
||
armedPlant={armedPlant}
|
||
onArm={armPlant}
|
||
onRemove={removeFromTrayAndDisarm}
|
||
onOpenPicker={() => setPicker('place')}
|
||
onDisarm={() => setArmedPlant(null)}
|
||
focusedPlopCount={focusedPlops.length}
|
||
onClear={() => setClearing(true)}
|
||
onFill={fillBed}
|
||
filling={fillObject.isPending}
|
||
canScan={canScan}
|
||
onScan={() => setScanning(true)}
|
||
/>
|
||
) : (
|
||
<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} />
|
||
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent
|
||
on a touch device, and dragging can't hit single-cm precision. Shown
|
||
on a coarse pointer (same signal as the bigger handles) while
|
||
something's selected (#104). */}
|
||
{canEdit && isCoarsePointer && (selectedId != null || selectedPlantingId != null) && (
|
||
<NudgePad onNudge={nudgeSelected} />
|
||
)}
|
||
</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>
|
||
|
||
{/* The rail sits between the canvas and the mode bar. On mobile it's an
|
||
in-flow PEEK (≤50vh), so the garden stays visible above it and the mode
|
||
bar below (see EditorRail); on desktop it's the right-hand column. */}
|
||
{railTab && (
|
||
<EditorRail
|
||
tabs={railTabs}
|
||
activeId={railTab}
|
||
onActivate={setRailTab}
|
||
onClose={() => {
|
||
// Only the inspector is *about* the selection, so only closing it
|
||
// deselects; dismissing a panel leaves the canvas as you had it. Any
|
||
// panel rail (journal/history/chat) drops back to a canvas mode.
|
||
if (railTab === 'inspector') {
|
||
clearSelection()
|
||
} else {
|
||
// Back to a canvas mode — Plants if you're still inside a bed, else
|
||
// Fixtures. (Hardcoding Fixtures here docked the object palette inside
|
||
// a focused bed.)
|
||
setMode(focusedObjectId != null ? 'plants' : 'fixtures')
|
||
}
|
||
setRailTab(null)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Mobile bottom: contextual tools for the current canvas mode + the
|
||
always-visible mode switch bar (#99/#101). md:hidden — desktop uses the
|
||
left column. The tool strip yields to a rail peek when one is open. */}
|
||
<div className="shrink-0 md:hidden">
|
||
{canEdit && !railTab && (mode === 'fixtures' || mode === 'plants') && (
|
||
<div className="mb-2 min-h-[2.25rem]">
|
||
{mode === 'fixtures' && <Palette />}
|
||
{mode === 'plants' &&
|
||
(focusedObject ? (
|
||
focusedObject.plantable ? (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<PlantPlacementTools
|
||
recentPlants={recentPlants}
|
||
trayPlants={trayPlants}
|
||
armedPlant={armedPlant}
|
||
onArm={armPlant}
|
||
onRemove={removeFromTrayAndDisarm}
|
||
onOpenPicker={() => setPicker('place')}
|
||
onDisarm={() => setArmedPlant(null)}
|
||
focusedPlopCount={focusedPlops.length}
|
||
onClear={() => setClearing(true)}
|
||
onFill={fillBed}
|
||
filling={fillObject.isPending}
|
||
canScan={!!capabilities.data?.vision}
|
||
onScan={() => setScanning(true)}
|
||
/>
|
||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||
Done planting
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
// Reachable via a ?focus= deep link onto a non-plantable object:
|
||
// say so, and give a way back out (there's no focus toolbar here).
|
||
<div className="flex items-center gap-2">
|
||
<span className="px-1 text-xs text-muted">
|
||
{objectDisplayName(focusedObject)} isn’t plantable.
|
||
</span>
|
||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||
Done
|
||
</Button>
|
||
</div>
|
||
)
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||
{canScan && (
|
||
<Button
|
||
variant="ghost"
|
||
className="ml-auto px-2 py-1 text-xs"
|
||
onClick={() => setScanning(true)}
|
||
>
|
||
📷 Scan packet
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
|
||
</div>
|
||
|
||
{picker && (
|
||
<PlantPicker
|
||
unit={garden.unitPref}
|
||
onClose={() => setPicker(null)}
|
||
onSelect={onPickPlant}
|
||
/>
|
||
)}
|
||
|
||
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
||
|
||
{scanning && <ScanPacketModal unit={garden.unitPref} onClose={() => setScanning(false)} />}
|
||
|
||
{clearing && focusedObject && (
|
||
<ClearBedModal
|
||
objectId={focusedObject.id}
|
||
objectName={objectDisplayName(focusedObject)}
|
||
plopCount={focusedPlops.length}
|
||
gardenId={gid}
|
||
onClose={() => setClearing(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
|
||
// focus toolbar and the mobile Plants strip so the two can't drift apart.
|
||
function PlantPlacementTools({
|
||
recentPlants,
|
||
trayPlants,
|
||
armedPlant,
|
||
onArm,
|
||
onRemove,
|
||
onOpenPicker,
|
||
onDisarm,
|
||
focusedPlopCount,
|
||
onClear,
|
||
onFill,
|
||
filling,
|
||
canScan,
|
||
onScan,
|
||
}: {
|
||
recentPlants: Plant[]
|
||
trayPlants: Plant[]
|
||
armedPlant: Plant | null
|
||
onArm: (p: Plant, lot?: SeedLot) => void
|
||
onRemove: (id: number) => void
|
||
onOpenPicker: () => void
|
||
onDisarm: () => void
|
||
focusedPlopCount: number
|
||
onClear: () => void
|
||
onFill: (layout: FillLayout) => void
|
||
filling: boolean
|
||
// Scanning a packet adds a variety to the catalog mid-planting; only offered
|
||
// where a vision model is configured.
|
||
canScan: boolean
|
||
onScan: () => void
|
||
}) {
|
||
return (
|
||
<>
|
||
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
|
||
<SeedTray
|
||
trayPlants={trayPlants}
|
||
armedPlantId={armedPlant?.id ?? null}
|
||
onArm={onArm}
|
||
onRemove={onRemove}
|
||
onOpenPicker={onOpenPicker}
|
||
/>
|
||
{canScan && (
|
||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onScan}>
|
||
📷 Scan packet
|
||
</Button>
|
||
)}
|
||
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
||
{armedPlant && (
|
||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||
Done
|
||
</Button>
|
||
)}
|
||
{focusedPlopCount > 0 && (
|
||
<Button
|
||
variant="ghost"
|
||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||
onClick={onClear}
|
||
>
|
||
Clear ({focusedPlopCount})
|
||
</Button>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
|
||
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
|
||
// individual plants in rows you could actually plant from. Defaults to grid,
|
||
// since "fill this bed" usually means a real planting.
|
||
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
|
||
const [layout, setLayout] = useState<FillLayout>('grid')
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
|
||
<span className="inline-flex overflow-hidden rounded-full">
|
||
{(['clump', 'grid'] as const).map((l) => (
|
||
<button
|
||
key={l}
|
||
type="button"
|
||
aria-pressed={layout === l}
|
||
onClick={() => setLayout(l)}
|
||
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
|
||
className={cn(
|
||
'px-2 py-0.5 font-medium capitalize transition-colors',
|
||
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
|
||
)}
|
||
>
|
||
{l === 'grid' ? 'rows' : l}
|
||
</button>
|
||
))}
|
||
</span>
|
||
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
|
||
{busy ? 'Filling…' : 'Fill bed'}
|
||
</Button>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch
|
||
// device, where the keyboard's arrow-nudge isn't reachable and a drag can't hit
|
||
// single-cm precision. Rendered only on a coarse pointer (the caller gates it).
|
||
// Wired to the same nudgeSelected, so it shares the live-then-debounced-PATCH.
|
||
function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
|
||
const btn =
|
||
'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' +
|
||
'shadow-sm outline-none backdrop-blur transition-colors active:bg-border/70 focus-visible:ring-2 focus-visible:ring-accent/40'
|
||
return (
|
||
<div
|
||
role="group"
|
||
aria-label="Nudge selection by 1cm"
|
||
className="absolute bottom-2 left-2 z-20 grid grid-cols-3 grid-rows-3 gap-0.5"
|
||
>
|
||
<span />
|
||
<button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}>
|
||
↑
|
||
</button>
|
||
<span />
|
||
<button type="button" className={btn} aria-label="Nudge left" onClick={() => onNudge(-1, 0)}>
|
||
←
|
||
</button>
|
||
<span className="flex size-10 items-center justify-center text-[0.6rem] font-medium text-muted">1cm</span>
|
||
<button type="button" className={btn} aria-label="Nudge right" onClick={() => onNudge(1, 0)}>
|
||
→
|
||
</button>
|
||
<span />
|
||
<button type="button" className={btn} aria-label="Nudge down" onClick={() => onNudge(0, 1)}>
|
||
↓
|
||
</button>
|
||
<span />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||
// strip. Assistant is dropped when the instance has no model configured.
|
||
const MODES: { id: EditorMode; label: string; icon: string }[] = [
|
||
{ id: 'fixtures', label: 'Fixtures', icon: '🛠️' },
|
||
{ id: 'plants', label: 'Plants', icon: '🌱' },
|
||
{ id: 'journal', label: 'Journal', icon: '📓' },
|
||
{ id: 'assistant', label: 'Assistant', icon: '💬' },
|
||
]
|
||
|
||
function ModeBar({
|
||
mode,
|
||
onSelect,
|
||
hasAssistant,
|
||
canEdit,
|
||
}: {
|
||
mode: EditorMode
|
||
onSelect: (m: EditorMode) => void
|
||
hasAssistant: boolean
|
||
canEdit: boolean
|
||
}) {
|
||
const modes = MODES.filter((m) => {
|
||
if (m.id === 'assistant') return hasAssistant
|
||
// Fixtures/Plants are edit actions — a viewer can't place anything, so the
|
||
// bar offers only what they can do (read the journal, ask the assistant).
|
||
if (m.id === 'fixtures' || m.id === 'plants') return canEdit
|
||
return true
|
||
})
|
||
return (
|
||
<div
|
||
role="tablist"
|
||
aria-label="Editor mode"
|
||
className="flex items-stretch justify-around overflow-hidden rounded-xl border border-border bg-surface"
|
||
>
|
||
{modes.map((m) => {
|
||
const active = mode === m.id
|
||
return (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => onSelect(m.id)}
|
||
className={cn(
|
||
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
|
||
active ? 'bg-border/60 text-accent-strong' : 'text-muted hover:text-fg',
|
||
)}
|
||
>
|
||
<span aria-hidden className="text-lg leading-none">
|
||
{m.icon}
|
||
</span>
|
||
{m.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|