Build image / build-and-push (push) Successful in 11s
Gadfly on #99, the real ones — all in the mode/focus/rail interplay: - (3 models) Closing a journal/assistant rail while a bed was focused hard-set mode='fixtures', docking the OBJECT palette inside the focused bed with the seed tray unreachable. Derive it: closing a panel returns to Plants if still focused, else Fixtures. The canvas-mode effect now also follows UN-focus (plants→fixtures) and won't override a panel mode. - (2 models) Plants mode on a focused non-plantable object (reachable via a ?focus= deep link) showed the misleading "tap a bed" hint and no way out on mobile. Now it says what's wrong and offers Done (exit focus). - Selecting an object leaves a panel mode, so closing the inspector can't strand the bar on Journal/Assistant with nothing open. - Tapping Fixtures steps out of a focused bed (you're arranging again). - Viewer mode bar drops Fixtures/Plants (a viewer can't place anything), leaving Journal + Assistant. - Safety: if the assistant capability flips off live while it's the active mode, fall back to a canvas mode and close the orphaned chat rail. Maintainability: extracted the shared seed-tray + Done + Clear cluster into PlantPlacementTools (was duplicated between the desktop focus toolbar and the mobile Plants strip); DEFAULT_MODE const replaces the twice-hardcoded 'fixtures'; selectMode reads the reactive railTab, not getState(). Verified live at 390px: focus a bed → Plants; open journal → close → back to Plants (tray), not the Fixtures palette; tap Fixtures → exits focus. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
800 lines
33 KiB
TypeScript
800 lines
33 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, type EditorMode } from '@/editor/store'
|
||
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,
|
||
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'
|
||
import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden'
|
||
import { ApiError } from '@/lib/api'
|
||
|
||
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')
|
||
|
||
// 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 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])
|
||
|
||
// 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])
|
||
|
||
const exitFocus = () => {
|
||
setFocusedObject(null)
|
||
setArmedPlant(null)
|
||
select(null)
|
||
selectPlanting(null)
|
||
}
|
||
|
||
// The mobile mode bar. Journal/Assistant are panel modes, so they open the
|
||
// rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
|
||
// leave an inspector, which is about the selection, alone). Tapping Fixtures
|
||
// means going back to arranging objects, so it 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 {
|
||
if (railTab === 'journal' || railTab === 'chat') 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
|
||
}, [])
|
||
|
||
// 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) {
|
||
// 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)
|
||
}
|
||
|
||
// 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
|
||
trayPlants={trayPlants}
|
||
armedPlant={armedPlant}
|
||
onArm={armPlant}
|
||
onRemove={removeFromTrayAndDisarm}
|
||
onOpenPicker={() => setPicker('place')}
|
||
onDisarm={() => setArmedPlant(null)}
|
||
focusedPlopCount={focusedPlops.length}
|
||
onClear={() => setClearing(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} />
|
||
</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>
|
||
|
||
{/* Mobile bottom: contextual tools for the current mode + the mode switch
|
||
bar (#99). md:hidden — desktop uses the left column. A panel-mode rail
|
||
(journal/assistant) or the inspector overlays this while open. */}
|
||
<div className="shrink-0 md:hidden">
|
||
{canEdit && (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
|
||
trayPlants={trayPlants}
|
||
armedPlant={armedPlant}
|
||
onArm={armPlant}
|
||
onRemove={removeFromTrayAndDisarm}
|
||
onOpenPicker={() => setPicker('place')}
|
||
onDisarm={() => setArmedPlant(null)}
|
||
focusedPlopCount={focusedPlops.length}
|
||
onClear={() => setClearing(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>
|
||
)
|
||
) : (
|
||
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
|
||
</div>
|
||
|
||
{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 so the
|
||
// mobile mode bar reappears.
|
||
if (railTab === 'inspector') {
|
||
select(null)
|
||
selectPlanting(null)
|
||
} else {
|
||
// Back to a canvas mode so the mode bar reappears — 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)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{picker && (
|
||
<PlantPicker
|
||
unit={garden.unitPref}
|
||
onClose={() => setPicker(null)}
|
||
onSelect={onPickPlant}
|
||
/>
|
||
)}
|
||
|
||
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(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({
|
||
trayPlants,
|
||
armedPlant,
|
||
onArm,
|
||
onRemove,
|
||
onOpenPicker,
|
||
onDisarm,
|
||
focusedPlopCount,
|
||
onClear,
|
||
}: {
|
||
trayPlants: Plant[]
|
||
armedPlant: Plant | null
|
||
onArm: (p: Plant, lot?: SeedLot) => void
|
||
onRemove: (id: number) => void
|
||
onOpenPicker: () => void
|
||
onDisarm: () => void
|
||
focusedPlopCount: number
|
||
onClear: () => void
|
||
}) {
|
||
return (
|
||
<>
|
||
<SeedTray
|
||
trayPlants={trayPlants}
|
||
armedPlantId={armedPlant?.id ?? null}
|
||
onArm={onArm}
|
||
onRemove={onRemove}
|
||
onOpenPicker={onOpenPicker}
|
||
/>
|
||
{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 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>
|
||
)
|
||
}
|