Files
pansy/web/src/editor/Toolkit.tsx
T
steveandClaude Fable 5 7a5b9d2ea1
Build image / build-and-push (push) Successful in 12s
Address #133 review: the toolkit has one home now
The `embedded` prop was always true, which left the card branch dead;
the component is simply the rail's tab now. The focus comment is one
line, and the default tab says why it is Plot and not the first tab.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 03:04:22 -04:00

221 lines
9.1 KiB
TypeScript

import { useState } from 'react'
import { ColorDot } from '@/components/plants/Monogram'
import { Button, IconButton } from '@/components/ui/Button'
import { cn } from '@/lib/cn'
import type { FillLayout } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { formatQuantity, type SeedLot } from '@/lib/seedLots'
import { formatSize, formatSpacing, type UnitPref } from '@/lib/units'
import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from './arming'
import { KindSwatch } from './KindSwatch'
import { OBJECT_KINDS } from './kinds'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
/**
* The desktop editor's toolkit, the rail's first tab. Out of focus: the seven
* object kinds as pill rows — click to arm (then click the plan), or drag one
* straight onto it. In a focused bed it swaps to the plant list with a search,
* same arm/drag behavior, plus the bed's bulk tools (fill, clear, scan a
* packet).
*
* It started life as the workspace's left card (the handoff's layout) and
* moved into the rail so the plan and the rail get the width the card took;
* the rail's tab body provides the card chrome and the scrolling, and the tab
* is the heading.
*/
export function Toolkit({
unit,
plants,
plantings,
lotsByPlant,
canEdit,
focused,
focusedPlopCount,
canScan,
filling,
onBack,
onFill,
onClear,
onScan,
}: {
unit: UnitPref
plants: Plant[]
plantings: EditorPlanting[]
lotsByPlant: Map<number, SeedLot[]>
canEdit: boolean
focused: EditorObject | null
focusedPlopCount: number
canScan: boolean
filling: boolean
onBack: () => void
onFill: (layout: FillLayout) => void
onClear: () => void
onScan: () => void
}) {
const armedKind = useEditorStore((s) => s.armedKind)
const armedPlant = useEditorStore((s) => s.armedPlant)
const armedLotId = useEditorStore((s) => s.armedLotId)
const setArmedLotId = useEditorStore((s) => s.setArmedLotId)
const [query, setQuery] = useState('')
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden">
{!focused ? (
<>
{OBJECT_KINDS.map((k) => {
const armed = armedKind === k.kind
return (
<button
key={k.kind}
type="button"
draggable={canEdit}
disabled={!canEdit}
onDragStart={(e) => e.dataTransfer.setData('text/plain', `kind:${k.kind}`)}
onClick={() => toggleArmedKind(k.kind)}
aria-pressed={armed}
title="Drag onto the plan — or click to arm, then click the plan"
className={cn(
'flex items-center gap-2.5 rounded-full border px-2.5 py-[7px] text-left disabled:cursor-default disabled:opacity-60',
armed ? 'border-accent-400 bg-accent-200' : 'border-transparent hover:bg-neutral-200',
)}
>
<KindSwatch def={k} />
<span className="flex flex-col items-start gap-px">
<span className="whitespace-nowrap text-[13px] font-bold">{k.label}</span>
<span className="whitespace-nowrap text-[11px] text-ink-mute">{formatSize(k.widthCm, k.heightCm, unit)}</span>
</span>
</button>
)
})}
<div className="mt-auto px-1.5 pb-0.5 pt-2 text-xs leading-relaxed text-ink-mute">
{canEdit ? 'Drag a shape onto the plan. Double-click any bed to plant it.' : 'You can look but not change this garden.'}
</div>
</>
) : (
<>
<div className="mb-1 mt-0.5 flex items-center gap-2">
<IconButton label="Back to the plan" icon="chevron-left" size={30} onClick={onBack} />
<span className="min-w-0 truncate font-heading text-base leading-[1.15]" title={focused.name}>
{focused.name}
</span>
</div>
{canEdit && focused.plantable ? (
<>
<input
className="input"
placeholder="Find a plant…"
aria-label="Find a plant"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{orderedPlants(plants, plantings, query).map((p) => {
const armed = armedPlant?.id === p.id
const choices = armed ? lotChoices(armedPlant, lotsByPlant) : []
return (
<div key={p.id} className="flex flex-col gap-1.5">
<button
type="button"
draggable
onDragStart={(e) => {
// Dragging arms too, so the drop knows which plant (and lot).
if (!armed) toggleArmedPlant(p, lotsByPlant)
e.dataTransfer.setData('text/plain', `plant:${p.id}`)
}}
onClick={() => toggleArmedPlant(p, lotsByPlant)}
aria-pressed={armed}
title="Drag into the bed — or click to arm, then click the bed"
className={cn(
'flex items-center gap-[9px] rounded-full border px-3 py-2 text-left',
armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg hover:border-neutral-400',
)}
>
<ColorDot color={p.color} size={16} />
<span className="min-w-0 truncate text-[13px] font-bold">{p.name}</span>
<span className="ml-auto flex-none text-[11px] text-ink-mute">{formatSpacing(p.spacingCm, unit)} apart</span>
</button>
{choices.length > 0 && <LotChooser lots={choices} value={armedLotId} onChange={setArmedLotId} />}
</div>
)
})}
{plants.length === 0 && <p className="px-1 text-xs text-ink-mute">No plants in the catalog yet.</p>}
<div className="mt-auto flex flex-col gap-1.5 pt-2">
{armedPlant && <FillRow plant={armedPlant} busy={filling} onFill={onFill} />}
<div className="flex flex-wrap gap-1">
{focusedPlopCount > 0 && (
<Button variant="ghost" icon="eraser" iconSize={13} className="px-2.5 text-[13px] text-accent-700" onClick={onClear}>
Clear the bed ({focusedPlopCount})
</Button>
)}
{canScan && (
<Button variant="ghost" icon="camera" iconSize={13} className="px-2.5 text-[13px]" onClick={onScan}>
Scan a packet
</Button>
)}
</div>
</div>
</>
) : (
<p className="px-1 text-xs leading-relaxed text-ink-mute">
{!canEdit ? 'You can look but not change this garden.' : `${focused.name} isn't plantable — turn that on in the inspector's details.`}
</p>
)}
</>
)}
</div>
)
}
/** Which packet a planting comes out of, when a plant has more than one. */
export function LotChooser({
lots,
value,
onChange,
compact = false,
}: {
lots: SeedLot[]
value: number | null
onChange: (lotId: number | null) => void
compact?: boolean
}) {
return (
<div className={cn('flex flex-wrap gap-1', compact ? 'items-center' : 'pl-2')}>
<span className="w-full text-[11px] font-bold text-ink-mute">Which packet?</span>
{lots.map((lot) => (
<button
key={lot.id}
type="button"
className="chip px-3 py-1 text-xs"
aria-pressed={value === lot.id}
onClick={() => onChange(lot.id)}
title={`${formatQuantity(lot.remaining)} of ${formatQuantity(lot.quantity)} ${lot.unit} left`}
>
{lot.vendor || 'Unnamed lot'}
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
</button>
))}
<button type="button" className="chip px-3 py-1 text-xs" aria-pressed={value === null} onClick={() => onChange(null)}>
No particular one
</button>
</div>
)
}
/** Fill the whole bed with the armed plant — rows you could plant from, or
* clumps for a quick sketch (#77). */
export function FillRow({ plant, busy, onFill }: { plant: Plant; busy: boolean; onFill: (layout: FillLayout) => void }) {
return (
<div className="flex flex-wrap items-center gap-1 rounded-[18px] border border-divider bg-bg px-2.5 py-2">
<span className="text-[11px] font-bold text-ink-mute">Fill the bed with {plant.name.toLowerCase()}:</span>
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('grid')} title="Individual plants at true spacing">
rows
</button>
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('clump')} title="Fat clumps — a quick sketch">
clumps
</button>
{busy && <span className="text-[11px] text-ink-mute">filling</span>}
</div>
)
}