Build image / build-and-push (push) Successful in 15s
The best catch was one I'd have missed: the store already has resetTransient(), a single list of ephemeral editor state, and the new railTab didn't join it — so the public garden page cleared everything except the rail. Rather than adding railTab in two places, the editor page now calls resetTransient() instead of maintaining its own parallel list, which is what let them drift in the first place. The rail auto-switch keyed off a "something is selected" boolean, so it fired on the transition into having a selection and never again. Select a bed, switch to History, select a different bed — the boolean never changed, so the inspector never came forward, breaking the constraint the whole design was built around. Now keyed off the selected ids. Closing the rail cleared the canvas selection regardless of which tab you were on, so dismissing History deselected your bed. Only the inspector is about the selection, so only closing it deselects. describeUndo said "Undone." when the server reported a complete no-op (200 with a null change set, reachable by undoing a creation whose object is already gone). That reports work that didn't happen; it now says so. relativeTime rounded, so 18 hours ago read as "yesterday" and 90 minutes as "2h ago" — rounding up into the next unit reads as a bigger gap than actually elapsed. Floors throughout. Clock skew that puts a just-written entry slightly in the future now reads "just now" rather than falling through the negative. A failed "Load older" was swallowed entirely: the button simply stopped working. It now reports the error, while the top-level alert only takes over when there is nothing on screen at all. changeCountSchema took any number. Counts come from COUNT(*) and can only be non-negative integers, so constraining them makes bad data fail loudly instead of quietly hiding an Undo button (totalChanges gates it) or rendering "1.5 beds changed". Dropped useUndo's unused isPending — the per-change-set outcome already carries it, and per-row is right anyway. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
432 lines
18 KiB
TypeScript
432 lines
18 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 { 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 { objectDisplayName } from '@/editor/kinds'
|
|
import { useEditorStore } from '@/editor/store'
|
|
import type { EditorGarden } from '@/editor/types'
|
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
|
import { useMe } from '@/lib/auth'
|
|
import { toEditorObject, useEnsurePlantInFull, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
|
import { toEditorPlanting } from '@/lib/plantings'
|
|
import type { Plant } from '@/lib/plants'
|
|
import { useSeedTray } from '@/lib/seedTray'
|
|
import { usePageTitle } from '@/lib/usePageTitle'
|
|
|
|
const routeApi = getRouteApi('/gardens/$gardenId')
|
|
|
|
export function GardenEditorPage() {
|
|
const { gardenId } = routeApi.useParams()
|
|
const gid = Number(gardenId)
|
|
const { focus } = routeApi.useSearch()
|
|
const navigate = routeApi.useNavigate()
|
|
const full = useGardenFull(gid)
|
|
const me = useMe()
|
|
usePageTitle(full.data?.garden.name ?? 'Garden')
|
|
|
|
const selectedId = useEditorStore((s) => s.selectedId)
|
|
const select = useEditorStore((s) => s.select)
|
|
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
|
const selectPlanting = useEditorStore((s) => s.selectPlanting)
|
|
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
|
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
|
const railTab = useEditorStore((s) => s.railTab)
|
|
const setRailTab = useEditorStore((s) => s.setRailTab)
|
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
|
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
|
|
|
const updatePlanting = useUpdatePlanting(gid)
|
|
const updateObject = useUpdateObject(gid)
|
|
const ensurePlant = useEnsurePlantInFull(gid)
|
|
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
|
|
|
// 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
|
|
const canEdit = gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor')
|
|
const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id
|
|
|
|
// Latest values for the mount-once nudge keydown handler to read, so its effect
|
|
// never re-subscribes (which would cancel a pending debounced commit).
|
|
const nudgeCtx = useRef({ canEdit, objects, plantings, updateObject, updatePlanting })
|
|
nudgeCtx.current = { canEdit, objects, plantings, updateObject, updatePlanting }
|
|
|
|
// Adopt the URL's focus and clear transient editor state on entering/switching
|
|
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
|
|
// so URL syncs below don't retrigger a full reset.
|
|
useEffect(() => {
|
|
// resetTransient is the single list of ephemeral editor state, so new state
|
|
// (the rail tab did exactly this) can't be forgotten here.
|
|
const clear = () => useEditorStore.getState().resetTransient()
|
|
clear()
|
|
setFocusedObject(focus ?? null)
|
|
return () => {
|
|
clear()
|
|
setFocusedObject(null)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [gid])
|
|
|
|
// Mirror focusedObjectId → ?focus so focus mode is deep-linkable and survives
|
|
// reload. Declared after the reset effect so the initial adopt wins.
|
|
useEffect(() => {
|
|
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
|
|
}, [focusedObjectId, navigate])
|
|
|
|
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
|
|
// focus mode so the canvas isn't stuck dimmed with no way out.
|
|
useEffect(() => {
|
|
if (focusedObjectId != null && full.data && !objects.some((o) => o.id === focusedObjectId)) {
|
|
setFocusedObject(null)
|
|
}
|
|
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
|
|
|
// Selecting anything lands you in the inspector without a click — the rail
|
|
// must never be something you operate before you can edit. Keyed off the
|
|
// selected ids rather than a "something is selected" boolean, so selecting a
|
|
// DIFFERENT object while History is open still brings the inspector forward.
|
|
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
|
// that's where you were, since those aren't about the selection.
|
|
useEffect(() => {
|
|
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector')
|
|
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
|
|
}, [selectedId, selectedPlantingId, setRailTab])
|
|
|
|
const exitFocus = () => {
|
|
setFocusedObject(null)
|
|
setArmedPlant(null)
|
|
select(null)
|
|
selectPlanting(null)
|
|
}
|
|
|
|
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key !== 'Escape') return
|
|
const s = useEditorStore.getState()
|
|
if (s.armedPlant) s.setArmedPlant(null)
|
|
else if (s.selectedPlantingId != null) s.selectPlanting(null)
|
|
else if (s.focusedObjectId != null) exitFocus()
|
|
else if (s.selectedId != null) s.select(null)
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift =
|
|
// 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam.
|
|
// Plops nudge in their object's local frame and clamp to its (local) bounds.
|
|
// Mounted once, reading live values from nudgeCtx so a data refetch can't
|
|
// re-subscribe and cancel a pending commit; the pending commit is flushed on
|
|
// unmount, and a fire only commits if its live value is still present (a drag
|
|
// that cleared it already committed its own PATCH).
|
|
useEffect(() => {
|
|
const DIRS: Record<string, [number, number]> = {
|
|
ArrowUp: [0, -1],
|
|
ArrowDown: [0, 1],
|
|
ArrowLeft: [-1, 0],
|
|
ArrowRight: [1, 0],
|
|
}
|
|
const commitLater = (fire: () => void) => {
|
|
nudgeFire.current = fire
|
|
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
|
nudgeTimer.current = window.setTimeout(() => {
|
|
nudgeTimer.current = null
|
|
const fn = nudgeFire.current
|
|
nudgeFire.current = null
|
|
fn?.()
|
|
}, 400)
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
|
|
nudgeCtx.current
|
|
if (!canNudge) return
|
|
const dir = DIRS[e.key]
|
|
if (!dir) return
|
|
const el = document.activeElement
|
|
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
|
|
const s = useEditorStore.getState()
|
|
if (s.objectDragging) return // don't fight an active pointer drag
|
|
const step = e.shiftKey ? 10 : 1
|
|
if (s.selectedId != null) {
|
|
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
|
|
if (!base) return
|
|
e.preventDefault()
|
|
s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step })
|
|
commitLater(() => {
|
|
const live = useEditorStore.getState().liveObject
|
|
if (live?.id !== s.selectedId) return // a drag cleared it and committed
|
|
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
|
useEditorStore.getState().setLiveObject(null)
|
|
})
|
|
} else if (s.selectedPlantingId != null) {
|
|
const base =
|
|
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
|
|
if (!base) return
|
|
e.preventDefault()
|
|
const obj = objs.find((o) => o.id === base.objectId)
|
|
let nx = base.xCm + dir[0] * step
|
|
let ny = base.yCm + dir[1] * step
|
|
if (obj) {
|
|
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
|
|
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
|
|
}
|
|
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
|
|
commitLater(() => {
|
|
const live = useEditorStore.getState().livePlanting
|
|
if (live?.id !== s.selectedPlantingId) return
|
|
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
|
useEditorStore.getState().setLivePlanting(null)
|
|
})
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey)
|
|
if (nudgeTimer.current != null) {
|
|
window.clearTimeout(nudgeTimer.current)
|
|
nudgeTimer.current = null
|
|
}
|
|
const fn = nudgeFire.current // flush a pending move so it isn't lost
|
|
nudgeFire.current = null
|
|
fn?.()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
|
if (full.isError)
|
|
return (
|
|
<div className="p-6">
|
|
<Alert>Could not load this garden.</Alert>
|
|
</div>
|
|
)
|
|
|
|
const g = full.data.garden
|
|
const garden: EditorGarden = {
|
|
id: g.id,
|
|
name: g.name,
|
|
widthCm: g.widthCm,
|
|
heightCm: g.heightCm,
|
|
unitPref: g.unitPref,
|
|
gridSizeCm: g.gridSizeCm,
|
|
snapToGrid: g.snapToGrid,
|
|
}
|
|
|
|
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
|
|
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
|
|
// Committed selected plop; PlopInspector applies live drag geometry itself.
|
|
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
|
const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : []
|
|
|
|
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
|
|
// garden's /full payload, so make sure the chosen one is in the cache first —
|
|
// otherwise its placed plops render without an icon/color until a refetch.
|
|
function armPlant(plant: Plant) {
|
|
ensurePlant(plant)
|
|
setArmedPlant(plant)
|
|
}
|
|
|
|
// 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) {
|
|
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) // '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)
|
|
}}
|
|
/>
|
|
) : 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: 'history',
|
|
label: 'History',
|
|
render: () => <HistoryPanel gardenId={gid} canEdit={canEdit} />,
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
|
<div className="shrink-0 md:w-40">
|
|
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
|
{garden.name}
|
|
</h1>
|
|
{isOwner && (
|
|
<Button variant="ghost" className="mb-2 w-full text-sm" onClick={() => setSharing(true)}>
|
|
Share
|
|
</Button>
|
|
)}
|
|
{!canEdit && (
|
|
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
|
|
)}
|
|
{focusedObjectId == null && canEdit && <Palette />}
|
|
<Button
|
|
variant="ghost"
|
|
className="mt-2 w-full text-sm"
|
|
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
|
|
>
|
|
History
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="relative min-h-0 flex-1">
|
|
{focusedObject && (
|
|
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
|
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
|
← {garden.name}
|
|
</button>
|
|
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
|
|
{canEdit &&
|
|
(focusedObject.plantable ? (
|
|
<>
|
|
<SeedTray
|
|
trayPlants={trayPlants}
|
|
armedPlantId={armedPlant?.id ?? null}
|
|
onArm={armPlant}
|
|
onRemove={removeFromTrayAndDisarm}
|
|
onOpenPicker={() => setPicker('place')}
|
|
/>
|
|
{armedPlant && (
|
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
|
Done
|
|
</Button>
|
|
)}
|
|
{focusedPlops.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
|
onClick={() => setClearing(true)}
|
|
>
|
|
Clear ({focusedPlops.length})
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-muted">Not plantable</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
|
|
|
{/* Empty-state hints (non-interactive overlays). */}
|
|
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
|
<EditorHint>Pick a shape from the palette, then tap the field to place your first bed.</EditorHint>
|
|
)}
|
|
{canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && (
|
|
<EditorHint position="top">Pick a plant from the tray, then tap inside the bed to place it.</EditorHint>
|
|
)}
|
|
</div>
|
|
|
|
{railTab && (
|
|
<EditorRail
|
|
tabs={railTabs}
|
|
activeId={railTab}
|
|
onActivate={setRailTab}
|
|
onClose={() => {
|
|
// Only the inspector is *about* the selection, so only closing it
|
|
// deselects; dismissing History leaves the canvas as you had it.
|
|
if (railTab === 'inspector') {
|
|
select(null)
|
|
selectPlanting(null)
|
|
}
|
|
setRailTab(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{picker && (
|
|
<PlantPicker
|
|
unit={garden.unitPref}
|
|
onClose={() => setPicker(null)}
|
|
onSelect={onPickPlant}
|
|
/>
|
|
)}
|
|
|
|
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
|
|
|
{clearing && focusedObject && (
|
|
<ClearBedModal
|
|
objectName={objectDisplayName(focusedObject)}
|
|
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
|
|
gardenId={gid}
|
|
onClose={() => setClearing(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|