The handoff drew the toolkit as a 216px card left of the plan. That width was better spent on the plan and the rail, so the toolkit is now the rail's first tab — Toolkit / Plot / Journal / History / Assistant — rendered `embedded` (no card chrome, no heading; the tab is the heading), the grid is two columns, and the rail grows from 336 to 400px. Focusing a bed (double-click) switches the rail to Toolkit, because that is where the plant palette now lives; a single click still selects into Plot. The phone chrome is untouched: it never used the card. Co-Authored-By: Claude Fable 5 <[email protected]>
672 lines
29 KiB
TypeScript
672 lines
29 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Link, getRouteApi } from '@tanstack/react-router'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { ScanPacketDialog } from '@/components/plants/ScanPacketDialog'
|
|
import { Nav } from '@/components/layout/Nav'
|
|
import { ThemeButton } from '@/components/layout/ThemeButton'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button, IconButton } from '@/components/ui/Button'
|
|
import { Icon, type IconName } from '@/components/ui/Icon'
|
|
import { Seg } from '@/components/ui/Seg'
|
|
import { ColorDot } from '@/components/plants/Monogram'
|
|
import { AssistantTab } from '@/editor/AssistantTab'
|
|
import { Canvas, type CanvasHandle } from '@/editor/Canvas'
|
|
import { ClearBedDialog } from '@/editor/ClearBedDialog'
|
|
import { HistoryTab } from '@/editor/HistoryTab'
|
|
import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector'
|
|
import { JournalTab, type JournalAttach } from '@/editor/JournalTab'
|
|
import { KindSwatch } from '@/editor/KindSwatch'
|
|
import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds'
|
|
import { useSeasons } from '@/editor/seasons'
|
|
import { PHONE_BREAKPOINT, isTyping } from '@/editor/shared'
|
|
import { useEditorStore, type PhoneMode, type RailTab } from '@/editor/store'
|
|
import { FillRow, LotChooser, Toolkit } from '@/editor/Toolkit'
|
|
import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from '@/editor/arming'
|
|
import type { EditorGarden, EditorObject } from '@/editor/types'
|
|
import { useUndoLast } from '@/editor/useUndoLast'
|
|
import { useCapabilities } from '@/lib/agent'
|
|
import { ApiError } from '@/lib/api'
|
|
import { useMe } from '@/lib/auth'
|
|
import { cn } from '@/lib/cn'
|
|
import { useGardens, type Garden } from '@/lib/gardens'
|
|
import { historyKey } from '@/lib/history'
|
|
import { useJournalCounts } from '@/lib/journal'
|
|
import { forgetLastGarden, rememberLastGarden } from '@/lib/lastGarden'
|
|
import { monogramMap } from '@/lib/monogram'
|
|
import {
|
|
toEditorObject,
|
|
useDeleteObject,
|
|
useEnsurePlantInFull,
|
|
useFillObject,
|
|
useGardenFull,
|
|
useGardenSeason,
|
|
useRemovePlanting,
|
|
useUpdateObject,
|
|
useUpdatePlanting,
|
|
type FullGarden,
|
|
} from '@/lib/objects'
|
|
import { toEditorPlanting } from '@/lib/plantings'
|
|
import { usePlants, type Plant } from '@/lib/plants'
|
|
import { lotsByPlant, useSeedLots } from '@/lib/seedLots'
|
|
import { formatSize } from '@/lib/units'
|
|
import { usePageTitle } from '@/lib/usePageTitle'
|
|
|
|
const routeApi = getRouteApi('/gardens/$gardenId')
|
|
|
|
/** Loads the garden (live, or a past season) and guards the edge cases; the
|
|
* Editor below is everything else. */
|
|
export function GardenEditorPage() {
|
|
const { gardenId } = routeApi.useParams()
|
|
const gid = Number(gardenId)
|
|
const { focus } = routeApi.useSearch()
|
|
const navigate = routeApi.useNavigate()
|
|
const seasonYear = useEditorStore((s) => s.seasonYear)
|
|
// Two queries, one shown: the live one stays mounted so returning to "now" is
|
|
// instant and the optimistic mutation cache it owns is never displaced.
|
|
const live = useGardenFull(gid)
|
|
const season = useGardenSeason(gid, seasonYear)
|
|
const full = seasonYear === null ? live : season
|
|
const me = useMe()
|
|
usePageTitle(full.data?.garden.name ?? 'Garden')
|
|
|
|
// Keyed on the LIVE query: whether the garden EXISTS doesn't depend on the
|
|
// season being viewed.
|
|
const gardenGone = live.error instanceof ApiError && live.error.isNotFound
|
|
|
|
// Fresh transient state on entering/switching gardens, adopting the URL's
|
|
// focus. `focus` is read once here on purpose.
|
|
useEffect(() => {
|
|
const st = useEditorStore.getState()
|
|
st.reset()
|
|
st.setFocus(focus ?? null)
|
|
return () => useEditorStore.getState().reset()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [gid])
|
|
|
|
useEffect(() => {
|
|
if (live.isSuccess) rememberLastGarden(gid)
|
|
}, [live.isSuccess, gid])
|
|
|
|
useEffect(() => {
|
|
if (gardenGone) {
|
|
forgetLastGarden(gid)
|
|
navigate({ to: '/gardens' })
|
|
}
|
|
}, [gardenGone, gid, navigate])
|
|
|
|
if (full.isPending) return <p className="p-7 text-[13px] font-semibold text-ink-mute">Loading the garden…</p>
|
|
if (full.isError) {
|
|
return (
|
|
<div className="p-7">
|
|
<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 canEdit = seasonYear === null && (g.ownerId === me.data?.id || g.myRole === 'editor')
|
|
const isOwner = me.data != null && g.ownerId === me.data.id
|
|
return <Editor gid={gid} data={full.data} canEdit={canEdit} isOwner={isOwner} currentUserId={me.data?.id} />
|
|
}
|
|
|
|
const MODES: { id: PhoneMode; label: string; icon: IconName }[] = [
|
|
{ id: 'build', label: 'Build', icon: 'shovel' },
|
|
{ id: 'plants', label: 'Plants', icon: 'sprout' },
|
|
{ id: 'journal', label: 'Journal', icon: 'notebook' },
|
|
{ id: 'chat', label: 'Assistant', icon: 'message-circle' },
|
|
]
|
|
|
|
const TABS: { id: RailTab; label: string }[] = [
|
|
{ id: 'toolkit', label: 'Toolkit' },
|
|
{ id: 'plot', label: 'Plot' },
|
|
{ id: 'journal', label: 'Journal' },
|
|
{ id: 'history', label: 'History' },
|
|
{ id: 'chat', label: 'Assistant' },
|
|
]
|
|
|
|
/**
|
|
* The editor: one canvas, two chromes. Above 760px of container width it is the
|
|
* two-card workspace — the plan, and a rail whose first tab is the toolkit
|
|
* (the handoff drew it as a third card on the left; folding it into the rail
|
|
* gave the plan and the rail its width); below, a phone layout with the canvas
|
|
* as the whole screen, a peek panel that docks between canvas and mode bar,
|
|
* and a tool strip for the current mode. Same state, same components.
|
|
*/
|
|
function Editor({
|
|
gid,
|
|
data,
|
|
canEdit,
|
|
isOwner,
|
|
currentUserId,
|
|
}: {
|
|
gid: number
|
|
data: FullGarden
|
|
canEdit: boolean
|
|
isOwner: boolean
|
|
currentUserId?: number
|
|
}) {
|
|
const navigate = routeApi.useNavigate()
|
|
const g: Garden = data.garden
|
|
const garden: EditorGarden = useMemo(
|
|
() => ({ id: g.id, name: g.name, widthCm: g.widthCm, heightCm: g.heightCm, unitPref: g.unitPref, gridSizeCm: g.gridSizeCm, snapToGrid: g.snapToGrid }),
|
|
[g.id, g.name, g.widthCm, g.heightCm, g.unitPref, g.gridSizeCm, g.snapToGrid],
|
|
)
|
|
const unit = g.unitPref
|
|
|
|
const gardens = useGardens()
|
|
const capabilities = useCapabilities()
|
|
const catalog = usePlants()
|
|
const seedLots = useSeedLots()
|
|
const journalCounts = useJournalCounts(gid)
|
|
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
|
|
|
const sel = useEditorStore((s) => s.sel)
|
|
const setSel = useEditorStore((s) => s.setSel)
|
|
const focusId = useEditorStore((s) => s.focusId)
|
|
const setFocus = useEditorStore((s) => s.setFocus)
|
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
|
const armedLotId = useEditorStore((s) => s.armedLotId)
|
|
const setArmedLotId = useEditorStore((s) => s.setArmedLotId)
|
|
const armedKind = useEditorStore((s) => s.armedKind)
|
|
const tab = useEditorStore((s) => s.tab)
|
|
const setTab = useEditorStore((s) => s.setTab)
|
|
const mode = useEditorStore((s) => s.mode)
|
|
const setMode = useEditorStore((s) => s.setMode)
|
|
const setJournalScope = useEditorStore((s) => s.setJournalScope)
|
|
|
|
const updateObject = useUpdateObject(gid)
|
|
const updatePlanting = useUpdatePlanting(gid)
|
|
const deleteObject = useDeleteObject(gid)
|
|
const removePlanting = useRemovePlanting(gid)
|
|
const fillObject = useFillObject(gid)
|
|
const ensurePlant = useEnsurePlantInFull(gid)
|
|
const undoLast = useUndoLast(gid, true)
|
|
const seasons = useSeasons(g, gardens.data)
|
|
|
|
// Every successful mutation is a history entry, and the history list is
|
|
// always mounted here (the Undo button reads it), so it never gets the
|
|
// on-open refetch it used to rely on. Refresh it whenever anything lands.
|
|
const qc = useQueryClient()
|
|
useEffect(
|
|
() =>
|
|
qc.getMutationCache().subscribe((event) => {
|
|
if (event.type === 'updated' && event.action.type === 'success') void qc.invalidateQueries({ queryKey: historyKey(gid) })
|
|
}),
|
|
[qc, gid],
|
|
)
|
|
|
|
const objects = useMemo(() => data.objects.map(toEditorObject), [data.objects])
|
|
const plantings = useMemo(() => data.plantings.map(toEditorPlanting), [data.plantings])
|
|
// The full payload carries the garden's referenced plants; the catalog, the
|
|
// rest. Markers need both (a just-armed plant isn't referenced yet), and the
|
|
// monogram collision set is the whole catalog so a letter means the same
|
|
// thing on every screen.
|
|
const plantsById = useMemo(() => {
|
|
const m = new Map<number, Plant>()
|
|
for (const p of data.plants) m.set(p.id, p)
|
|
for (const p of catalog.data ?? []) m.set(p.id, p)
|
|
return m
|
|
}, [data.plants, catalog.data])
|
|
const plants = useMemo(() => [...plantsById.values()], [plantsById])
|
|
const letters = useMemo(() => monogramMap(plants), [plants])
|
|
|
|
// The container's width, not the viewport's, decides the chrome.
|
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
const [vw, setVw] = useState(() => (typeof window !== 'undefined' ? window.innerWidth : 1024))
|
|
useEffect(() => {
|
|
const el = rootRef.current
|
|
if (!el) return
|
|
const ro = new ResizeObserver(([entry]) => setVw(entry.contentRect.width))
|
|
ro.observe(el)
|
|
return () => ro.disconnect()
|
|
}, [])
|
|
const isMobile = vw < PHONE_BREAKPOINT
|
|
|
|
const canvasRef = useRef<CanvasHandle>(null)
|
|
const [clearing, setClearing] = useState(false)
|
|
const [scanning, setScanning] = useState(false)
|
|
|
|
const focused = focusId != null ? (objects.find((o) => o.id === focusId) ?? null) : null
|
|
const focusedPlops = useMemo(() => (focusId != null ? plantings.filter((p) => p.objectId === focusId) : []), [plantings, focusId])
|
|
const selectedObject = sel?.type === 'object' ? (objects.find((o) => o.id === sel.id) ?? null) : null
|
|
const selectedPlop = sel?.type === 'plop' ? (plantings.find((p) => p.id === sel.id) ?? null) : null
|
|
const hasAssistant = !!capabilities.data?.agent
|
|
const canScan = !!capabilities.data?.vision
|
|
|
|
// Mirror focus → ?focus so a bed is deep-linkable and survives reload.
|
|
useEffect(() => {
|
|
navigate({ search: (prev) => ({ ...prev, focus: focusId ?? undefined }), replace: true })
|
|
}, [focusId, navigate])
|
|
|
|
// A focused bed that vanished (deleted, or a stale ?focus) must not leave the
|
|
// canvas dimmed with no way out.
|
|
useEffect(() => {
|
|
if (focusId != null && !objects.some((o) => o.id === focusId)) {
|
|
setFocus(null)
|
|
canvasRef.current?.zoomFit()
|
|
}
|
|
}, [focusId, objects, setFocus])
|
|
|
|
// The phone's mode follows focus: inside a bed you're planting.
|
|
useEffect(() => {
|
|
const cur = useEditorStore.getState().mode
|
|
if (focusId != null) {
|
|
if (cur === 'build') setMode('plants')
|
|
} else if (cur === 'plants') setMode('build')
|
|
}, [focusId, setMode])
|
|
|
|
// A plant chosen from the catalog may not be in this garden's payload yet;
|
|
// make sure its plops render with a color the moment they're placed.
|
|
useEffect(() => {
|
|
if (armedPlant) ensurePlant(armedPlant)
|
|
}, [armedPlant, ensurePlant])
|
|
|
|
// If the assistant is turned off under us, don't strand the UI on its tab.
|
|
useEffect(() => {
|
|
if (capabilities.data && !capabilities.data.agent) {
|
|
if (useEditorStore.getState().tab === 'chat') setTab('plot')
|
|
if (useEditorStore.getState().mode === 'chat') setMode('build')
|
|
}
|
|
}, [capabilities.data, setTab, setMode])
|
|
|
|
const exitFocus = useCallback(() => {
|
|
const st = useEditorStore.getState()
|
|
st.setFocus(null)
|
|
st.setArmedPlant(null)
|
|
st.setSel(null)
|
|
if (isMobile) st.setMode('build')
|
|
canvasRef.current?.zoomFit()
|
|
}, [isMobile])
|
|
|
|
const plantThis = (o: EditorObject) => canvasRef.current?.focusObject(o)
|
|
|
|
const openNotes = (scope: { type: 'object' | 'plop'; id: number }) => {
|
|
setJournalScope(scope)
|
|
setTab('journal')
|
|
if (isMobile) {
|
|
setSel(null)
|
|
setMode('journal')
|
|
}
|
|
}
|
|
|
|
// Latest values for the window keydown handler, which mounts once.
|
|
const latest = useRef({ canEdit, objects, plantings, selectedObject, selectedPlop, focused, exitFocus, undoLast, updateObject, updatePlanting, deleteObject, removePlanting })
|
|
latest.current = { canEdit, objects, plantings, selectedObject, selectedPlop, focused, exitFocus, undoLast, updateObject, updatePlanting, deleteObject, removePlanting }
|
|
const nudgeTimer = useRef<number | null>(null)
|
|
const nudgeCommit = useRef<(() => void) | null>(null)
|
|
|
|
useEffect(() => {
|
|
const DIRS: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
|
|
function onKey(e: KeyboardEvent) {
|
|
if (isTyping()) return
|
|
const st = useEditorStore.getState()
|
|
const L = latest.current
|
|
if (e.key === 'Escape') {
|
|
if (st.armedKind || st.armedPlant) {
|
|
st.setArmedKind(null)
|
|
st.setArmedPlant(null)
|
|
st.setGhost(null)
|
|
} else if (st.sel) st.setSel(null)
|
|
else if (st.focusId != null) L.exitFocus()
|
|
return
|
|
}
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && st.sel && L.canEdit) {
|
|
e.preventDefault()
|
|
if (st.sel.type === 'object') {
|
|
if (st.focusId === st.sel.id) L.exitFocus()
|
|
st.setSel(null)
|
|
L.deleteObject.mutate(st.sel.id)
|
|
} else if (L.selectedPlop) {
|
|
st.setSel(null)
|
|
L.removePlanting.mutate({ id: L.selectedPlop.id, version: L.selectedPlop.version })
|
|
}
|
|
return
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
|
if (L.canEdit && L.undoLast.canUndo) {
|
|
e.preventDefault()
|
|
L.undoLast.undoLast()
|
|
}
|
|
return
|
|
}
|
|
const dir = DIRS[e.key]
|
|
if (!dir || !st.sel || !L.canEdit) return
|
|
e.preventDefault()
|
|
const step = e.shiftKey ? 10 : 1
|
|
const dx = dir[0] * step
|
|
const dy = dir[1] * step
|
|
// Live geometry now, one PATCH after the burst settles.
|
|
if (st.sel.type === 'object') {
|
|
const base = st.liveObject?.id === st.sel.id ? st.liveObject : L.objects.find((o) => o.id === st.sel!.id)
|
|
if (!base) return
|
|
st.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
|
|
nudgeCommit.current = () => {
|
|
const live = useEditorStore.getState().liveObject
|
|
if (!live) return
|
|
L.updateObject.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
|
useEditorStore.getState().setLiveObject(null)
|
|
}
|
|
} else {
|
|
const base = st.livePlanting?.id === st.sel.id ? st.livePlanting : L.plantings.find((p) => p.id === st.sel!.id)
|
|
if (!base) return
|
|
st.setLivePlanting({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
|
|
nudgeCommit.current = () => {
|
|
const live = useEditorStore.getState().livePlanting
|
|
if (!live) return
|
|
L.updatePlanting.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
|
useEditorStore.getState().setLivePlanting(null)
|
|
}
|
|
}
|
|
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
|
nudgeTimer.current = window.setTimeout(() => {
|
|
nudgeTimer.current = null
|
|
const fn = nudgeCommit.current
|
|
nudgeCommit.current = null
|
|
fn?.()
|
|
}, 400)
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey)
|
|
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
|
nudgeCommit.current?.() // flush a pending nudge so it isn't lost
|
|
nudgeCommit.current = null
|
|
}
|
|
}, [])
|
|
|
|
const banner = seasons.banner ?? (!canEdit ? 'You can look but not change this garden — ask the owner for editor access.' : null)
|
|
|
|
// What a new journal note attaches to: selection, else the focused bed, else the garden.
|
|
const attach: JournalAttach = selectedPlop
|
|
? { plantingId: selectedPlop.id, label: plantsById.get(selectedPlop.plantId)?.name ?? 'this planting' }
|
|
: selectedObject
|
|
? { objectId: selectedObject.id, label: objectDisplayName(selectedObject) }
|
|
: focused
|
|
? { objectId: focused.id, label: objectDisplayName(focused) }
|
|
: { label: null }
|
|
|
|
const inspector = selectedObject ? (
|
|
<ObjectInspector
|
|
key={`o${selectedObject.id}`}
|
|
object={selectedObject}
|
|
gardenId={gid}
|
|
unit={unit}
|
|
canEdit={canEdit}
|
|
focused={focusId === selectedObject.id}
|
|
roster={rosterText(selectedObject, plantings, plantsById)}
|
|
plantCount={plantCountIn(selectedObject, plantings, plantsById)}
|
|
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
|
|
large={isMobile}
|
|
onPlantThis={() => plantThis(selectedObject)}
|
|
onNotes={() => openNotes({ type: 'object', id: selectedObject.id })}
|
|
onDeleted={() => {
|
|
if (focusId === selectedObject.id) exitFocus()
|
|
setSel(null)
|
|
}}
|
|
/>
|
|
) : selectedPlop ? (
|
|
<PlopInspector
|
|
key={`p${selectedPlop.id}`}
|
|
plop={selectedPlop}
|
|
plant={plantsById.get(selectedPlop.plantId)}
|
|
plants={plants}
|
|
gardenId={gid}
|
|
unit={unit}
|
|
canEdit={canEdit}
|
|
noteCount={0}
|
|
large={isMobile}
|
|
onNotes={() => openNotes({ type: 'plop', id: selectedPlop.id })}
|
|
onRemoved={() => setSel(null)}
|
|
/>
|
|
) : null
|
|
|
|
const journal = (
|
|
<JournalTab
|
|
gardenId={gid}
|
|
canEdit={canEdit}
|
|
currentUserId={currentUserId}
|
|
isOwner={isOwner}
|
|
objects={objects}
|
|
plantings={plantings}
|
|
attach={attach}
|
|
composerFirst={isMobile}
|
|
large={isMobile}
|
|
/>
|
|
)
|
|
const assistant = hasAssistant ? <AssistantTab gardenId={gid} canEdit={canEdit} undo={undoLast.undo} large={isMobile} /> : null
|
|
|
|
const canvas = (
|
|
<Canvas ref={canvasRef} garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} letters={letters} canEdit={canEdit} isMobile={isMobile} />
|
|
)
|
|
|
|
const dialogs = (
|
|
<>
|
|
{scanning && <ScanPacketDialog unit={unit} onClose={() => setScanning(false)} />}
|
|
{clearing && focused && (
|
|
<ClearBedDialog objectId={focused.id} objectName={objectDisplayName(focused)} plopCount={focusedPlops.length} gardenId={gid} onClose={() => setClearing(false)} />
|
|
)}
|
|
</>
|
|
)
|
|
|
|
// ── phone ───────────────────────────────────────────────────────────────
|
|
if (isMobile) {
|
|
const peekInsp = !!inspector
|
|
const peekJournal = !inspector && mode === 'journal'
|
|
const peekChat = !inspector && mode === 'chat' && hasAssistant
|
|
const peek = peekInsp || peekJournal || peekChat
|
|
const stripKinds = !peek && mode === 'build'
|
|
const stripPlants = !peek && mode === 'plants'
|
|
const modes = MODES.filter((m) => m.id !== 'chat' || hasAssistant)
|
|
const closePeek = () => {
|
|
setSel(null)
|
|
if (mode === 'journal' || mode === 'chat') setMode('build')
|
|
}
|
|
const selectMode = (id: PhoneMode) => {
|
|
const st = useEditorStore.getState()
|
|
st.setMode(st.mode === id && (id === 'journal' || id === 'chat') ? 'build' : id)
|
|
st.setSel(null)
|
|
st.setArmedKind(null)
|
|
st.setGhost(null)
|
|
if (id !== 'plants') st.setArmedPlant(null)
|
|
}
|
|
const choices = lotChoices(armedPlant, lotsByPlantId)
|
|
|
|
return (
|
|
<div ref={rootRef} className="flex h-dvh flex-col bg-bg">
|
|
<div className="flex flex-none items-center gap-2 px-3 py-2.5">
|
|
{focused ? (
|
|
<IconButton label="Back to the plan" icon="chevron-left" iconSize={17} size={38} onClick={exitFocus} />
|
|
) : (
|
|
<Link to="/gardens" className="btn btn-icon btn-secondary no-underline" style={{ width: 38, height: 38 }} title="Your gardens" aria-label="Your gardens">
|
|
<Icon name="chevron-left" size={17} />
|
|
</Link>
|
|
)}
|
|
<span className="min-w-0 truncate font-heading text-[17px]" title={focused?.name ?? g.name}>
|
|
{focused?.name ?? g.name}
|
|
</span>
|
|
<span className="ml-auto flex flex-none items-center gap-2">
|
|
<ThemeButton size={38} iconSize={16} />
|
|
{seasons.options.length > 1 && (
|
|
<button type="button" className="tag tag-accent-2 cursor-pointer border-0" onClick={seasons.cycle} title="Switch season">
|
|
{seasons.short}
|
|
</button>
|
|
)}
|
|
<IconButton label="Undo" icon="undo-2" iconSize={16} size={38} disabled={!canEdit || !undoLast.canUndo} onClick={undoLast.undoLast} />
|
|
</span>
|
|
</div>
|
|
{banner && <div className="flex-none bg-accent-2-200 px-3.5 py-1.5 text-xs font-semibold text-accent-2-800">{banner}</div>}
|
|
|
|
<div className="relative min-h-0 flex-1">
|
|
{canvas}
|
|
<IconButton label="Fit the garden" icon="maximize" iconSize={16} variant="plain" size={40} className="elev-sm absolute bottom-3 right-3 border border-divider bg-surface" onClick={() => canvasRef.current?.zoomFit()} />
|
|
</div>
|
|
|
|
{peek && (
|
|
<div className="elev-lg flex max-h-[45%] flex-none flex-col rounded-t-[22px] border-t border-divider bg-neutral-100">
|
|
<div className="flex items-center gap-2 px-3.5 pb-0.5 pt-2.5">
|
|
<span className="font-heading text-[15px]">{peekInsp ? (selectedObject ? 'Selected' : 'Planting') : peekJournal ? 'Journal' : 'Assistant'}</span>
|
|
<IconButton label="Close" icon="x" variant="plain" size={32} className="ml-auto" onClick={closePeek} />
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-4 pb-4 pt-2">
|
|
{peekInsp && inspector}
|
|
{peekJournal && journal}
|
|
{peekChat && assistant}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(stripKinds || stripPlants) && (
|
|
<div className="flex flex-none flex-col gap-2 border-t border-divider bg-bg px-3 py-2.5">
|
|
{stripPlants && choices.length > 0 && <LotChooser lots={choices} value={armedLotId} onChange={setArmedLotId} compact />}
|
|
{stripPlants && armedPlant && focused && canEdit && <FillRow plant={armedPlant} busy={fillObject.isPending} onFill={(layout) => fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })} />}
|
|
<div className="flex gap-2 overflow-x-auto">
|
|
{!canEdit && <span className="self-center whitespace-nowrap px-1 text-xs text-ink-mute">View only — you can look but not change this garden.</span>}
|
|
{stripPlants && focused && (
|
|
<Button variant="primary" tall className="flex-none" onClick={exitFocus}>
|
|
Done
|
|
</Button>
|
|
)}
|
|
{stripKinds &&
|
|
canEdit &&
|
|
OBJECT_KINDS.map((k) => (
|
|
<button key={k.kind} type="button" className={chipClass(armedKind === k.kind)} aria-pressed={armedKind === k.kind} onClick={() => toggleArmedKind(k.kind)}>
|
|
<KindSwatch def={k} compact />
|
|
<span className="whitespace-nowrap text-[13px] font-bold">{k.label}</span>
|
|
</button>
|
|
))}
|
|
{stripPlants &&
|
|
canEdit &&
|
|
orderedPlants(plants, plantings, '').map((p) => (
|
|
<button key={p.id} type="button" className={chipClass(armedPlant?.id === p.id)} aria-pressed={armedPlant?.id === p.id} onClick={() => toggleArmedPlant(p, lotsByPlantId)}>
|
|
<ColorDot color={p.color} size={15} />
|
|
<span className="whitespace-nowrap text-[13px] font-bold">{p.name}</span>
|
|
</button>
|
|
))}
|
|
{stripPlants && canEdit && focused && focusedPlops.length > 0 && (
|
|
<button type="button" className={cn(chipClass(false), 'text-accent-700')} onClick={() => setClearing(true)}>
|
|
<Icon name="eraser" size={14} />
|
|
<span className="whitespace-nowrap text-[13px] font-bold">Clear ({focusedPlops.length})</span>
|
|
</button>
|
|
)}
|
|
{stripPlants && canEdit && canScan && (
|
|
<button type="button" className={chipClass(false)} onClick={() => setScanning(true)}>
|
|
<Icon name="camera" size={14} />
|
|
<span className="whitespace-nowrap text-[13px] font-bold">Scan a packet</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-none gap-1 border-t border-divider bg-surface px-2 pb-[calc(6px+env(safe-area-inset-bottom))] pt-1.5" role="tablist" aria-label="Editor mode">
|
|
{modes.map((m) => {
|
|
const active = mode === m.id && !inspector
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => selectMode(m.id)}
|
|
className={cn('flex min-h-[52px] flex-1 flex-col items-center gap-[3px] rounded-[16px] border-0 px-1 py-2', active ? 'bg-accent-200 text-accent-800' : 'text-ink-soft')}
|
|
>
|
|
<Icon name={m.icon} size={19} />
|
|
<span className="text-[11px] font-bold">{m.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{dialogs}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── desktop ─────────────────────────────────────────────────────────────
|
|
const tabs = TABS.filter((t) => t.id !== 'chat' || hasAssistant)
|
|
const plot = inspector ?? <GardenSummary objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
|
const toolkit = (
|
|
<Toolkit
|
|
embedded
|
|
unit={unit}
|
|
plants={plants}
|
|
plantings={plantings}
|
|
lotsByPlant={lotsByPlantId}
|
|
canEdit={canEdit}
|
|
focused={focused}
|
|
focusedPlopCount={focusedPlops.length}
|
|
canScan={canScan}
|
|
filling={fillObject.isPending}
|
|
onBack={exitFocus}
|
|
onFill={(layout) => focused && armedPlant && fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })}
|
|
onClear={() => setClearing(true)}
|
|
onScan={() => setScanning(true)}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<div ref={rootRef} className="flex h-dvh flex-col bg-bg">
|
|
<Nav active="gardens" />
|
|
<div className="grid min-h-0 flex-1 gap-3.5 p-3.5 pt-0 [grid-template-columns:minmax(0,1fr)_400px]">
|
|
<div className="panel flex min-h-0 flex-col overflow-hidden">
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-divider px-[18px] py-3">
|
|
<h4 className="text-[19px]">{g.name}</h4>
|
|
<span className="text-[13px] font-semibold text-ink-mute">{formatSize(g.widthCm, g.heightCm, unit)}</span>
|
|
{focused && (
|
|
<>
|
|
<span className="opacity-40">/</span>
|
|
<span className="text-sm font-bold text-accent-700">{focused.name}</span>
|
|
</>
|
|
)}
|
|
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
|
|
{seasons.options.length > 1 && <Seg options={seasons.options} value={seasons.value} onChange={seasons.select} label="Season" optionClassName="px-[13px]" />}
|
|
<Button icon="undo-2" className="gap-[7px]" disabled={!canEdit || !undoLast.canUndo} onClick={undoLast.undoLast} title={undoLast.target ? `Undo: ${undoLast.target.summary}` : 'Nothing to undo'}>
|
|
Undo
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
{banner && <div className="bg-accent-2-200 px-[18px] py-2 text-[13px] font-semibold text-accent-2-800">{banner}</div>}
|
|
<div className="relative min-h-0 flex-1">
|
|
{canvas}
|
|
<div className="elev-sm absolute bottom-3 right-3 flex gap-1 rounded-full border border-divider bg-surface p-1">
|
|
<IconButton label="Zoom out" icon="minus" iconSize={14} variant="plain" size={30} onClick={() => canvasRef.current?.zoomOut()} />
|
|
<IconButton label="Fit" icon="maximize" iconSize={14} variant="plain" size={30} onClick={() => canvasRef.current?.zoomFit()} />
|
|
<IconButton label="Zoom in" icon="plus" iconSize={14} variant="plain" size={30} onClick={() => canvasRef.current?.zoomIn()} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="panel flex min-h-0 flex-col overflow-hidden">
|
|
<div className="flex gap-1 px-2.5 pt-2.5" role="tablist">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={cn('flex-1 rounded-full border-0 px-1 py-2 text-[12.5px] font-bold', tab === t.id ? 'bg-accent-200 text-accent-800' : 'text-ink-soft hover:text-text')}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4">
|
|
{tab === 'toolkit' && toolkit}
|
|
{tab === 'plot' && plot}
|
|
{tab === 'journal' && journal}
|
|
{tab === 'history' && <HistoryTab canEdit={canEdit} undoLast={undoLast} />}
|
|
{tab === 'chat' && assistant}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{dialogs}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function chipClass(armed: boolean): string {
|
|
return cn(
|
|
'flex min-h-11 flex-none items-center gap-2 rounded-full border px-3.5 py-2.5',
|
|
armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-neutral-100',
|
|
)
|
|
}
|