Files
pansy/web/src/editor/PlantPicker.tsx
T
steve 5d9b10d7c7
Build image / build-and-push (push) Successful in 11s
Seed shelf UI: source links, lots, and what's left (#51) (#68)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:03:12 +00:00

234 lines
9.3 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/cn'
import { fieldControlClass } from '@/components/ui/field'
import { CategoryChips } from '@/components/plants/CategoryChips'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import {
attributableLots,
formatQuantity,
lotsByPlant,
lotState,
summarizeLots,
useSeedLots,
type SeedLot,
} from '@/lib/seedLots'
import { LotStateChip } from '@/components/plants/SeedLotList'
import { formatSpacing, type UnitPref } from '@/lib/units'
const RECENT_KEY = 'pansy:recent-plants'
const RECENT_MAX = 8
/** Recently-picked plant ids, most-recent first, from localStorage. */
function loadRecent(): number[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
} catch {
return []
}
}
/** Prepend id (dedup, capped) and persist; returns the new list. */
function recordRecent(id: number): number[] {
const next = [id, ...loadRecent().filter((n) => n !== id)].slice(0, RECENT_MAX)
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
} catch {
// Recents are a nicety; ignore quota/availability failures.
}
return next
}
/**
* Reusable plant chooser: a search-first list with a recently-used shortcut and
* category filter, rendered as a bottom sheet on mobile / centered panel on
* desktop. `onSelect` fires with the chosen plant (and records it as recent).
* The plops editor (#15) opens this when placing plants; /plants demos it.
*/
export function PlantPicker({
onSelect,
onClose,
unit = 'metric',
}: {
// The chosen plant, and the lot it should be attributed to when that isn't
// ambiguous. Undefined lot means "don't attribute" — which is the honest
// answer when there are no lots, and the deliberate one when the user skips.
onSelect: (plant: Plant, lot?: SeedLot) => void
onClose: () => void
unit?: UnitPref
}) {
const plants = usePlants()
const seedLots = useSeedLots()
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
// Set when a plant with SEVERAL lots is picked: which packet did this come out
// of? One lot auto-attributes and zero lots stays silent, because forcing that
// question on every placement is how a nicety becomes an obstacle.
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all')
const [recent, setRecent] = useState<number[]>(() => loadRecent())
const searchRef = useRef<HTMLInputElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
searchRef.current?.focus()
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [])
const all = useMemo(() => plants.data ?? [], [plants.data])
const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category])
// Recents show only when not actively searching, and only rows still present.
const recentPlants = useMemo(() => {
if (query.trim() !== '') return []
const byId = new Map(all.map((p) => [p.id, p]))
return recent.map((id) => byId.get(id)).filter((p): p is Plant => !!p)
}, [all, recent, query])
function choose(p: Plant) {
const lots = attributableLots(lotsFor.get(p.id) ?? [])
if (lots.length > 1) {
setChoosingLotFor(p)
return
}
setRecent(recordRecent(p.id))
// A single lot attributes even when its count says empty. The count records
// what was written down, not a fact about the packet — dropping attribution
// there would make a wrong number harder to correct rather than easier.
onSelect(p, lots[0])
}
function chooseLot(p: Plant, lot?: SeedLot) {
setRecent(recordRecent(p.id))
onSelect(p, lot)
}
// A plant option row. keyPrefix namespaces the key so a plant appearing in
// both the Recent and All sections doesn't collide on a duplicate React key.
const row = (p: Plant, keyPrefix: string) => (
<button
key={`${keyPrefix}-${p.id}`}
type="button"
onClick={() => choose(p)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
<PlantIcon color={p.color} icon={p.icon} className="h-9 w-9 rounded-md text-xl" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-fg">{p.name}</span>
<span className="block text-xs text-muted">
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
{remainingLabel(lotsFor.get(p.id) ?? [])}
</span>
</span>
</button>
)
// What's left, shown where you're deciding what to plant. Silent when there
// are no lots — most plants won't have any, and "0 left" on all of them would
// be noise that trains you to ignore the number. summarizeLots owns the
// unit-agreement rule; a second copy of it here would drift.
function remainingLabel(lots: SeedLot[]): string {
if (lots.length === 0) return ''
const { remaining, unit } = summarizeLots(lots)
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
}
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
onMouseDown={(e) => e.target === e.currentTarget && onClose()}
>
<div
role="dialog"
aria-modal="true"
aria-label="Choose a plant"
className="flex max-h-[85vh] w-full flex-col rounded-t-2xl border border-border bg-surface shadow-xl sm:max-w-lg sm:rounded-2xl"
>
<div className="flex items-center gap-2 border-b border-border p-3">
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search plants…"
aria-label="Search plants"
className={cn(fieldControlClass, 'min-w-0 flex-1')}
/>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md px-2 py-2 text-muted outline-none transition-colors hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
</button>
</div>
<div className="border-b border-border px-3 py-2">
<CategoryChips value={category} onChange={setCategory} size="sm" />
</div>
{choosingLotFor && (
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
Which lot of {choosingLotFor.name}?
</p>
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
<button
key={lot.id}
type="button"
onClick={() => chooseLot(choosingLotFor, lot)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg">
{lot.vendor || 'Unnamed lot'}
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
</span>
<span className="block text-xs text-muted">
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
</span>
</span>
<LotStateChip state={lotState(lot)} />
</button>
))}
<button
type="button"
onClick={() => chooseLot(choosingLotFor, undefined)}
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
Don't attribute to a lot
</button>
</div>
)}
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
{recentPlants.length > 0 && (
<>
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent</p>
{recentPlants.map((p) => row(p, 'recent'))}
<p className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted">All plants</p>
</>
)}
{filtered.map((p) => row(p, 'all'))}
{plants.isSuccess && filtered.length === 0 && (
<p className="p-4 text-sm text-muted">No plants match your search.</p>
)}
</div>
</div>
</div>
)
}