diff --git a/web/src/editor/SeedTray.tsx b/web/src/editor/SeedTray.tsx new file mode 100644 index 0000000..cbfe6f1 --- /dev/null +++ b/web/src/editor/SeedTray.tsx @@ -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 ( +
+ {trayPlants.map((p) => { + const active = p.id === armedPlantId + return ( + + + + + ) + })} + +
+ ) +} diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index e97b0be..a8da090 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -3,11 +3,12 @@ // PATCH with the row version; a 409 rolls back to the server's current row and // toasts (see DESIGN § Sync). +import { useCallback } from 'react' import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { z } from 'zod' import { ApiError, api } from './api' import { gardenSchema } from './gardens' -import { plantSchema } from './plants' +import { plantSchema, type Plant } from './plants' import { serverPlantingSchema, type ServerPlanting } from './plantings' import { toast } from '@/components/ui/toast' import type { EditorObject, ObjectShapeKind } from '@/editor/types' @@ -77,6 +78,24 @@ export function useGardenFull(gardenId: number) { 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 ------------------------------------------------------------- export interface ObjectCreate { diff --git a/web/src/lib/seedTray.ts b/web/src/lib/seedTray.ts new file mode 100644 index 0000000..8db0347 --- /dev/null +++ b/web/src/lib/seedTray.ts @@ -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(() => 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 } +} diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index e688beb..77ef159 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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,10 +236,30 @@ 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) + } + + // 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 - const plant = plantsById.get(plantId) - if (!plant) return + ensurePlant(plant) + addToTray(plant) if (picker === 'change' && selectedPlop) { updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id }) } else { @@ -268,26 +293,33 @@ export function GardenEditorPage() { {objectDisplayName(focusedObject)} {canEdit && - (!focusedObject.plantable ? ( - Not plantable - ) : armedPlant ? ( - + (focusedObject.plantable ? ( + <> + setPicker('place')} + /> + {armedPlant && ( + + )} + {focusedPlops.length > 0 && ( + + )} + ) : ( - + Not plantable ))} - {canEdit && focusedObject.plantable && focusedPlops.length > 0 && ( - - )} )} @@ -297,7 +329,7 @@ export function GardenEditorPage() { Pick a shape from the palette, then tap the field to place your first bed. )} {canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && ( - Tap “+ Add plant”, then tap inside the bed to place plops. + Pick a plant from the tray, then tap inside the bed to place it. )} @@ -335,7 +367,7 @@ export function GardenEditorPage() { setPicker(null)} - onSelect={(p) => onPickPlant(p.id)} + onSelect={onPickPlant} /> )}