Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
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 { SeedLotModal } from '@/components/plants/SeedLotModal'
|
|
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
|
import { PlantPicker } from '@/editor/PlantPicker'
|
|
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
|
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
|
import type { UnitPref } from '@/lib/units'
|
|
import { usePageTitle } from '@/lib/usePageTitle'
|
|
|
|
// 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' }
|
|
| { kind: 'addLot'; plant: Plant }
|
|
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
|
| { kind: 'deleteLot'; lot: SeedLot }
|
|
| 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() {
|
|
usePageTitle('Plants')
|
|
const plants = usePlants()
|
|
const seedLots = useSeedLots()
|
|
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
|
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}
|
|
lots={lots.get(p.id) ?? []}
|
|
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
|
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
|
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
|
|
onAddLot={() => setDialog({ kind: 'addLot', plant: p })}
|
|
onEditLot={(lot) => setDialog({ kind: 'editLot', plant: p, lot })}
|
|
onDeleteLot={(lot) => setDialog({ kind: 'deleteLot', lot })}
|
|
/>
|
|
))}
|
|
</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 === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
|
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
|
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
|
{dialog?.kind === 'picker' && (
|
|
<PlantPicker
|
|
unit={unit}
|
|
onClose={close}
|
|
onSelect={(p) => {
|
|
toast.info(`Picked ${p.name}`)
|
|
close()
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|