diff --git a/web/src/components/plants/CategoryChips.tsx b/web/src/components/plants/CategoryChips.tsx new file mode 100644 index 0000000..4ea0a0f --- /dev/null +++ b/web/src/components/plants/CategoryChips.tsx @@ -0,0 +1,37 @@ +import { cn } from '@/lib/cn' +import { CATEGORY_LABELS, PLANT_CATEGORIES, type CategoryFilter } from '@/lib/plants' + +/** + * Horizontal, scrollable "All + each category" chip row. Shared by the /plants + * page and the PlantPicker so both filter the catalog identically. + */ +export function CategoryChips({ + value, + onChange, + size = 'md', +}: { + value: CategoryFilter + onChange: (c: CategoryFilter) => void + size?: 'sm' | 'md' +}) { + const chip = (v: CategoryFilter, label: string) => ( + + ) + return ( +
+ {chip('all', 'All')} + {PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))} +
+ ) +} diff --git a/web/src/components/plants/PlantCard.tsx b/web/src/components/plants/PlantCard.tsx index 55ebba7..c772e9d 100644 --- a/web/src/components/plants/PlantCard.tsx +++ b/web/src/components/plants/PlantCard.tsx @@ -1,3 +1,4 @@ +import { PlantIcon } from './PlantIcon' import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants' import { formatSpacing, type UnitPref } from '@/lib/units' @@ -30,13 +31,7 @@ export function PlantCard({ return (
- - {plant.icon} - +

{plant.name}

diff --git a/web/src/components/plants/PlantFormModal.tsx b/web/src/components/plants/PlantFormModal.tsx index fdc94fc..70faae3 100644 --- a/web/src/components/plants/PlantFormModal.tsx +++ b/web/src/components/plants/PlantFormModal.tsx @@ -65,6 +65,7 @@ export function PlantFormModal({ const [version, setVersion] = useState(plant?.version ?? 0) const [conflict, setConflict] = useState(null) const [formError, setFormError] = useState(null) + const unitLabel = spacingUnitLabel(unit) async function onSubmit(e: FormEvent) { e.preventDefault() @@ -81,12 +82,13 @@ export function PlantFormModal({ } const spacingCm = cmFromSpacing(parseFloat(spacing), unit) if (!Number.isFinite(spacingCm) || spacingCm < 1) { - setFormError('Spacing must be a positive number.') + setFormError(`Spacing must be at least 1 ${unitLabel}.`) return } let daysToMaturity: number | null = null if (days.trim()) { - const d = parseInt(days, 10) + // Number() (not parseInt) so "1.5" is rejected, not silently truncated to 1. + const d = Number(days) if (!Number.isInteger(d) || d < 1) { setFormError('Days to maturity must be a whole number of days, or left blank.') return @@ -131,8 +133,6 @@ export function PlantFormModal({ } } - const unitLabel = spacingUnitLabel(unit) - return (
@@ -154,7 +154,7 @@ export function PlantFormModal({ type="number" inputMode="decimal" step="any" - min="0" + min="1" required value={spacing} onChange={(e) => setSpacing(e.target.value)} @@ -178,7 +178,6 @@ export function PlantFormModal({ label="Icon (emoji)" name="icon" value={icon} - maxLength={8} onChange={(e) => setIcon(e.target.value)} hint="A single emoji, e.g. 🍅" /> diff --git a/web/src/components/plants/PlantIcon.tsx b/web/src/components/plants/PlantIcon.tsx new file mode 100644 index 0000000..2239039 --- /dev/null +++ b/web/src/components/plants/PlantIcon.tsx @@ -0,0 +1,18 @@ +import { cn } from '@/lib/cn' + +/** + * A plant's emoji on a tile tinted with its own color (via color-mix, so any + * valid CSS color works). Shared by PlantCard and the PlantPicker rows. Size and + * shape come from `className`. + */ +export function PlantIcon({ color, icon, className }: { color: string; icon: string; className?: string }) { + return ( + + {icon} + + ) +} diff --git a/web/src/editor/PlantPicker.tsx b/web/src/editor/PlantPicker.tsx index 2597ef4..3e86f8c 100644 --- a/web/src/editor/PlantPicker.tsx +++ b/web/src/editor/PlantPicker.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/cn' import { fieldControlClass } from '@/components/ui/field' -import { CATEGORY_LABELS, PLANT_CATEGORIES, usePlants, type Plant, type PlantCategory } from '@/lib/plants' +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' @@ -28,8 +30,6 @@ function recordRecent(id: number): number[] { return next } -type CategoryFilter = PlantCategory | 'all' - /** * 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 @@ -64,12 +64,7 @@ export function PlantPicker({ const all = useMemo(() => plants.data ?? [], [plants.data]) - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - return all.filter( - (p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)), - ) - }, [all, query, category]) + 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(() => { @@ -83,20 +78,16 @@ export function PlantPicker({ onSelect(p) } - const row = (p: Plant) => ( + // 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) => ( ) - const chip = (value: CategoryFilter, label: string) => ( - - ) - return (
-
- {chip('all', 'All')} - {PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))} +
+
@@ -163,12 +139,12 @@ export function PlantPicker({ {recentPlants.length > 0 && ( <>

Recent

- {recentPlants.map(row)} + {recentPlants.map((p) => row(p, 'recent'))}

All plants

)} - {filtered.map(row)} + {filtered.map((p) => row(p, 'all'))} {plants.isSuccess && filtered.length === 0 && (

No plants match your search.

diff --git a/web/src/lib/plants.ts b/web/src/lib/plants.ts index f8068ae..52f885c 100644 --- a/web/src/lib/plants.ts +++ b/web/src/lib/plants.ts @@ -43,6 +43,20 @@ export function isBuiltin(p: Plant): boolean { return p.ownerId == null } +/** Category filter value: a specific category, or 'all'. Shared by the catalog + * page and the PlantPicker so their chip rows and filtering stay in sync. */ +export type CategoryFilter = PlantCategory | 'all' + +/** Filter a plant list by a name query (case-insensitive substring) and + * category. The single source of truth for both the /plants grid and the + * PlantPicker list. */ +export function filterPlants(plants: Plant[], query: string, category: CategoryFilter): Plant[] { + const q = query.trim().toLowerCase() + return plants.filter( + (p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)), + ) +} + const plantsKey = ['plants'] as const export const plantsQueryOptions = queryOptions({ diff --git a/web/src/pages/PlantsPage.tsx b/web/src/pages/PlantsPage.tsx index 1f89acb..648eb1f 100644 --- a/web/src/pages/PlantsPage.tsx +++ b/web/src/pages/PlantsPage.tsx @@ -1,18 +1,16 @@ import { useMemo, useState } from 'react' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' -import { cn } from '@/lib/cn' 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 { CATEGORY_LABELS, PLANT_CATEGORIES, usePlants, type Plant, type PlantCategory } from '@/lib/plants' +import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants' import type { UnitPref } from '@/lib/units' -type CategoryFilter = PlantCategory | 'all' - // Which modal is open, if any. edit/duplicate/delete carry the target plant. type Dialog = | { kind: 'create' } @@ -54,26 +52,7 @@ export function PlantsPage() { } const all = useMemo(() => plants.data ?? [], [plants.data]) - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - return all.filter( - (p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)), - ) - }, [all, query, category]) - - const chip = (value: CategoryFilter, label: string) => ( - - ) + const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category]) return (
@@ -104,9 +83,8 @@ export function PlantsPage() { aria-label="Search plants" className={fieldControlClass} /> -
- {chip('all', 'All')} - {PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))} +
+