Plant catalog UI: /plants page + PlantPicker (#13)
Build image / build-and-push (push) Successful in 15s
Build image / build-and-push (push) Successful in 15s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #32.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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 { 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',
|
||||
}: {
|
||||
onSelect: (plant: Plant) => void
|
||||
onClose: () => void
|
||||
unit?: UnitPref
|
||||
}) {
|
||||
const plants = usePlants()
|
||||
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) {
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p)
|
||||
}
|
||||
|
||||
// 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)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user