Address review: disarm on tray-remove, cap load, tidy
Build image / build-and-push (push) Successful in 9s
Build image / build-and-push (push) Successful in 9s
- Removing the armed plant from the tray now disarms it, so placement doesn't continue for a plant that's no longer shown. - loadIds honors TRAY_MAX even if localStorage was hand-edited. - Consolidate the repeated `canEdit && plantable` toolbar guards into one nested conditional (3-model finding). - Rename the hook's SeedTray interface to SeedTrayState (avoids colliding with the SeedTray component) and the component prop plants → trayPlants. - Hoist ensurePlant to one call in onPickPlant; pass onPickPlant directly as onSelect; use ASCII "+" for the tray chip; document the FIFO eviction. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -5,17 +5,17 @@ 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 + chip opens
|
||||
* the full catalog picker. See useSeedTray for persistence.
|
||||
* chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
|
||||
* opens the full catalog picker. See useSeedTray for persistence.
|
||||
*/
|
||||
export function SeedTray({
|
||||
plants,
|
||||
trayPlants,
|
||||
armedPlantId,
|
||||
onArm,
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: {
|
||||
plants: Plant[]
|
||||
trayPlants: Plant[]
|
||||
armedPlantId: number | null
|
||||
onArm: (plant: Plant) => void
|
||||
onRemove: (id: number) => void
|
||||
@@ -23,7 +23,7 @@ export function SeedTray({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{plants.map((p) => {
|
||||
{trayPlants.map((p) => {
|
||||
const active = p.id === armedPlantId
|
||||
return (
|
||||
<span
|
||||
@@ -59,7 +59,7 @@ export function SeedTray({
|
||||
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
|
||||
+ Plant
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ 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)) ?? '[]')
|
||||
return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
||||
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 []
|
||||
}
|
||||
@@ -30,17 +31,18 @@ function saveIds(gardenId: number, ids: number[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SeedTray {
|
||||
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). */
|
||||
/** 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): SeedTray {
|
||||
export function useSeedTray(gardenId: number): SeedTrayState {
|
||||
const plants = usePlants()
|
||||
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
||||
|
||||
|
||||
@@ -244,18 +244,26 @@ export function GardenEditorPage() {
|
||||
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).
|
||||
// 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) {
|
||||
ensurePlant(plant)
|
||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||
} else {
|
||||
armPlant(plant) // 'place' mode: arm for repeat placement
|
||||
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
||||
}
|
||||
setPicker(null)
|
||||
}
|
||||
@@ -284,30 +292,34 @@ 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>}
|
||||
{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"
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||
onClick={() => setClearing(true)}
|
||||
>
|
||||
Clear ({focusedPlops.length})
|
||||
</Button>
|
||||
)}
|
||||
{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} />
|
||||
@@ -355,7 +367,7 @@ export function GardenEditorPage() {
|
||||
<PlantPicker
|
||||
unit={garden.unitPref}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(p) => onPickPlant(p)}
|
||||
onSelect={onPickPlant}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user