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
|
* 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
|
* 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
|
* chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
|
||||||
* the full catalog picker. See useSeedTray for persistence.
|
* opens the full catalog picker. See useSeedTray for persistence.
|
||||||
*/
|
*/
|
||||||
export function SeedTray({
|
export function SeedTray({
|
||||||
plants,
|
trayPlants,
|
||||||
armedPlantId,
|
armedPlantId,
|
||||||
onArm,
|
onArm,
|
||||||
onRemove,
|
onRemove,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: {
|
}: {
|
||||||
plants: Plant[]
|
trayPlants: Plant[]
|
||||||
armedPlantId: number | null
|
armedPlantId: number | null
|
||||||
onArm: (plant: Plant) => void
|
onArm: (plant: Plant) => void
|
||||||
onRemove: (id: number) => void
|
onRemove: (id: number) => void
|
||||||
@@ -23,7 +23,7 @@ export function SeedTray({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{plants.map((p) => {
|
{trayPlants.map((p) => {
|
||||||
const active = p.id === armedPlantId
|
const active = p.id === armedPlantId
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -59,7 +59,7 @@ export function SeedTray({
|
|||||||
onClick={onOpenPicker}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const TRAY_MAX = 24 // a working set, not a catalog; caps pathological growth
|
|||||||
function loadIds(gardenId: number): number[] {
|
function loadIds(gardenId: number): number[] {
|
||||||
try {
|
try {
|
||||||
const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]')
|
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 {
|
} catch {
|
||||||
return []
|
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. */
|
/** Tray plants in insertion order, resolved against the live catalog. */
|
||||||
trayPlants: Plant[]
|
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
|
add: (plant: Plant) => void
|
||||||
/** Remove a plant from the tray. */
|
/** Remove a plant from the tray. */
|
||||||
remove: (id: number) => void
|
remove: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-garden Seed Tray backed by localStorage and the plant catalog. */
|
/** 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 plants = usePlants()
|
||||||
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
||||||
|
|
||||||
|
|||||||
@@ -244,18 +244,26 @@ export function GardenEditorPage() {
|
|||||||
setArmedPlant(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
|
// 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 —
|
// 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
|
// 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) {
|
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
|
||||||
|
ensurePlant(plant)
|
||||||
addToTray(plant)
|
addToTray(plant)
|
||||||
if (picker === 'change' && selectedPlop) {
|
if (picker === 'change' && selectedPlop) {
|
||||||
ensurePlant(plant)
|
|
||||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||||
} else {
|
} else {
|
||||||
armPlant(plant) // 'place' mode: arm for repeat placement
|
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
||||||
}
|
}
|
||||||
setPicker(null)
|
setPicker(null)
|
||||||
}
|
}
|
||||||
@@ -284,30 +292,34 @@ export function GardenEditorPage() {
|
|||||||
← {garden.name}
|
← {garden.name}
|
||||||
</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 && !focusedObject.plantable && <span className="text-xs text-muted">Not plantable</span>}
|
{canEdit &&
|
||||||
{canEdit && focusedObject.plantable && (
|
(focusedObject.plantable ? (
|
||||||
<SeedTray
|
<>
|
||||||
plants={trayPlants}
|
<SeedTray
|
||||||
armedPlantId={armedPlant?.id ?? null}
|
trayPlants={trayPlants}
|
||||||
onArm={armPlant}
|
armedPlantId={armedPlant?.id ?? null}
|
||||||
onRemove={removeFromTray}
|
onArm={armPlant}
|
||||||
onOpenPicker={() => setPicker('place')}
|
onRemove={removeFromTrayAndDisarm}
|
||||||
/>
|
onOpenPicker={() => setPicker('place')}
|
||||||
)}
|
/>
|
||||||
{canEdit && focusedObject.plantable && armedPlant && (
|
{armedPlant && (
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canEdit && focusedObject.plantable && focusedPlops.length > 0 && (
|
{focusedPlops.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||||
onClick={() => setClearing(true)}
|
onClick={() => setClearing(true)}
|
||||||
>
|
>
|
||||||
Clear ({focusedPlops.length})
|
Clear ({focusedPlops.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted">Not plantable</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||||
@@ -355,7 +367,7 @@ export function GardenEditorPage() {
|
|||||||
<PlantPicker
|
<PlantPicker
|
||||||
unit={garden.unitPref}
|
unit={garden.unitPref}
|
||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
onSelect={(p) => onPickPlant(p)}
|
onSelect={onPickPlant}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user