Fix plant placement + add a Seed Tray (#39) (#42)
Build image / build-and-push (push) Successful in 21s
Build image / build-and-push (push) Successful in 21s
Use the full Plant the picker returns (fixes the silent placement failure) and add a per-garden Seed Tray for quick repeat placing. Review fixes: disarm on tray-remove, cap load, consolidate toolbar guards, rename interface, tidy. Closes #39. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #42.
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Seed Tray strip in the focus-mode toolbar: a row of the garden's
|
||||||
|
* kept-at-hand plants. Tap a chip to arm that plant for tap-to-place (the armed
|
||||||
|
* chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
|
||||||
|
* opens the full catalog picker. See useSeedTray for persistence.
|
||||||
|
*/
|
||||||
|
export function SeedTray({
|
||||||
|
trayPlants,
|
||||||
|
armedPlantId,
|
||||||
|
onArm,
|
||||||
|
onRemove,
|
||||||
|
onOpenPicker,
|
||||||
|
}: {
|
||||||
|
trayPlants: Plant[]
|
||||||
|
armedPlantId: number | null
|
||||||
|
onArm: (plant: Plant) => void
|
||||||
|
onRemove: (id: number) => void
|
||||||
|
onOpenPicker: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{trayPlants.map((p) => {
|
||||||
|
const active = p.id === armedPlantId
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border text-xs transition-colors',
|
||||||
|
active ? 'border-accent bg-accent/10 text-accent-strong' : 'border-border bg-surface text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onArm(p)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
|
||||||
|
className="flex items-center gap-1.5 rounded-full py-1 pl-1.5 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
||||||
|
<span className="max-w-[6rem] truncate font-medium">{p.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(p.id)}
|
||||||
|
aria-label={`Remove ${p.name} from tray`}
|
||||||
|
className="rounded-full px-1.5 py-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenPicker}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs font-medium text-muted outline-none transition-colors hover:border-accent hover:text-accent-strong focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
+ Plant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+20
-1
@@ -3,11 +3,12 @@
|
|||||||
// PATCH with the row version; a 409 rolls back to the server's current row and
|
// PATCH with the row version; a 409 rolls back to the server's current row and
|
||||||
// toasts (see DESIGN § Sync).
|
// toasts (see DESIGN § Sync).
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { ApiError, api } from './api'
|
import { ApiError, api } from './api'
|
||||||
import { gardenSchema } from './gardens'
|
import { gardenSchema } from './gardens'
|
||||||
import { plantSchema } from './plants'
|
import { plantSchema, type Plant } from './plants'
|
||||||
import { serverPlantingSchema, type ServerPlanting } from './plantings'
|
import { serverPlantingSchema, type ServerPlanting } from './plantings'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
|
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
|
||||||
@@ -77,6 +78,24 @@ export function useGardenFull(gardenId: number) {
|
|||||||
return useQuery(gardenFullQueryOptions(gardenId))
|
return useQuery(gardenFullQueryOptions(gardenId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a plant is present in the /full cache's `plants` list. The full payload
|
||||||
|
* carries only the garden's *referenced* plants (ListReferencedPlants), so a
|
||||||
|
* plant chosen from the full catalog but not yet placed here is absent — its
|
||||||
|
* plops would render without an icon/color until the next refetch. Call this
|
||||||
|
* when arming/placing such a plant so it renders immediately. No-op if present.
|
||||||
|
*/
|
||||||
|
export function useEnsurePlantInFull(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useCallback(
|
||||||
|
(plant: Plant) =>
|
||||||
|
patchFullCache(qc, gardenId, (full) =>
|
||||||
|
full.plants.some((p) => p.id === plant.id) ? full : { ...full, plants: [...full.plants, plant] },
|
||||||
|
),
|
||||||
|
[qc, gardenId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- mutations -------------------------------------------------------------
|
// --- mutations -------------------------------------------------------------
|
||||||
|
|
||||||
export interface ObjectCreate {
|
export interface ObjectCreate {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// The Seed Tray: a small, deliberately-curated set of plants a gardener keeps at
|
||||||
|
// hand while planting a bed, so repeat placement is a single tap instead of a
|
||||||
|
// trip through the full catalog. It's a per-garden convenience, persisted in
|
||||||
|
// localStorage (same rationale as the PlantPicker's "recent plants"): a nicety,
|
||||||
|
// not authoritative state, so a quota/availability failure is swallowed.
|
||||||
|
//
|
||||||
|
// Only plant ids are stored; they resolve against the live catalog on read, so a
|
||||||
|
// renamed plant stays current and a deleted one silently drops out of the tray.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { usePlants, type Plant } from './plants'
|
||||||
|
|
||||||
|
const KEY = (gardenId: number) => `pansy:seed-tray:${gardenId}`
|
||||||
|
const TRAY_MAX = 24 // a working set, not a catalog; caps pathological growth
|
||||||
|
|
||||||
|
function loadIds(gardenId: number): number[] {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]')
|
||||||
|
const ids = Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
||||||
|
return ids.slice(-TRAY_MAX) // honor the cap even if storage was hand-edited
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveIds(gardenId: number, ids: number[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY(gardenId), JSON.stringify(ids))
|
||||||
|
} catch {
|
||||||
|
// The tray is a convenience; ignore quota/availability failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeedTrayState {
|
||||||
|
/** Tray plants in insertion order, resolved against the live catalog. */
|
||||||
|
trayPlants: Plant[]
|
||||||
|
/** Add a plant to the end of the tray (no-op if already present; when the tray
|
||||||
|
* is full the oldest entry is evicted). */
|
||||||
|
add: (plant: Plant) => void
|
||||||
|
/** Remove a plant from the tray. */
|
||||||
|
remove: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-garden Seed Tray backed by localStorage and the plant catalog. */
|
||||||
|
export function useSeedTray(gardenId: number): SeedTrayState {
|
||||||
|
const plants = usePlants()
|
||||||
|
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
||||||
|
|
||||||
|
// Re-read when switching gardens (the route param changes without remounting).
|
||||||
|
useEffect(() => {
|
||||||
|
setIds(loadIds(gardenId))
|
||||||
|
}, [gardenId])
|
||||||
|
|
||||||
|
const byId = useMemo(() => new Map((plants.data ?? []).map((p) => [p.id, p])), [plants.data])
|
||||||
|
|
||||||
|
// Resolve ids → plants, dropping any no longer in the catalog; preserve order.
|
||||||
|
const trayPlants = useMemo(
|
||||||
|
() => ids.map((id) => byId.get(id)).filter((p): p is Plant => !!p),
|
||||||
|
[ids, byId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const add = useCallback(
|
||||||
|
(plant: Plant) =>
|
||||||
|
setIds((prev) => {
|
||||||
|
if (prev.includes(plant.id)) return prev
|
||||||
|
const next = [...prev, plant.id].slice(-TRAY_MAX)
|
||||||
|
saveIds(gardenId, next)
|
||||||
|
return next
|
||||||
|
}),
|
||||||
|
[gardenId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
(id: number) =>
|
||||||
|
setIds((prev) => {
|
||||||
|
const next = prev.filter((x) => x !== id)
|
||||||
|
saveIds(gardenId, next)
|
||||||
|
return next
|
||||||
|
}),
|
||||||
|
[gardenId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { trayPlants, add, remove }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Inspector } from '@/editor/Inspector'
|
|||||||
import { PlopInspector } from '@/editor/PlopInspector'
|
import { PlopInspector } from '@/editor/PlopInspector'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { Palette } from '@/editor/Palette'
|
import { Palette } from '@/editor/Palette'
|
||||||
|
import { SeedTray } from '@/editor/SeedTray'
|
||||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { EditorHint } from '@/editor/EditorHint'
|
import { EditorHint } from '@/editor/EditorHint'
|
||||||
import { objectDisplayName } from '@/editor/kinds'
|
import { objectDisplayName } from '@/editor/kinds'
|
||||||
@@ -14,8 +15,10 @@ import { useEditorStore } from '@/editor/store'
|
|||||||
import type { EditorGarden } from '@/editor/types'
|
import type { EditorGarden } from '@/editor/types'
|
||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { useMe } from '@/lib/auth'
|
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 { toEditorPlanting } from '@/lib/plantings'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||||
@@ -43,6 +46,8 @@ export function GardenEditorPage() {
|
|||||||
|
|
||||||
const updatePlanting = useUpdatePlanting(gid)
|
const updatePlanting = useUpdatePlanting(gid)
|
||||||
const updateObject = useUpdateObject(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;
|
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
||||||
// 'change' swaps the selected plop's plant.
|
// 'change' swaps the selected plop's plant.
|
||||||
@@ -231,10 +236,30 @@ export function GardenEditorPage() {
|
|||||||
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
||||||
const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : []
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||||
const plant = plantsById.get(plantId)
|
ensurePlant(plant)
|
||||||
if (!plant) return
|
addToTray(plant)
|
||||||
if (picker === 'change' && selectedPlop) {
|
if (picker === 'change' && selectedPlop) {
|
||||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||||
} else {
|
} else {
|
||||||
@@ -268,26 +293,33 @@ export function GardenEditorPage() {
|
|||||||
</button>
|
</button>
|
||||||
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
|
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
|
||||||
{canEdit &&
|
{canEdit &&
|
||||||
(!focusedObject.plantable ? (
|
(focusedObject.plantable ? (
|
||||||
<span className="text-xs text-muted">Not plantable</span>
|
<>
|
||||||
) : armedPlant ? (
|
<SeedTray
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
trayPlants={trayPlants}
|
||||||
Placing {armedPlant.icon} — Done
|
armedPlantId={armedPlant?.id ?? null}
|
||||||
</Button>
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
|
<span className="text-xs text-muted">Not plantable</span>
|
||||||
+ Add plant
|
|
||||||
</Button>
|
|
||||||
))}
|
))}
|
||||||
{canEdit && focusedObject.plantable && 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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||||
@@ -297,7 +329,7 @@ export function GardenEditorPage() {
|
|||||||
<EditorHint>Pick a shape from the palette, then tap the field to place your first bed.</EditorHint>
|
<EditorHint>Pick a shape from the palette, then tap the field to place your first bed.</EditorHint>
|
||||||
)}
|
)}
|
||||||
{canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && (
|
{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>
|
</div>
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ export function GardenEditorPage() {
|
|||||||
<PlantPicker
|
<PlantPicker
|
||||||
unit={garden.unitPref}
|
unit={garden.unitPref}
|
||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
onSelect={(p) => onPickPlant(p.id)}
|
onSelect={onPickPlant}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user