Plants mode: recent-planted quick strip + clump/rows fill (#100)
Two things Steve asked for, both in the #99 Plants mode: - A "Recent" strip of the plants most recently planted IN THIS GARDEN (recentlyPlantedIds — derived from actual plantings, newest first, unique; NOT the manual localStorage tray), as tap-to-arm chips. So re-planting "more of the same" is one tap, no picker. Hidden until something's planted. - A clump/rows fill control (FillControl), shown once a plant is armed: pick a layout, "Fill bed", and it runs POST /objects/:id/fill region=all with the chosen layout — the #77 grid/clump fill the UI could NOT reach before (it was agent/REST only). Defaults to rows (a real planting). useFillObject mirrors useClearObject: one request, invalidate /full. Both live in the shared PlantPlacementTools, so desktop's focus toolbar and the mobile Plants strip get them identically. Fill is capped/validated server-side (#95) and covered spots are skipped, so re-filling is safe. Verified live at 390px: the Recent strip shows this garden's tomato/lettuce/ garlic; arming a chip reveals Clump|Rows + Fill bed; fill fires cleanly. tsc + vitest (95, incl. recentlyPlantedIds) + build green. DESIGN updated. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -129,7 +129,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
|||||||
|
|
||||||
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
||||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
|
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
|
||||||
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (the seed tray, shown once a bed is focused; focusing a bed puts you in this mode), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
|
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
|
||||||
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
||||||
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
|
||||||
|
* so re-placing "more of the same" is one tap instead of a trip through the
|
||||||
|
* catalog or the manual tray. Derived from actual plantings (see
|
||||||
|
* recentlyPlantedIds), newest first; renders nothing until something's planted.
|
||||||
|
* Tap a chip to arm it for placement (the armed one is highlighted).
|
||||||
|
*/
|
||||||
|
export function RecentPlants({
|
||||||
|
plants,
|
||||||
|
armedPlantId,
|
||||||
|
onArm,
|
||||||
|
}: {
|
||||||
|
plants: Plant[]
|
||||||
|
armedPlantId: number | null
|
||||||
|
onArm: (plant: Plant) => void
|
||||||
|
}) {
|
||||||
|
if (plants.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||||
|
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
|
||||||
|
{plants.map((p) => {
|
||||||
|
const active = p.id === armedPlantId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onArm(p)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center gap-1.5 rounded-full border py-1 pl-1.5 pr-2.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||||
|
active
|
||||||
|
? 'border-accent bg-accent/10 text-accent-strong'
|
||||||
|
: 'border-border bg-surface text-fg hover:border-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
||||||
|
<span className="max-w-[6rem] truncate">{p.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -329,6 +329,38 @@ export function useUpdatePlanting(gardenId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearResultSchema = z.object({ cleared: z.number() })
|
const clearResultSchema = z.object({ cleared: z.number() })
|
||||||
|
const fillResultSchema = z.object({ created: z.number() })
|
||||||
|
|
||||||
|
/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
|
||||||
|
* coverage, or a grid of individual plants at true spacing you could plant from.
|
||||||
|
* Passed straight through to the server's `layout` field. */
|
||||||
|
export type FillLayout = 'clump' | 'grid'
|
||||||
|
|
||||||
|
/** Fill a whole plantable object with one plant at the chosen layout, via the
|
||||||
|
* same `POST /objects/:id/fill` the agent uses (region "all"). The response is
|
||||||
|
* just a count; invalidate rather than optimistically splice a hex lattice we'd
|
||||||
|
* have to recompute client-side. Returns how many plops it created. */
|
||||||
|
export function useFillObject(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
objectId,
|
||||||
|
plantId,
|
||||||
|
layout,
|
||||||
|
}: {
|
||||||
|
objectId: number
|
||||||
|
plantId: number
|
||||||
|
layout: FillLayout
|
||||||
|
}): Promise<number> => {
|
||||||
|
const res = fillResultSchema.parse(
|
||||||
|
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
|
||||||
|
)
|
||||||
|
return res.created
|
||||||
|
},
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||||
|
onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear a bed: soft-remove every active plop in an object (#82).
|
/** Clear a bed: soft-remove every active plop in an object (#82).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { computeDerivedCount, effectiveCount } from './plantings'
|
import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
|
||||||
|
|
||||||
|
function plop(over: Partial<EditorPlanting>): EditorPlanting {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
objectId: 1,
|
||||||
|
plantId: 1,
|
||||||
|
xCm: 0,
|
||||||
|
yCm: 0,
|
||||||
|
radiusCm: 5,
|
||||||
|
count: null,
|
||||||
|
derivedCount: 1,
|
||||||
|
label: null,
|
||||||
|
plantedAt: null,
|
||||||
|
version: 1,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recentlyPlantedIds', () => {
|
||||||
|
it('returns unique plant ids, newest planted first', () => {
|
||||||
|
const got = recentlyPlantedIds([
|
||||||
|
plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
|
||||||
|
plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
|
||||||
|
plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
|
||||||
|
])
|
||||||
|
// 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
|
||||||
|
expect(got).toEqual([20, 10])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
|
||||||
|
const got = recentlyPlantedIds([
|
||||||
|
plop({ id: 5, plantId: 30, plantedAt: null }),
|
||||||
|
plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
|
||||||
|
plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
|
||||||
|
])
|
||||||
|
expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty for no plantings', () => {
|
||||||
|
expect(recentlyPlantedIds([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('computeDerivedCount', () => {
|
describe('computeDerivedCount', () => {
|
||||||
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
|
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
|
|||||||
return p.count ?? p.derivedCount
|
return p.count ?? p.derivedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plant ids a garden has been planted with, most recent first and de-duplicated —
|
||||||
|
* the "what have I actually been planting here" quick list (#100). Ordered by
|
||||||
|
* plantedAt (a plop with no date sorts last), then by id so newer plops win a
|
||||||
|
* same-day tie. The caller resolves the ids to plants against the catalog.
|
||||||
|
*/
|
||||||
|
export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
|
||||||
|
const sorted = [...plantings].sort(
|
||||||
|
(a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
|
||||||
|
)
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const ids: number[] = []
|
||||||
|
for (const p of sorted) {
|
||||||
|
if (!seen.has(p.plantId)) {
|
||||||
|
seen.add(p.plantId)
|
||||||
|
ids.push(p.plantId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
/** Client-side mirror of the server's derived-count formula, for live display
|
/** Client-side mirror of the server's derived-count formula, for live display
|
||||||
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
|
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
|
||||||
* spacing²)), capped like the server. */
|
* spacing²)), capped like the server. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { SeedTray } from '@/editor/SeedTray'
|
||||||
|
import { RecentPlants } from '@/editor/RecentPlants'
|
||||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { EditorHint } from '@/editor/EditorHint'
|
import { EditorHint } from '@/editor/EditorHint'
|
||||||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||||
@@ -24,13 +25,15 @@ import { useMe } from '@/lib/auth'
|
|||||||
import {
|
import {
|
||||||
toEditorObject,
|
toEditorObject,
|
||||||
useEnsurePlantInFull,
|
useEnsurePlantInFull,
|
||||||
|
useFillObject,
|
||||||
useGardenFull,
|
useGardenFull,
|
||||||
useGardenSeason,
|
useGardenSeason,
|
||||||
useGardenYears,
|
useGardenYears,
|
||||||
useUpdateObject,
|
useUpdateObject,
|
||||||
useUpdatePlanting,
|
useUpdatePlanting,
|
||||||
|
type FillLayout,
|
||||||
} from '@/lib/objects'
|
} from '@/lib/objects'
|
||||||
import { toEditorPlanting } from '@/lib/plantings'
|
import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
import { useCapabilities } from '@/lib/agent'
|
import { useCapabilities } from '@/lib/agent'
|
||||||
import { useJournalCounts } from '@/lib/journal'
|
import { useJournalCounts } from '@/lib/journal'
|
||||||
@@ -88,6 +91,7 @@ export function GardenEditorPage() {
|
|||||||
const setMode = useEditorStore((s) => s.setMode)
|
const setMode = useEditorStore((s) => s.setMode)
|
||||||
|
|
||||||
const updatePlanting = useUpdatePlanting(gid)
|
const updatePlanting = useUpdatePlanting(gid)
|
||||||
|
const fillObject = useFillObject(gid)
|
||||||
const updateObject = useUpdateObject(gid)
|
const updateObject = useUpdateObject(gid)
|
||||||
const ensurePlant = useEnsurePlantInFull(gid)
|
const ensurePlant = useEnsurePlantInFull(gid)
|
||||||
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
||||||
@@ -109,6 +113,17 @@ export function GardenEditorPage() {
|
|||||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
||||||
|
|
||||||
|
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
|
||||||
|
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
|
||||||
|
const recentPlants = useMemo(
|
||||||
|
() =>
|
||||||
|
recentlyPlantedIds(plantings)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((id) => plantsById.get(id))
|
||||||
|
.filter((p): p is Plant => !!p),
|
||||||
|
[plantings, plantsById],
|
||||||
|
)
|
||||||
|
|
||||||
// Role gating, computed before the effects/returns so the nudge handler can use
|
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||||
// it. Ownership is the authoritative ownerId==me check.
|
// it. Ownership is the authoritative ownerId==me check.
|
||||||
const gd = full.data?.garden
|
const gd = full.data?.garden
|
||||||
@@ -393,6 +408,14 @@ export function GardenEditorPage() {
|
|||||||
if (armedPlant?.id === id) setArmedPlant(null)
|
if (armedPlant?.id === id) setArmedPlant(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
|
||||||
|
// #77) — the UI's way to run the region fill that was agent-only before. Guards
|
||||||
|
// are belt-and-braces: the control only shows with a plant armed in a bed.
|
||||||
|
function fillBed(layout: FillLayout) {
|
||||||
|
if (!canEdit || focusedObject == null || armedPlant == null) return
|
||||||
|
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -572,6 +595,7 @@ export function GardenEditorPage() {
|
|||||||
{canEdit &&
|
{canEdit &&
|
||||||
(focusedObject.plantable ? (
|
(focusedObject.plantable ? (
|
||||||
<PlantPlacementTools
|
<PlantPlacementTools
|
||||||
|
recentPlants={recentPlants}
|
||||||
trayPlants={trayPlants}
|
trayPlants={trayPlants}
|
||||||
armedPlant={armedPlant}
|
armedPlant={armedPlant}
|
||||||
onArm={armPlant}
|
onArm={armPlant}
|
||||||
@@ -580,6 +604,8 @@ export function GardenEditorPage() {
|
|||||||
onDisarm={() => setArmedPlant(null)}
|
onDisarm={() => setArmedPlant(null)}
|
||||||
focusedPlopCount={focusedPlops.length}
|
focusedPlopCount={focusedPlops.length}
|
||||||
onClear={() => setClearing(true)}
|
onClear={() => setClearing(true)}
|
||||||
|
onFill={fillBed}
|
||||||
|
filling={fillObject.isPending}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted">Not plantable</span>
|
<span className="text-xs text-muted">Not plantable</span>
|
||||||
@@ -611,6 +637,7 @@ export function GardenEditorPage() {
|
|||||||
focusedObject.plantable ? (
|
focusedObject.plantable ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<PlantPlacementTools
|
<PlantPlacementTools
|
||||||
|
recentPlants={recentPlants}
|
||||||
trayPlants={trayPlants}
|
trayPlants={trayPlants}
|
||||||
armedPlant={armedPlant}
|
armedPlant={armedPlant}
|
||||||
onArm={armPlant}
|
onArm={armPlant}
|
||||||
@@ -619,6 +646,8 @@ export function GardenEditorPage() {
|
|||||||
onDisarm={() => setArmedPlant(null)}
|
onDisarm={() => setArmedPlant(null)}
|
||||||
focusedPlopCount={focusedPlops.length}
|
focusedPlopCount={focusedPlops.length}
|
||||||
onClear={() => setClearing(true)}
|
onClear={() => setClearing(true)}
|
||||||
|
onFill={fillBed}
|
||||||
|
filling={fillObject.isPending}
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||||
Done planting
|
Done planting
|
||||||
@@ -694,6 +723,7 @@ export function GardenEditorPage() {
|
|||||||
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
|
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
|
||||||
// focus toolbar and the mobile Plants strip so the two can't drift apart.
|
// focus toolbar and the mobile Plants strip so the two can't drift apart.
|
||||||
function PlantPlacementTools({
|
function PlantPlacementTools({
|
||||||
|
recentPlants,
|
||||||
trayPlants,
|
trayPlants,
|
||||||
armedPlant,
|
armedPlant,
|
||||||
onArm,
|
onArm,
|
||||||
@@ -702,7 +732,10 @@ function PlantPlacementTools({
|
|||||||
onDisarm,
|
onDisarm,
|
||||||
focusedPlopCount,
|
focusedPlopCount,
|
||||||
onClear,
|
onClear,
|
||||||
|
onFill,
|
||||||
|
filling,
|
||||||
}: {
|
}: {
|
||||||
|
recentPlants: Plant[]
|
||||||
trayPlants: Plant[]
|
trayPlants: Plant[]
|
||||||
armedPlant: Plant | null
|
armedPlant: Plant | null
|
||||||
onArm: (p: Plant, lot?: SeedLot) => void
|
onArm: (p: Plant, lot?: SeedLot) => void
|
||||||
@@ -711,9 +744,12 @@ function PlantPlacementTools({
|
|||||||
onDisarm: () => void
|
onDisarm: () => void
|
||||||
focusedPlopCount: number
|
focusedPlopCount: number
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
|
onFill: (layout: FillLayout) => void
|
||||||
|
filling: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
|
||||||
<SeedTray
|
<SeedTray
|
||||||
trayPlants={trayPlants}
|
trayPlants={trayPlants}
|
||||||
armedPlantId={armedPlant?.id ?? null}
|
armedPlantId={armedPlant?.id ?? null}
|
||||||
@@ -721,6 +757,7 @@ function PlantPlacementTools({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onOpenPicker={onOpenPicker}
|
onOpenPicker={onOpenPicker}
|
||||||
/>
|
/>
|
||||||
|
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
||||||
{armedPlant && (
|
{armedPlant && (
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||||||
Done
|
Done
|
||||||
@@ -739,6 +776,38 @@ function PlantPlacementTools({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
|
||||||
|
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
|
||||||
|
// individual plants in rows you could actually plant from. Defaults to grid,
|
||||||
|
// since "fill this bed" usually means a real planting.
|
||||||
|
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
|
||||||
|
const [layout, setLayout] = useState<FillLayout>('grid')
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
|
||||||
|
<span className="inline-flex overflow-hidden rounded-full">
|
||||||
|
{(['clump', 'grid'] as const).map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={layout === l}
|
||||||
|
onClick={() => setLayout(l)}
|
||||||
|
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 font-medium capitalize transition-colors',
|
||||||
|
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l === 'grid' ? 'rows' : l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
|
||||||
|
{busy ? 'Filling…' : 'Fill bed'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||||||
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||||||
// strip. Assistant is dropped when the instance has no model configured.
|
// strip. Assistant is dropped when the instance has no model configured.
|
||||||
|
|||||||
Reference in New Issue
Block a user