Build image / build-and-push (push) Successful in 12s
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]>
221 lines
9.1 KiB
TypeScript
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>
|
|
)
|
|
}
|