Merge pull request 'Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)' (#110) from feat/editor-modes into main
Build image / build-and-push (push) Successful in 20s
Build image / build-and-push (push) Successful in 20s
This commit was merged in pull request #110.
This commit is contained in:
@@ -128,7 +128,8 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
|
|||||||
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,25 @@ import type { EditorObject } from './types'
|
|||||||
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
||||||
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
||||||
|
|
||||||
|
// The editor's one primary activity (#99). On mobile this is the segmented
|
||||||
|
// control at the bottom of the screen; it decides which tools dock there —
|
||||||
|
// placing fixtures, placing plants, the journal, or the assistant — so the four
|
||||||
|
// activities stop competing for the same cramped strip. Desktop keeps its
|
||||||
|
// side-column layout and treats this as a lighter hint.
|
||||||
|
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
|
||||||
|
|
||||||
|
// Where the editor starts, and where it returns on a garden switch.
|
||||||
|
export const DEFAULT_MODE: EditorMode = 'fixtures'
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewport: Viewport
|
viewport: Viewport
|
||||||
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
||||||
|
|
||||||
|
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
|
||||||
|
// last using is not a property of the garden.
|
||||||
|
mode: EditorMode
|
||||||
|
setMode: (mode: EditorMode) => void
|
||||||
|
|
||||||
// The selected object OR plop (mutually exclusive; selecting one clears the
|
// The selected object OR plop (mutually exclusive; selecting one clears the
|
||||||
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
||||||
selectedId: number | null
|
selectedId: number | null
|
||||||
@@ -82,6 +97,9 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
viewport: { tx: 0, ty: 0, scale: 1 },
|
viewport: { tx: 0, ty: 0, scale: 1 },
|
||||||
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||||
|
|
||||||
|
mode: DEFAULT_MODE,
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
||||||
|
|
||||||
@@ -129,5 +147,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
railTab: null,
|
railTab: null,
|
||||||
seasonYear: null,
|
seasonYear: null,
|
||||||
journalObjectId: null,
|
journalObjectId: null,
|
||||||
|
mode: DEFAULT_MODE,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ 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'
|
||||||
import { objectDisplayName } from '@/editor/kinds'
|
import { objectDisplayName } from '@/editor/kinds'
|
||||||
import { useEditorStore } from '@/editor/store'
|
import { useEditorStore, type EditorMode } from '@/editor/store'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
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'
|
||||||
@@ -83,6 +84,8 @@ export function GardenEditorPage() {
|
|||||||
)
|
)
|
||||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||||
|
const mode = useEditorStore((s) => s.mode)
|
||||||
|
const setMode = useEditorStore((s) => s.setMode)
|
||||||
|
|
||||||
const updatePlanting = useUpdatePlanting(gid)
|
const updatePlanting = useUpdatePlanting(gid)
|
||||||
const updateObject = useUpdateObject(gid)
|
const updateObject = useUpdateObject(gid)
|
||||||
@@ -169,16 +172,39 @@ export function GardenEditorPage() {
|
|||||||
}
|
}
|
||||||
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
||||||
|
|
||||||
|
// The canvas mode follows focus: inside a bed you're placing Plants, out of it
|
||||||
|
// you're arranging Fixtures — so the bottom strip can't show the object palette
|
||||||
|
// while you're in a bed, or the seed tray while you're not. A panel mode
|
||||||
|
// (journal/assistant) is set explicitly and left alone here. (Inert on desktop,
|
||||||
|
// where the mode bar isn't shown.)
|
||||||
|
useEffect(() => {
|
||||||
|
const cur = useEditorStore.getState().mode
|
||||||
|
if (focusedObjectId != null) {
|
||||||
|
if (cur !== 'journal' && cur !== 'assistant') setMode('plants')
|
||||||
|
} else if (cur === 'plants') {
|
||||||
|
setMode('fixtures')
|
||||||
|
}
|
||||||
|
}, [focusedObjectId, setMode])
|
||||||
|
|
||||||
// Selecting anything lands you in the inspector without a click — the rail
|
// Selecting anything lands you in the inspector without a click — the rail
|
||||||
// must never be something you operate before you can edit. Keyed off the
|
// must never be something you operate before you can edit. Keyed off the
|
||||||
// selected ids rather than a "something is selected" boolean, so selecting a
|
// selected ids rather than a "something is selected" boolean, so selecting a
|
||||||
// DIFFERENT object while History is open still brings the inspector forward.
|
// DIFFERENT object while History is open still brings the inspector forward.
|
||||||
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
||||||
// that's where you were, since those aren't about the selection.
|
// that's where you were, since those aren't about the selection. Selecting is a
|
||||||
|
// canvas interaction, so it also leaves a panel mode — otherwise closing the
|
||||||
|
// inspector would strand the mode bar on Journal/Assistant with nothing open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector')
|
if (selectedId != null || selectedPlantingId != null) {
|
||||||
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
|
setRailTab('inspector')
|
||||||
}, [selectedId, selectedPlantingId, setRailTab])
|
const s = useEditorStore.getState()
|
||||||
|
if (s.mode === 'journal' || s.mode === 'assistant') {
|
||||||
|
setMode(s.focusedObjectId != null ? 'plants' : 'fixtures')
|
||||||
|
}
|
||||||
|
} else if (useEditorStore.getState().railTab === 'inspector') {
|
||||||
|
setRailTab(null)
|
||||||
|
}
|
||||||
|
}, [selectedId, selectedPlantingId, setRailTab, setMode])
|
||||||
|
|
||||||
const exitFocus = () => {
|
const exitFocus = () => {
|
||||||
setFocusedObject(null)
|
setFocusedObject(null)
|
||||||
@@ -187,6 +213,33 @@ export function GardenEditorPage() {
|
|||||||
selectPlanting(null)
|
selectPlanting(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The mobile mode bar. Journal/Assistant are panel modes, so they open the
|
||||||
|
// rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
|
||||||
|
// leave an inspector, which is about the selection, alone). Tapping Fixtures
|
||||||
|
// means going back to arranging objects, so it steps out of a focused bed.
|
||||||
|
const selectMode = (m: EditorMode) => {
|
||||||
|
setMode(m)
|
||||||
|
if (m === 'journal') {
|
||||||
|
setJournalObjectId(null)
|
||||||
|
setRailTab('journal')
|
||||||
|
} else if (m === 'assistant') {
|
||||||
|
setRailTab('chat')
|
||||||
|
} else {
|
||||||
|
if (railTab === 'journal' || railTab === 'chat') setRailTab(null)
|
||||||
|
if (m === 'fixtures' && focusedObjectId != null) exitFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety for a live capability flip: if the admin turns the assistant off while
|
||||||
|
// it's the active mode, drop back to a canvas mode so the bar isn't stuck on a
|
||||||
|
// tab that no longer exists (and close the now-orphaned chat rail).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capabilities.data?.agent && useEditorStore.getState().mode === 'assistant') {
|
||||||
|
setMode('fixtures')
|
||||||
|
if (useEditorStore.getState().railTab === 'chat') setRailTab(null)
|
||||||
|
}
|
||||||
|
}, [capabilities.data?.agent, setMode, setRailTab])
|
||||||
|
|
||||||
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -439,7 +492,10 @@ export function GardenEditorPage() {
|
|||||||
// the canvas bottom + Fit button under the browser chrome (#85).
|
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||||
<div className="shrink-0 md:w-40">
|
{/* Desktop-only control column. On mobile these move to the bottom mode bar
|
||||||
|
+ a slim top strip so the canvas — the point of the screen — isn't shoved
|
||||||
|
into a corner by a stack of controls (#99). */}
|
||||||
|
<div className="hidden shrink-0 md:block md:w-40">
|
||||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||||
{garden.name}
|
{garden.name}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -486,38 +542,45 @@ export function GardenEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
{/* Mobile top strip: the garden identity / season / share that live in the
|
||||||
|
desktop left column. md:hidden. */}
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
|
||||||
|
{garden.name}
|
||||||
|
</h1>
|
||||||
|
{!canEdit && seasonYear === null && (
|
||||||
|
<span className="shrink-0 rounded-md bg-border/40 px-2 py-0.5 text-xs text-muted">👁 View only</span>
|
||||||
|
)}
|
||||||
|
{years.data && years.data.length > 0 && (
|
||||||
|
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
|
||||||
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<Button variant="ghost" className="shrink-0 px-2 py-1 text-xs" onClick={() => setSharing(true)}>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
||||||
|
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the
|
||||||
|
bottom Plants-mode strip so they don't wrap over the canvas. */}
|
||||||
{focusedObject && (
|
{focusedObject && (
|
||||||
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
<div className="absolute left-2 top-2 z-20 hidden flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur md:flex">
|
||||||
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
||||||
← {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 &&
|
{canEdit &&
|
||||||
(focusedObject.plantable ? (
|
(focusedObject.plantable ? (
|
||||||
<>
|
<PlantPlacementTools
|
||||||
<SeedTray
|
trayPlants={trayPlants}
|
||||||
trayPlants={trayPlants}
|
armedPlant={armedPlant}
|
||||||
armedPlantId={armedPlant?.id ?? null}
|
onArm={armPlant}
|
||||||
onArm={armPlant}
|
onRemove={removeFromTrayAndDisarm}
|
||||||
onRemove={removeFromTrayAndDisarm}
|
onOpenPicker={() => setPicker('place')}
|
||||||
onOpenPicker={() => setPicker('place')}
|
onDisarm={() => setArmedPlant(null)}
|
||||||
/>
|
focusedPlopCount={focusedPlops.length}
|
||||||
{armedPlant && (
|
onClear={() => setClearing(true)}
|
||||||
<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>
|
<span className="text-xs text-muted">Not plantable</span>
|
||||||
))}
|
))}
|
||||||
@@ -536,6 +599,51 @@ export function GardenEditorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile bottom: contextual tools for the current mode + the mode switch
|
||||||
|
bar (#99). md:hidden — desktop uses the left column. A panel-mode rail
|
||||||
|
(journal/assistant) or the inspector overlays this while open. */}
|
||||||
|
<div className="shrink-0 md:hidden">
|
||||||
|
{canEdit && (mode === 'fixtures' || mode === 'plants') && (
|
||||||
|
<div className="mb-2 min-h-[2.25rem]">
|
||||||
|
{mode === 'fixtures' && <Palette />}
|
||||||
|
{mode === 'plants' &&
|
||||||
|
(focusedObject ? (
|
||||||
|
focusedObject.plantable ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<PlantPlacementTools
|
||||||
|
trayPlants={trayPlants}
|
||||||
|
armedPlant={armedPlant}
|
||||||
|
onArm={armPlant}
|
||||||
|
onRemove={removeFromTrayAndDisarm}
|
||||||
|
onOpenPicker={() => setPicker('place')}
|
||||||
|
onDisarm={() => setArmedPlant(null)}
|
||||||
|
focusedPlopCount={focusedPlops.length}
|
||||||
|
onClear={() => setClearing(true)}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||||
|
Done planting
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Reachable via a ?focus= deep link onto a non-plantable object:
|
||||||
|
// say so, and give a way back out (there's no focus toolbar here).
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-1 text-xs text-muted">
|
||||||
|
{objectDisplayName(focusedObject)} isn’t plantable.
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{railTab && (
|
{railTab && (
|
||||||
<EditorRail
|
<EditorRail
|
||||||
tabs={railTabs}
|
tabs={railTabs}
|
||||||
@@ -543,10 +651,17 @@ export function GardenEditorPage() {
|
|||||||
onActivate={setRailTab}
|
onActivate={setRailTab}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// Only the inspector is *about* the selection, so only closing it
|
// Only the inspector is *about* the selection, so only closing it
|
||||||
// deselects; dismissing History leaves the canvas as you had it.
|
// deselects; dismissing a panel leaves the canvas as you had it. Any
|
||||||
|
// panel rail (journal/history/chat) drops back to a canvas mode so the
|
||||||
|
// mobile mode bar reappears.
|
||||||
if (railTab === 'inspector') {
|
if (railTab === 'inspector') {
|
||||||
select(null)
|
select(null)
|
||||||
selectPlanting(null)
|
selectPlanting(null)
|
||||||
|
} else {
|
||||||
|
// Back to a canvas mode so the mode bar reappears — Plants if you're
|
||||||
|
// still inside a bed, else Fixtures. (Hardcoding Fixtures here docked
|
||||||
|
// the object palette inside a focused bed.)
|
||||||
|
setMode(focusedObjectId != null ? 'plants' : 'fixtures')
|
||||||
}
|
}
|
||||||
setRailTab(null)
|
setRailTab(null)
|
||||||
}}
|
}}
|
||||||
@@ -575,3 +690,110 @@ export function GardenEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function PlantPlacementTools({
|
||||||
|
trayPlants,
|
||||||
|
armedPlant,
|
||||||
|
onArm,
|
||||||
|
onRemove,
|
||||||
|
onOpenPicker,
|
||||||
|
onDisarm,
|
||||||
|
focusedPlopCount,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
trayPlants: Plant[]
|
||||||
|
armedPlant: Plant | null
|
||||||
|
onArm: (p: Plant, lot?: SeedLot) => void
|
||||||
|
onRemove: (id: number) => void
|
||||||
|
onOpenPicker: () => void
|
||||||
|
onDisarm: () => void
|
||||||
|
focusedPlopCount: number
|
||||||
|
onClear: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SeedTray
|
||||||
|
trayPlants={trayPlants}
|
||||||
|
armedPlantId={armedPlant?.id ?? null}
|
||||||
|
onArm={onArm}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onOpenPicker={onOpenPicker}
|
||||||
|
/>
|
||||||
|
{armedPlant && (
|
||||||
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{focusedPlopCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear ({focusedPlopCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||||||
|
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||||||
|
// strip. Assistant is dropped when the instance has no model configured.
|
||||||
|
const MODES: { id: EditorMode; label: string; icon: string }[] = [
|
||||||
|
{ id: 'fixtures', label: 'Fixtures', icon: '🛠️' },
|
||||||
|
{ id: 'plants', label: 'Plants', icon: '🌱' },
|
||||||
|
{ id: 'journal', label: 'Journal', icon: '📓' },
|
||||||
|
{ id: 'assistant', label: 'Assistant', icon: '💬' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ModeBar({
|
||||||
|
mode,
|
||||||
|
onSelect,
|
||||||
|
hasAssistant,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
mode: EditorMode
|
||||||
|
onSelect: (m: EditorMode) => void
|
||||||
|
hasAssistant: boolean
|
||||||
|
canEdit: boolean
|
||||||
|
}) {
|
||||||
|
const modes = MODES.filter((m) => {
|
||||||
|
if (m.id === 'assistant') return hasAssistant
|
||||||
|
// Fixtures/Plants are edit actions — a viewer can't place anything, so the
|
||||||
|
// bar offers only what they can do (read the journal, ask the assistant).
|
||||||
|
if (m.id === 'fixtures' || m.id === 'plants') return canEdit
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Editor mode"
|
||||||
|
className="flex items-stretch justify-around overflow-hidden rounded-xl border border-border bg-surface"
|
||||||
|
>
|
||||||
|
{modes.map((m) => {
|
||||||
|
const active = mode === m.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => onSelect(m.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
|
||||||
|
active ? 'bg-border/60 text-accent-strong' : 'text-muted hover:text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-lg leading-none">
|
||||||
|
{m.icon}
|
||||||
|
</span>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user