Plant catalog UI: /plants page + PlantPicker (#13)
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:
2026-07-19 02:18:12 +00:00
committed by steve
parent 293532f021
commit e4505ed9a7
10 changed files with 848 additions and 3 deletions
+131 -2
View File
@@ -1,5 +1,134 @@
import { PageStub } from '@/components/PageStub'
import { useMemo, useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { fieldControlClass } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { CategoryChips } from '@/components/plants/CategoryChips'
import { PlantCard } from '@/components/plants/PlantCard'
import { PlantFormModal } from '@/components/plants/PlantFormModal'
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
import { PlantPicker } from '@/editor/PlantPicker'
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import type { UnitPref } from '@/lib/units'
// Which modal is open, if any. edit/duplicate/delete carry the target plant.
type Dialog =
| { kind: 'create' }
| { kind: 'edit'; plant: Plant }
| { kind: 'duplicate'; plant: Plant }
| { kind: 'delete'; plant: Plant }
| { kind: 'picker' }
| null
// There's no per-user unit preference server-side (only per-garden), so the
// catalog page keeps its own, persisted locally.
const UNIT_KEY = 'pansy:unit-pref'
function loadUnit(): UnitPref {
try {
return localStorage.getItem(UNIT_KEY) === 'imperial' ? 'imperial' : 'metric'
} catch {
return 'metric'
}
}
export function PlantsPage() {
return <PageStub title="Plants">The plant catalog arrives in issue #13.</PageStub>
const plants = usePlants()
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all')
const [dialog, setDialog] = useState<Dialog>(null)
const close = () => setDialog(null)
function toggleUnit() {
setUnit((u) => {
const next: UnitPref = u === 'metric' ? 'imperial' : 'metric'
try {
localStorage.setItem(UNIT_KEY, next)
} catch {
// Preference is a nicety; ignore storage failures.
}
return next
})
}
const all = useMemo(() => plants.data ?? [], [plants.data])
const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category])
return (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Plants</h1>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
Try the picker
</Button>
<Button
variant="ghost"
onClick={toggleUnit}
title="Toggle spacing units"
aria-label={`Spacing units: ${unit === 'metric' ? 'metric' : 'imperial'}`}
>
{unit === 'metric' ? 'cm' : 'in'}
</Button>
<Button onClick={() => setDialog({ kind: 'create' })}>New plant</Button>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search plants by name…"
aria-label="Search plants"
className={fieldControlClass}
/>
<div className="pb-1">
<CategoryChips value={category} onChange={setCategory} />
</div>
</div>
<div className="mt-5">
{plants.isPending && <p className="text-sm text-muted">Loading the catalog</p>}
{plants.isError && <Alert>Could not load the plant catalog. Please refresh.</Alert>}
{plants.isSuccess && filtered.length === 0 && (
<div className="rounded-xl border border-dashed border-border p-10 text-center">
<p className="text-fg">No plants match.</p>
<p className="mt-1 text-sm text-muted">Try a different search or category, or add a custom plant.</p>
</div>
)}
{filtered.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((p) => (
<PlantCard
key={p.id}
plant={p}
unit={unit}
onEdit={() => setDialog({ kind: 'edit', plant: p })}
onDelete={() => setDialog({ kind: 'delete', plant: p })}
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
/>
))}
</div>
)}
</div>
{dialog?.kind === 'create' && <PlantFormModal unit={unit} onClose={close} />}
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'picker' && (
<PlantPicker
unit={unit}
onClose={close}
onSelect={(p) => {
toast.info(`Picked ${p.name}`)
close()
}}
/>
)}
</section>
)
}