Fix plant placement + add a Seed Tray (#39)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 7m57s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m57s

The picker offers the full catalog, but onPickPlant resolved the choice
against the garden's *referenced* plants (the /full payload only carries
ListReferencedPlants). A not-yet-placed plant wasn't in that map, so the
handler returned early — before arming and before closing the picker, so
nothing happened and the picker stayed open. Use the full Plant the picker
already hands back, and splice it into the /full cache so its plops render
with an icon/color before the next refetch (useEnsurePlantInFull).

Add a per-garden Seed Tray: a curated row of plant chips in the focus
toolbar. Tap a chip to arm that plant for tap-to-place; picking from the
catalog adds it; ✕ removes it. Persisted in localStorage, mirroring the
PlantPicker's recent-plants mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-19 01:51:13 -04:00
co-authored by Claude Opus 4.8
parent 18e4a299c2
commit 75cdac2925
4 changed files with 207 additions and 20 deletions
+39 -19
View File
@@ -7,6 +7,7 @@ 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'
@@ -14,8 +15,10 @@ import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
import { toEditorObject, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
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')
@@ -43,6 +46,8 @@ export function GardenEditorPage() {
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.
@@ -231,14 +236,26 @@ export function GardenEditorPage() {
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : []
function onPickPlant(plantId: number) {
// 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)
}
// 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).
function onPickPlant(plant: Plant) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
const plant = plantsById.get(plantId)
if (!plant) return
addToTray(plant)
if (picker === 'change' && selectedPlop) {
ensurePlant(plant)
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else {
setArmedPlant(plant) // 'place' mode: arm for repeat placement
armPlant(plant) // 'place' mode: arm for repeat placement
}
setPicker(null)
}
@@ -267,18 +284,21 @@ export function GardenEditorPage() {
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{canEdit &&
(!focusedObject.plantable ? (
<span className="text-xs text-muted">Not plantable</span>
) : armedPlant ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Placing {armedPlant.icon} Done
</Button>
) : (
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
+ Add plant
</Button>
))}
{canEdit && !focusedObject.plantable && <span className="text-xs text-muted">Not plantable</span>}
{canEdit && focusedObject.plantable && (
<SeedTray
plants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
onArm={armPlant}
onRemove={removeFromTray}
onOpenPicker={() => setPicker('place')}
/>
)}
{canEdit && focusedObject.plantable && armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Done
</Button>
)}
{canEdit && focusedObject.plantable && focusedPlops.length > 0 && (
<Button
variant="ghost"
@@ -297,7 +317,7 @@ export function GardenEditorPage() {
<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">Tap + Add plant, then tap inside the bed to place plops.</EditorHint>
<EditorHint position="top">Pick a plant from the tray, then tap inside the bed to place it.</EditorHint>
)}
</div>
@@ -335,7 +355,7 @@ export function GardenEditorPage() {
<PlantPicker
unit={garden.unitPref}
onClose={() => setPicker(null)}
onSelect={(p) => onPickPlant(p.id)}
onSelect={(p) => onPickPlant(p)}
/>
)}