- 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)
170 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|