Files
plant-disease-id/apps/web/src/app/browse/BrowseContent.tsx
Michael Freno 820a872f07 Initial commit: Plant Disease Identification app
- Next.js 16 App Router project with Tailwind CSS
- Plant disease knowledge base (93 diseases, 25 plants)
- Image upload with client+server preprocessing
- ML inference pipeline with mock/demo fallback
- Responsive results page with disease cards and treatment
- Full test suite (285 passing tests)
2026-06-05 19:21:16 -04:00

170 lines
6.0 KiB
TypeScript

"use client";
import React, { useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import PlantCard from "@/components/PlantCard";
import EmptyState from "@/components/EmptyState";
import { plants, type Plant } from "@/data/plants";
import { PLANT_CATEGORIES } from "@/lib/constants";
type Category = Plant["category"] | "all";
/**
* Client component that handles the interactive browse/search/filter logic.
* Wrapped in a Suspense boundary in the parent page.
*/
export default function BrowseContent() {
const searchParams = useSearchParams();
const initialSearch = searchParams.get("search") || "";
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [activeCategory, setActiveCategory] = useState<Category>("all");
const filteredPlants = useMemo(() => {
let result = plants;
if (activeCategory !== "all") {
result = result.filter((p) => p.category === activeCategory);
}
const q = searchQuery.toLowerCase().trim();
if (q) {
result = result.filter(
(p) =>
p.commonName.toLowerCase().includes(q) ||
p.scientificName.toLowerCase().includes(q) ||
p.family.toLowerCase().includes(q) ||
p.diseases.some((d) => d.name.toLowerCase().includes(q))
);
}
return result;
}, [activeCategory, searchQuery]);
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Page header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">
Browse Plants
</h1>
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
Explore our database of {plants.length} plants and their common
diseases.
</p>
</div>
{/* Search bar */}
<div className="relative mb-6">
<label htmlFor="browse-search" className="sr-only">
Search plants and diseases
</label>
<div className="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or disease..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
/>
</div>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
{/* Category filter chips */}
<div
className="flex flex-wrap gap-2 mb-8"
role="tablist"
aria-label="Plant categories"
>
{PLANT_CATEGORIES.map((cat) => (
<button
key={cat.value}
role="tab"
aria-selected={activeCategory === cat.value}
onClick={() => setActiveCategory(cat.value as Category)}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2 ${
activeCategory === cat.value
? "bg-leaf-green-600 text-white shadow-sm"
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
}`}
>
{cat.label}
</button>
))}
</div>
{/* Results count */}
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{filteredPlants.length === 0
? "No plants found"
: `Showing ${filteredPlants.length} ${filteredPlants.length === 1 ? "plant" : "plants"}`}
{activeCategory !== "all" &&
` in ${PLANT_CATEGORIES.find((c) => c.value === activeCategory)?.label.toLowerCase()}`}
{searchQuery.trim() && ` matching "${searchQuery.trim()}"`}
</p>
{/* Plant grid or empty state */}
{filteredPlants.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredPlants.map((plant) => (
<PlantCard key={plant.id} plant={plant} />
))}
</div>
) : (
<EmptyState
illustration="🔍"
title="No plants found"
description={
searchQuery.trim()
? `We couldn't find any plants matching "${searchQuery.trim()}". Try a different search term or browse a different category.`
: "No plants in this category yet. We're constantly adding new plants to our database."
}
actionLabel="Clear filters"
actionHref="/browse"
/>
)}
</div>
);
}