search, db integration

This commit is contained in:
2026-06-05 21:47:00 -04:00
parent 365d1281dd
commit 71d7a9d6f0
25 changed files with 1573 additions and 244 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `diseases` ADD `image_url` text DEFAULT '' NOT NULL;

View File

@@ -0,0 +1,348 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6f2de82b-c1f9-42de-b03c-1c1f0c02b7c9",
"prevId": "5471dc75-3736-4b26-b7a9-0629c9b1efa0",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": [
"plant_id"
],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": [
"causal_agent_type"
],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": [
"severity"
],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": [
"category"
],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": [
"common_name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1780704072268,
"tag": "0000_flippant_talon",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1780710023177,
"tag": "0001_add-disease-images",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,23 @@
import "dotenv/config";
import { createClient } from "@libsql/client";
async function main() {
const db = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
console.log("Applying migration: add image_url to diseases...");
await db.execute("ALTER TABLE diseases ADD COLUMN image_url TEXT DEFAULT ''");
await db.execute("UPDATE diseases SET image_url = '' WHERE image_url IS NULL");
// Mark migration as applied
await db.execute(
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('0001_add-disease-images', datetime('now'))",
);
console.log("Migration applied successfully.");
db.close();
}
main().catch(console.error);

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env node
/**
* Fetch disease images from Wikipedia/Wikimedia Commons.
*
* For each disease in the database, searches Wikipedia for its page
* and retrieves the main infobox image.
*
* Usage: cd apps/web && npx tsx scripts/scrape-disease-images.ts
*
* Rate-limited to 1 request per 300ms to be respectful.
*/
import "dotenv/config";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
const WIKI_API = "https://en.wikipedia.org/w/api.php";
const COMMONS_API = "https://commons.wikimedia.org/w/api.php";
const MIN_DELAY_MS = 350; // Be respectful
let lastCall = 0;
async function rateLimit() {
const now = Date.now();
const elapsed = now - lastCall;
if (elapsed < MIN_DELAY_MS) {
await new Promise((r) => setTimeout(r, MIN_DELAY_MS - elapsed));
}
lastCall = Date.now();
}
interface WikiSearchResult {
title: string;
pageid: number;
}
async function searchWikipedia(term: string): Promise<WikiSearchResult | null> {
await rateLimit();
const url = `${WIKI_API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=1&origin=*`;
try {
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
const data = await res.json() as any;
const results = data?.query?.search;
if (results && results.length > 0) {
return { title: results[0].title, pageid: results[0].pageid };
}
} catch {
// ignore
}
return null;
}
async function getPageImage(title: string): Promise<string | null> {
await rateLimit();
const url = `${WIKI_API}?action=query&titles=${encodeURIComponent(title)}&prop=pageimages&format=json&pithumbsize=400&origin=*`;
try {
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
const data = await res.json() as any;
const pages = data?.query?.pages;
if (pages) {
const page = Object.values(pages)[0] as any;
if (page?.thumbnail?.source) {
return page.thumbnail.source;
}
}
} catch {
// ignore
}
return null;
}
async function searchCommons(term: string): Promise<string | null> {
await rateLimit();
const url = `${COMMONS_API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=3&origin=*`;
try {
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
const data = await res.json() as any;
const results = data?.query?.search;
if (results && results.length > 0) {
// Try to get thumbnail for best match
for (const r of results.slice(0, 2)) {
const imgUrl = await getCommonsImage(r.title);
if (imgUrl) return imgUrl;
}
}
} catch {
// ignore
}
return null;
}
async function getCommonsImage(title: string): Promise<string | null> {
await rateLimit();
const url = `${COMMONS_API}?action=query&titles=${encodeURIComponent(title)}&prop=imageinfo&iiprop=url&iiurlwidth=400&format=json&origin=*`;
try {
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
const data = await res.json() as any;
const pages = data?.query?.pages;
if (pages) {
const page = Object.values(pages)[0] as any;
if (page?.imageinfo?.[0]?.thumburl) {
return page.imageinfo[0].thumburl;
}
if (page?.imageinfo?.[0]?.url) {
return page.imageinfo[0].url;
}
}
} catch {
// ignore
}
return null;
}
async function main() {
console.log("🔍 Fetching disease images from Wikipedia\n");
const db = getDb();
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
// Get all diseases without images
const rows = await db
.select({
id: diseases.id,
name: diseases.name,
sciName: diseases.scientificName,
plantId: diseases.plantId,
})
.from(diseases)
.where(sql`image_url IS NULL OR image_url = ''`);
console.log(`📋 ${rows.length} diseases missing images`);
if (rows.length === 0) {
console.log("✅ All diseases already have images!");
process.exit(0);
}
let found = 0;
let skipped = 0;
let batch: { sql: string; args: any[] }[] = [];
const BATCH_SIZE = 50;
let i = 0;
for (const row of rows) {
i++;
// Build search terms: try scientific name + disease name, then disease name alone
const searchTerms = [
`${row.sciName || ""} ${row.name}`.trim(),
row.name,
`${row.name} (${row.sciName})`.trim(),
].filter(Boolean);
let imageUrl: string | null = null;
for (const term of searchTerms) {
if (term.length < 3) continue;
// Try Wikipedia first
const page = await searchWikipedia(term);
if (page) {
imageUrl = await getPageImage(page.title);
if (imageUrl) break;
}
// Try Commons directly
imageUrl = await searchCommons(term);
if (imageUrl) break;
}
if (imageUrl && !imageUrl.startsWith("https://")) {
imageUrl = null;
}
if (imageUrl) {
batch.push({
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
args: [imageUrl, row.id],
});
if (i % 100 === 0) {
process.stdout.write(` 🔍 found ${found} so far...\n`);
}
found++;
} else {
skipped++;
}
// Flush batch
if (batch.length >= BATCH_SIZE) {
await rawClient.batch(
batch.map((b) => ({ sql: b.sql, args: b.args })),
"write",
);
process.stdout.write(` 📦 flushed ${batch.length} updates (${i}/${rows.length})\n`);
batch = [];
}
}
// Flush remaining
if (batch.length > 0) {
await rawClient.batch(
batch.map((b) => ({ sql: b.sql, args: b.args })),
"write",
);
process.stdout.write(` 📦 final flush: ${batch.length} updates\n`);
}
rawClient.close();
closeDb();
console.log(`\n✅ Done! Found images: ${found} | Skipped: ${skipped}`);
}
main().catch((err) => { console.error("❌ Fatal:", err); process.exit(1); });

View File

@@ -0,0 +1,62 @@
/**
* Quick test of Wikipedia image API for disease search terms.
* Run: cd apps/web && npx tsx scripts/test-wiki-images.ts
*/
const API = "https://en.wikipedia.org/w/api.php";
async function search(term: string) {
const url = `${API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=1&origin=*`;
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
return await res.json() as { query?: { search?: Array<{ title: string; pageid: number }> } };
}
async function getImg(title: string) {
const url = `${API}?action=query&titles=${encodeURIComponent(title)}&prop=pageimages&format=json&pithumbsize=400&origin=*`;
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
return await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source: string } }> } };
}
async function testOne(term: string) {
const s = await search(term);
const page = s?.query?.search?.[0];
if (page) {
const img = await getImg(page.title);
const pages = img?.query?.pages;
if (!pages) { console.log(term, '→ NO PAGES'); return; }
const first = Object.values(pages)[0] as { thumbnail?: { source: string } };
const thumb = first?.thumbnail?.source;
console.log(`${term.padEnd(40)}${page.title.padEnd(50)}${thumb ?? "NO IMG"}`);
} else {
console.log(`${term.padEnd(40)} → NO PAGE`);
}
await new Promise((r) => setTimeout(r, 400));
}
async function main() {
const tests = [
"Phytophthora infestans Late Blight",
"Early Blight",
"Septoria Leaf Spot",
"Powdery Mildew",
"Fusarium oxysporum",
"Citrus Canker",
"Root Rot Pythium",
"Downy Mildew Peronospora",
"Bacterial Leaf Spot Xanthomonas",
"Apple Scab Venturia inaequalis",
"Fire Blight Erwinia amylovora",
"Blossom End Rot",
"Tomato Mosaic Virus",
"Rust Puccinia",
"Black Spot Diplocarpon rosae",
"Sooty Mold Capnodium",
"Clubroot Plasmodiophora brassicae",
"Anthracnose Colletotrichum",
];
console.log("Searching Wikipedia for disease images...\n");
for (const t of tests) {
await testOne(t);
}
}
main().catch(console.error);

View File

@@ -0,0 +1,98 @@
/**
* GET /api/plants/suggestions?q=<term>
*
* Returns autocomplete suggestions for the navbar search-as-you-type feature.
* Queries both plants and diseases from the database and returns an interleaved
* list with at most 8 suggestions total.
*
* Each suggestion includes: type (plant|disease), id, label, subtitle, emoji, href.
* Plants link to their browse detail page; diseases link to the plant page with
* a hash anchor to the specific disease card.
*/
import { NextRequest, NextResponse } from "next/server";
import { like, or, eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plants, diseases } from "@/lib/db/schema";
import { getEmojiForCategory } from "@/lib/display-helpers";
export const dynamic = "force-dynamic";
interface SuggestionItem {
type: "plant" | "disease";
id: string;
label: string;
subtitle: string;
emoji: string;
href: string;
}
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
// Empty or very short queries return no suggestions
if (q.length < 1) {
return NextResponse.json({ suggestions: [] });
}
const db = getDb();
const term = `%${q.toLowerCase()}%`;
// Fetch matching plants (by common name or scientific name)
const plantRows = await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
category: plants.category,
})
.from(plants)
.where(or(like(plants.commonName, term), like(plants.scientificName, term)))
.limit(5);
// Fetch matching diseases (by name or scientific name) with parent plant info
const diseaseRows = await db
.select({
id: diseases.id,
name: diseases.name,
plantId: diseases.plantId,
plantCommonName: plants.commonName,
plantCategory: plants.category,
})
.from(diseases)
.leftJoin(plants, eq(diseases.plantId, plants.id))
.where(or(like(diseases.name, term), like(diseases.scientificName, term)))
.limit(5);
const plantSuggestions: SuggestionItem[] = plantRows.map((p) => ({
type: "plant" as const,
id: p.id,
label: p.commonName,
subtitle: p.scientificName,
emoji: getEmojiForCategory(p.category),
href: `/browse/${p.id}`,
}));
const diseaseSuggestions: SuggestionItem[] = diseaseRows.map((d) => ({
type: "disease" as const,
id: d.id,
label: d.name,
subtitle: `Disease on ${d.plantCommonName ?? "Unknown plant"}`,
emoji: getEmojiForCategory(d.plantCategory ?? "houseplant"),
href: `/browse/${d.plantId}#disease-${d.id}`,
}));
// Interleave plant and disease results so the dropdown shows variety
const interleaved: SuggestionItem[] = [];
const maxLen = Math.max(plantSuggestions.length, diseaseSuggestions.length);
for (let i = 0; i < maxLen && interleaved.length < 8; i++) {
if (i < plantSuggestions.length) {
interleaved.push(plantSuggestions[i]);
}
if (i < diseaseSuggestions.length && interleaved.length < 8) {
interleaved.push(diseaseSuggestions[i]);
}
}
return NextResponse.json({ suggestions: interleaved });
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import BrowseContent from "@/app/browse/BrowseContent";
import type { PlantCardData } from "@/components/PlantCard";
// Mock Next.js navigation
vi.mock("next/navigation", () => ({
@@ -11,10 +12,9 @@ vi.mock("next/navigation", () => ({
// Mock PlantCard
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: any) => (
default: ({ plant }: { plant: PlantCardData }) => (
<div data-testid={`plant-card-${plant.id}`}>
<span>{plant.commonName}</span>
<span>{plant.emoji}</span>
</div>
),
}));
@@ -30,18 +30,69 @@ vi.mock("@/components/EmptyState", () => ({
),
}));
const MOCK_PLANTS: PlantCardData[] = [
{
id: "tomato",
commonName: "Tomato",
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
diseaseCount: 15,
},
{
id: "basil",
commonName: "Basil",
scientificName: "Ocimum basilicum",
family: "Lamiaceae",
category: "herb",
diseaseCount: 3,
},
{
id: "rose",
commonName: "Rose",
scientificName: "Rosa spp.",
family: "Rosaceae",
category: "flower",
diseaseCount: 7,
},
{
id: "monstera",
commonName: "Monstera",
scientificName: "Monstera deliciosa",
family: "Araceae",
category: "houseplant",
diseaseCount: 5,
},
{
id: "snake-plant",
commonName: "Snake Plant",
scientificName: "Dracaena trifasciata",
family: "Asparagaceae",
category: "houseplant",
diseaseCount: 2,
},
{
id: "pepper",
commonName: "Bell Pepper",
scientificName: "Capsicum annuum",
family: "Solanaceae",
category: "vegetable",
diseaseCount: 9,
},
];
describe("BrowseContent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders page header with plant count", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
expect(screen.getByText("Browse Plants")).toBeInTheDocument();
});
it("renders search input", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox", {
name: /Search plants and diseases/i,
});
@@ -49,7 +100,7 @@ describe("BrowseContent", () => {
});
it("filters plants by search query", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
@@ -59,12 +110,12 @@ describe("BrowseContent", () => {
});
it("shows results count", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument();
});
it("renders category filter tabs", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const tablist = screen.getByRole("tablist", { name: /Plant categories/i });
expect(tablist).toBeInTheDocument();
@@ -74,7 +125,7 @@ describe("BrowseContent", () => {
});
it("filters by category when tab is clicked", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const tabs = screen.getAllByRole("tab");
// Click a category tab (not 'all')
@@ -86,7 +137,7 @@ describe("BrowseContent", () => {
});
it("clears search when clear button is clicked", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
@@ -99,7 +150,7 @@ describe("BrowseContent", () => {
});
it("shows empty state when no plants match search", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
@@ -108,7 +159,7 @@ describe("BrowseContent", () => {
});
it("shows empty state with search query in description", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
@@ -117,7 +168,7 @@ describe("BrowseContent", () => {
});
it("shows matching text in results count", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "tomato" } });
@@ -126,14 +177,14 @@ describe("BrowseContent", () => {
});
it("renders all plant cards when no filter applied", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
// Should show all plants
const plantCards = screen.getAllByTestId(/plant-card-/);
expect(plantCards.length).toBeGreaterThan(0);
expect(plantCards.length).toBe(MOCK_PLANTS.length);
});
it("searches by scientific name", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanum" } });
@@ -142,7 +193,7 @@ describe("BrowseContent", () => {
});
it("searches by family name", () => {
render(<BrowseContent />);
render(<BrowseContent allPlants={MOCK_PLANTS} />);
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "solanaceae" } });

View File

@@ -4,16 +4,21 @@ 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";
import type { PlantCardData } from "@/components/PlantCard";
type Category = Plant["category"] | "all";
interface BrowseContentProps {
allPlants: PlantCardData[];
}
type Category = string | "all";
/**
* Client component that handles the interactive browse/search/filter logic.
* Receives all plants as props from the parent server component.
* Wrapped in a Suspense boundary in the parent page.
*/
export default function BrowseContent() {
export default function BrowseContent({ allPlants }: BrowseContentProps) {
const searchParams = useSearchParams();
const initialSearch = searchParams.get("search") || "";
@@ -21,7 +26,7 @@ export default function BrowseContent() {
const [activeCategory, setActiveCategory] = useState<Category>("all");
const filteredPlants = useMemo(() => {
let result = plants;
let result = allPlants;
if (activeCategory !== "all") {
result = result.filter((p) => p.category === activeCategory);
@@ -33,24 +38,20 @@ export default function BrowseContent() {
(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))
p.family.toLowerCase().includes(q),
);
}
return result;
}, [activeCategory, searchQuery]);
}, [activeCategory, searchQuery, allPlants]);
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>
<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.
Explore our database of {allPlants.length} plants and their common diseases.
</p>
</div>
@@ -79,7 +80,7 @@ export default function BrowseContent() {
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or disease..."
placeholder="Search by plant name, scientific name, or family..."
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"
@@ -112,11 +113,7 @@ export default function BrowseContent() {
</div>
{/* Category filter chips */}
<div
className="flex flex-wrap gap-2 mb-8"
role="tablist"
aria-label="Plant categories"
>
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Plant categories">
{PLANT_CATEGORIES.map((cat) => (
<button
key={cat.value}

View File

@@ -1,44 +1,50 @@
import React from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPlantById, type Disease } from "@/data/plants";
import type { Metadata } from "next";
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
import { getEmojiForCategory, getPlantDescription } from "@/lib/display-helpers";
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
interface Props {
params: Promise<{ plantId: string }>;
}
export async function generateStaticParams() {
const { plants } = await import("@/data/plants");
return plants.map((plant) => ({
plantId: plant.id,
const { getDb } = await import("@/lib/db/index");
const { plants } = await import("@/lib/db/schema");
const db = getDb();
const rows = await db.select({ id: plants.id }).from(plants);
return rows.map((p: { id: string }) => ({
plantId: p.id,
}));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { plantId } = await params;
const plant = getPlantById(plantId);
const result = await getPlantWithDiseases(plantId);
if (!plant) {
if (!result) {
return { title: "Plant Not Found" };
}
return {
title: `${plant.commonName} — Diseases & Care`,
description: `Learn about ${plant.commonName} (${plant.scientificName}) diseases, symptoms, causes, and treatments. ${plant.diseases.length} diseases documented.`,
title: `${result.plant.commonName} — Diseases & Care`,
description: `Learn about ${result.plant.commonName} (${result.plant.scientificName}) diseases, symptoms, causes, and treatments. ${result.diseases.length} diseases documented.`,
};
}
/* ─── Severity badge ─── */
function SeverityBadge({ severity }: { severity: Disease["severity"] }) {
const colors: Record<Disease["severity"], string> = {
// ─── Severity badge ───
function SeverityBadge({ severity }: { severity: Severity }) {
const colors: Record<Severity, string> = {
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
moderate: "bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
moderate:
"bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const labels: Record<Disease["severity"], string> = {
const labels: Record<Severity, string> = {
low: "Low",
moderate: "Moderate",
high: "High",
@@ -55,26 +61,27 @@ function SeverityBadge({ severity }: { severity: Disease["severity"] }) {
);
}
/* ─── Disease type badge ─── */
function TypeBadge({ type }: { type: Disease["type"] }) {
const colors: Record<Disease["type"], string> = {
// ─── Disease type badge ───
function TypeBadge({ type }: { type: CausalAgentType }) {
const colors: Record<CausalAgentType, string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
pest: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
physiological: "bg-zinc-100 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-300",
environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
{type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
/* ─── Disease card (expandable) ─── */
// ─── Disease card ───
function DiseaseCard({ disease }: { disease: Disease }) {
return (
<div
@@ -95,11 +102,23 @@ function DiseaseCard({ disease }: { disease: Disease }) {
)}
</div>
<div className="flex flex-wrap gap-2">
<TypeBadge type={disease.type} />
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
{/* Disease image */}
{disease.imageUrl && (
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
<img
src={disease.imageUrl}
alt={`${disease.name} symptoms on ${disease.plantId}`}
className="w-full h-48 sm:h-64 object-cover"
loading="lazy"
/>
</div>
)}
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
{disease.description}
</p>
@@ -148,11 +167,8 @@ function DiseaseCard({ disease }: { disease: Disease }) {
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatmentSteps.map((step, i) => (
<li
key={i}
className="text-sm text-zinc-600 dark:text-zinc-300"
>
{disease.treatment.map((step, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
{step}
</li>
))}
@@ -165,7 +181,7 @@ function DiseaseCard({ disease }: { disease: Disease }) {
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<ul className="space-y-1.5">
{disease.preventionTips.map((tip, i) => (
{disease.prevention.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
@@ -182,35 +198,49 @@ function DiseaseCard({ disease }: { disease: Disease }) {
);
}
/* ─── Plant Detail Page ─── */
// ─── Plant Detail Page ───
export default async function PlantDetailPage({ params }: Props) {
const { plantId } = await params;
const plant = getPlantById(plantId);
const result = await getPlantWithDiseases(plantId);
if (!plant) {
if (!result) {
notFound();
}
const { plant, diseases } = result;
const emoji = getEmojiForCategory(plant.category);
const description = getPlantDescription(
plant.commonName,
plant.scientificName,
plant.category,
plant.family,
);
return (
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<li>
<Link href="/" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
<Link
href="/"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Home
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link href="/browse" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
<Link
href="/browse"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Browse
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">
{plant.commonName}
</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
</ol>
</nav>
@@ -219,7 +249,7 @@ export default async function PlantDetailPage({ params }: Props) {
{/* Emoji illustration */}
<div className="flex items-center justify-center h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
<span className="text-6xl sm:text-7xl" role="img" aria-hidden="true">
{plant.imageEmoji}
{emoji}
</span>
</div>
@@ -233,11 +263,10 @@ export default async function PlantDetailPage({ params }: Props) {
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Family: <span className="font-medium">{plant.family}</span>
{" · "}
Category:{" "}
<span className="font-medium capitalize">{plant.category}</span>
Category: <span className="font-medium capitalize">{plant.category}</span>
</p>
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{plant.description}
{description}
</p>
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true">💚</span>
@@ -258,7 +287,7 @@ export default async function PlantDetailPage({ params }: Props) {
</p>
</div>
<Link
href="/browse"
href="/upload"
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
📸 Identify a Disease
@@ -272,20 +301,22 @@ export default async function PlantDetailPage({ params }: Props) {
Known Diseases
</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{plant.diseases.length === 0
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${plant.diseases.length} ${plant.diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
: `${diseases.length} ${diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{plant.diseases.length > 0 ? (
{diseases.length > 0 ? (
<div className="space-y-6">
{plant.diseases.map((disease) => (
{diseases.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">🌿</span>
<span className="text-4xl block mb-3" aria-hidden="true">
🌿
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
Disease data for {plant.commonName} is being researched and will be added soon.
</p>

View File

@@ -1,12 +1,16 @@
import React, { Suspense } from "react";
import { getBrowsePlants } from "@/lib/api/browse";
import BrowseContent from "./BrowseContent";
import { PlantCardSkeleton } from "@/components/LoadingSkeleton";
/**
* Browse page requires a Suspense boundary because it uses useSearchParams().
* The actual interactive content is in BrowseContent (client component).
* Browse page — fetches plants with disease counts from the database
* and passes them to the client-side search/filter component.
* Requires a Suspense boundary because BrowseContent uses useSearchParams().
*/
export default function BrowsePage() {
export default async function BrowsePage() {
const allPlants = await getBrowsePlants();
return (
<Suspense
fallback={
@@ -28,7 +32,7 @@ export default function BrowsePage() {
</div>
}
>
<BrowseContent />
<BrowseContent allPlants={allPlants} />
</Suspense>
);
}

View File

@@ -2,26 +2,20 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Page from "@/app/page";
// Mock PlantCard
vi.mock("@/components/PlantCard", () => ({
default: ({ plant }: { plant: any }) => (
<div data-testid={`plant-card-${plant.id}`}>{plant.commonName}</div>
// Mock FeaturedPlantsSection (async server component — mocked for testing)
vi.mock("@/components/FeaturedPlantsSection", () => ({
FeaturedPlantsGrid: () => (
<>
<div data-testid="plant-card-tomato">Tomato</div>
<div data-testid="plant-card-pepper">Pepper</div>
<div data-testid="plant-card-cucumber">Cucumber</div>
</>
),
}));
// Mock data/plants
vi.mock("@/data/plants", () => ({
getFeaturedPlants: vi.fn(() => [
{ id: "tomato", commonName: "Tomato", imageEmoji: "🍅", diseases: [] },
{ id: "pepper", commonName: "Pepper", imageEmoji: "🌶️", diseases: [] },
{ id: "cucumber", commonName: "Cucumber", imageEmoji: "🥒", diseases: [] },
]),
}));
describe("Homepage (page.tsx)", () => {
it("renders hero section with title", () => {
render(<Page />);
// Hero section has the app tagline
expect(screen.getByText(/Snap. Identify. Treat/i)).toBeInTheDocument();
});
@@ -66,8 +60,7 @@ describe("Homepage (page.tsx)", () => {
it("renders trust signals", () => {
render(<Page />);
// Trust signals should be present
const trustSignals = screen.queryAllByText(/95/i);
const trustSignals = screen.queryAllByText(/300\+ plants/i);
expect(trustSignals.length).toBeGreaterThanOrEqual(0);
});

View File

@@ -1,12 +1,8 @@
import React from "react";
import Link from "next/link";
import PlantCard from "@/components/PlantCard";
import { getFeaturedPlants } from "@/data/plants";
import { FeaturedPlantsGrid } from "@/components/FeaturedPlantsSection";
import { TRUST_SIGNALS, HOW_IT_WORKS, APP_NAME, APP_TAGLINE } from "@/lib/constants";
export default function HomePage() {
const featuredPlants = getFeaturedPlants();
return (
<div className="flex flex-col">
{/* ─── Hero Section ─── */}
@@ -29,9 +25,8 @@ export default function HomePage() {
</h1>
<p className="mt-4 text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 max-w-xl">
Upload a photo of your plant and get a hyper-specific disease
diagnosis with treatment steps, prevention tips, and confidence
scores all within seconds.
Upload a photo of your plant and get a hyper-specific disease diagnosis with treatment
steps, prevention tips, and confidence scores all within seconds.
</p>
{/* Upload CTA area */}
@@ -51,7 +46,10 @@ export default function HomePage() {
Tap to upload a photo and get started
</span>
</div>
<span className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform" aria-hidden="true">
<span
className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform"
aria-hidden="true"
>
</span>
</Link>
@@ -85,10 +83,7 @@ export default function HomePage() {
<div className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-3">
{HOW_IT_WORKS.map((step, index) => (
<div
key={step.step}
className="relative flex flex-col items-center text-center"
>
<div key={step.step} className="relative flex flex-col items-center text-center">
{/* Connector line (desktop) */}
{index < HOW_IT_WORKS.length - 1 && (
<div
@@ -141,9 +136,7 @@ export default function HomePage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredPlants.map((plant) => (
<PlantCard key={plant.id} plant={plant} />
))}
<FeaturedPlantsGrid />
</div>
</div>
</section>
@@ -158,8 +151,8 @@ export default function HomePage() {
Open Source &amp; Community Driven
</h2>
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
{APP_NAME} is free and open source. Contributions, feedback, and
plant data are welcome from gardeners and developers alike.
{APP_NAME} is free and open source. Contributions, feedback, and plant data are welcome
from gardeners and developers alike.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
<Link

View File

@@ -0,0 +1,50 @@
import React, { Suspense } from "react";
import PlantCard from "@/components/PlantCard";
import { getFeaturedPlants } from "@/lib/api/home";
/**
* Featured plants section — fetches plant data from the DB and renders cards.
* This is an async server component, wrapped in Suspense by the parent.
*/
export default async function FeaturedPlantsSection() {
const featuredPlants = await getFeaturedPlants();
return (
<>
{featuredPlants.map((plant) => (
<PlantCard key={plant.id} plant={plant} />
))}
</>
);
}
function LoadingFallback() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden animate-pulse"
>
<div className="h-40 bg-zinc-200 dark:bg-zinc-700" />
<div className="p-4 space-y-2">
<div className="h-4 w-24 rounded bg-zinc-200 dark:bg-zinc-700" />
<div className="h-3 w-32 rounded bg-zinc-200 dark:bg-zinc-700" />
</div>
</div>
))}
</div>
);
}
/**
* Featured plants wrapper with Suspense boundary for SSR.
* Used by the homepage to avoid making the whole page async.
*/
export function FeaturedPlantsGrid() {
return (
<Suspense fallback={<LoadingFallback />}>
<FeaturedPlantsSection />
</Suspense>
);
}

View File

@@ -1,9 +1,10 @@
"use client";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, startTransition } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { APP_NAME, NAV_LINKS } from "@/lib/constants";
import SearchSuggestions from "@/components/SearchSuggestions";
/**
* Responsive global navigation bar.
@@ -14,15 +15,15 @@ import { APP_NAME, NAV_LINKS } from "@/lib/constants";
*/
export default function Navbar() {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const drawerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
// Close mobile menu on route change
useEffect(() => {
startTransition(() => {
setMobileOpen(false);
});
}, [pathname]);
// Close on Escape key
@@ -51,21 +52,7 @@ export default function Navbar() {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
},
[pathname]
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
if (q) {
router.push(`/browse?search=${encodeURIComponent(q)}`);
} else {
router.push("/browse");
}
setMobileOpen(false);
},
[searchQuery, router]
[pathname],
);
const navLinkClass = (href: string) =>
@@ -109,47 +96,12 @@ export default function Navbar() {
))}
</div>
{/* Desktop search form */}
<form
onSubmit={handleSearch}
className="hidden md:flex items-center gap-2"
role="search"
>
<div className="relative">
<label htmlFor="navbar-search" className="sr-only">
Search plants and diseases
</label>
<input
id="navbar-search"
type="search"
{/* Desktop search */}
<SearchSuggestions
placeholder="Search plants..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-56 lg:w-64 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2 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"
inputClassName="w-56 lg:w-64 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2 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"
wrapperClassName="hidden md:block"
/>
<button
type="submit"
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
aria-label="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
</div>
</form>
{/* Mobile hamburger button */}
<button
@@ -261,25 +213,11 @@ export default function Navbar() {
{/* Mobile search */}
<div className="px-4 mt-6 pt-6 border-t border-zinc-200 dark:border-zinc-800">
<form onSubmit={handleSearch} role="search">
<label htmlFor="mobile-search" className="sr-only">
Search plants and diseases
</label>
<input
id="mobile-search"
type="search"
<SearchSuggestions
placeholder="Search plants..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2.5 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"
inputClassName="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2.5 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"
onNavigate={() => setMobileOpen(false)}
/>
<button
type="submit"
className="mt-3 w-full rounded-lg bg-leaf-green-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
Search
</button>
</form>
</div>
</div>
</header>

View File

@@ -1,42 +1,16 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import PlantCard from "@/components/PlantCard";
import type { Plant } from "@/data/plants";
import type { PlantCardData } from "@/components/PlantCard";
describe("PlantCard", () => {
const mockPlant: Plant = {
const mockPlant: PlantCardData = {
id: "tomato",
commonName: "Tomato",
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetables",
description: "A popular garden vegetable.",
careSummary: "Full sun, well-drained soil.",
imageEmoji: "🍅",
diseases: [
{
id: "early-blight",
name: "Early Blight",
type: "fungal",
description: "A fungal disease.",
symptoms: ["Dark spots"],
causes: ["Fungus"],
treatmentSteps: ["Remove leaves"],
preventionTips: ["Rotate crops"],
severity: "moderate",
},
{
id: "late-blight",
name: "Late Blight",
type: "fungal",
description: "A devastating disease.",
symptoms: ["Water-soaked lesions"],
causes: ["Water mold"],
treatmentSteps: ["Remove plants"],
preventionTips: ["Use resistant varieties"],
severity: "high",
},
],
category: "vegetable",
diseaseCount: 2,
};
it("renders plant name", () => {
@@ -44,9 +18,10 @@ describe("PlantCard", () => {
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("renders plant emoji", () => {
it("renders plant emoji (generated from category)", () => {
render(<PlantCard plant={mockPlant} />);
expect(screen.getByText("🍅")).toBeInTheDocument();
// Vegetable category → 🥬 emoji
expect(screen.getByText("🥬")).toBeInTheDocument();
});
it("renders plant family", () => {

View File

@@ -1,9 +1,18 @@
import React from "react";
import Link from "next/link";
import type { Plant } from "@/data/plants";
import { getEmojiForCategory } from "@/lib/display-helpers";
export interface PlantCardData {
id: string;
commonName: string;
scientificName: string;
family: string;
category: string;
diseaseCount: number;
}
interface PlantCardProps {
plant: Plant;
plant: PlantCardData;
showDiseaseCount?: boolean;
}
@@ -11,11 +20,8 @@ interface PlantCardProps {
* Plant card showing emoji, name, family, and optional disease count.
* Used on the homepage featured section and browse grid.
*/
export default function PlantCard({
plant,
showDiseaseCount = true,
}: PlantCardProps) {
const diseaseCount = plant.diseases.length;
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
const emoji = getEmojiForCategory(plant.category);
return (
<Link
@@ -29,7 +35,7 @@ export default function PlantCard({
role="img"
aria-hidden="true"
>
{plant.imageEmoji}
{emoji}
</span>
</div>
@@ -37,14 +43,12 @@ export default function PlantCard({
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-leaf-green-700 dark:group-hover:text-leaf-green-400 transition-colors">
{plant.commonName}
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 italic mt-0.5">
{plant.family}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 italic mt-0.5">{plant.family}</p>
{showDiseaseCount && (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
{diseaseCount === 0
{plant.diseaseCount === 0
? "No known diseases in database"
: `${diseaseCount} ${diseaseCount === 1 ? "disease" : "diseases"} tracked`}
: `${plant.diseaseCount} ${plant.diseaseCount === 1 ? "disease" : "diseases"} tracked`}
</p>
)}
</div>

View File

@@ -0,0 +1,350 @@
"use client";
import React, { useState, useCallback, useEffect, useRef, useId } from "react";
import { useRouter } from "next/navigation";
// ─── Types ───────────────────────────────────────────────────────────────────
interface Suggestion {
type: "plant" | "disease";
id: string;
label: string;
subtitle: string;
emoji: string;
href: string;
}
export interface SearchSuggestionsProps {
/** Placeholder text for the search input */
placeholder?: string;
/** Additional CSS classes for the search <input> element */
inputClassName?: string;
/** Additional CSS classes for the outer wrapper div */
wrapperClassName?: string;
/** Additional CSS classes for the <form> element */
formClassName?: string;
/** Called after a suggestion is clicked or the search is submitted (e.g., to close a mobile drawer) */
onNavigate?: () => void;
}
// ─── Highlight helper ────────────────────────────────────────────────────────
/**
* Splits `text` on case-insensitive occurrences of `query` and wraps each match
* in a <mark> element so the user can see what part of the suggestion matched
* their typed input.
*/
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escaped})`, "gi");
const parts = text.split(regex);
const lowerQuery = query.toLowerCase();
return parts.map((part, i) => {
if (part.toLowerCase() === lowerQuery) {
return (
<mark
key={i}
className="bg-leaf-green-200 dark:bg-leaf-green-700 text-leaf-green-900 dark:text-leaf-green-100 rounded px-0.5"
>
{part}
</mark>
);
}
return part;
});
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Search-as-you-type input with a debounced suggestions dropdown.
*
* - Fetches suggestions from `/api/plants/suggestions?q=...` as the user types
* - Displays results in a dropdown with keyboard navigation (↑↓ Enter Escape)
* - Clicking a suggestion navigates directly to the plant or disease page
* - Pressing Enter (when no suggestion is highlighted) navigates to the browse
* page with the query as a search parameter
*/
export default function SearchSuggestions({
placeholder = "Search plants...",
inputClassName = "",
wrapperClassName = "",
formClassName = "",
onNavigate,
}: SearchSuggestionsProps) {
const router = useRouter();
const inputId = useId();
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ─── Fetch suggestions with debounce ─────────────────────────────────────
useEffect(() => {
const trimmed = query.trim();
// Empty query: don't fetch (the empty-input reset is handled in onChange).
if (trimmed.length < 1) return;
// Cancel any pending debounced fetch so we only fire the latest one.
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// Track whether this particular effect cycle is still active, so stale
// async responses don't overwrite later (or cleared) state.
let cancelled = false;
debounceRef.current = setTimeout(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/plants/suggestions?q=${encodeURIComponent(trimmed)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const items: Suggestion[] = data.suggestions ?? [];
if (!cancelled) {
setSuggestions(items);
setShowDropdown(items.length > 0);
setActiveIndex(-1);
}
} catch {
if (!cancelled) {
setSuggestions([]);
setShowDropdown(false);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}, 200);
return () => {
cancelled = true;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [query]);
// ─── Close dropdown on outside click ─────────────────────────────────────
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
inputRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
!inputRef.current.contains(e.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// ─── Navigation helpers ──────────────────────────────────────────────────
const navigate = useCallback(
(href: string) => {
setShowDropdown(false);
setQuery("");
setSuggestions([]);
setActiveIndex(-1);
router.push(href);
onNavigate?.();
},
[router, onNavigate],
);
const submitQuery = useCallback(() => {
const trimmed = query.trim();
if (trimmed) {
navigate(`/browse?search=${encodeURIComponent(trimmed)}`);
} else {
navigate("/browse");
}
}, [query, navigate]);
// ─── Keyboard navigation ─────────────────────────────────────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showDropdown || suggestions.length === 0) {
if (e.key === "Enter") {
e.preventDefault();
submitQuery();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && activeIndex < suggestions.length) {
navigate(suggestions[activeIndex].href);
} else {
submitQuery();
}
break;
case "Escape":
e.preventDefault();
setShowDropdown(false);
setActiveIndex(-1);
inputRef.current?.blur();
break;
}
},
[showDropdown, suggestions, activeIndex, submitQuery, navigate],
);
// ─── Suggestion click (uses mousedown so it fires before blur) ───────────
const handleSuggestionClick = useCallback(
(href: string) => (e: React.MouseEvent) => {
e.preventDefault();
navigate(href);
},
[navigate],
);
// ─── Input change handler: syncs query state AND resets suggestions
// when the user clears the input (avoids doing setState in the effect).
// ───────────────────────────────────────────────────────────────────────────
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
// When the input is cleared, immediately reset the suggestion state
// instead of doing it inside the effect (which would trigger a
// cascading-render warning).
if (!value.trim()) {
setSuggestions([]);
setShowDropdown(false);
setActiveIndex(-1);
setIsLoading(false);
}
}, []);
// ─── Render ──────────────────────────────────────────────────────────────
return (
<div className={`relative ${wrapperClassName}`}>
<form
className={formClassName}
onSubmit={(e) => {
e.preventDefault();
submitQuery();
}}
role="search"
>
<div className="relative">
<label htmlFor={inputId} className="sr-only">
{placeholder}
</label>
<input
ref={inputRef}
id={inputId}
type="search"
placeholder={placeholder}
value={query}
onChange={handleInputChange}
onFocus={() => {
if (suggestions.length > 0) setShowDropdown(true);
}}
onKeyDown={handleKeyDown}
className={inputClassName}
autoComplete="off"
aria-expanded={showDropdown}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls={showDropdown ? `${inputId}-listbox` : undefined}
aria-activedescendant={
activeIndex >= 0 ? `${inputId}-option-${activeIndex}` : undefined
}
/>
{/* Loading spinner */}
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2" aria-hidden="true">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-300 border-t-leaf-green-600 dark:border-zinc-600 dark:border-t-leaf-green-400" />
</div>
)}
</div>
</form>
{/* Suggestions dropdown */}
{showDropdown && suggestions.length > 0 && (
<div
ref={dropdownRef}
id={`${inputId}-listbox`}
role="listbox"
aria-label="Search suggestions"
className="absolute z-50 mt-1 w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-lg overflow-hidden"
>
{suggestions.map((suggestion, index) => (
<button
key={`${suggestion.type}-${suggestion.id}`}
id={`${inputId}-option-${index}`}
type="button"
role="option"
aria-selected={index === activeIndex}
onMouseDown={handleSuggestionClick(suggestion.href)}
className={`w-full flex items-center gap-3 px-3.5 py-2.5 text-left transition-colors ${
index === activeIndex
? "bg-leaf-green-50 dark:bg-leaf-green-900/30"
: "hover:bg-zinc-50 dark:hover:bg-zinc-800"
}`}
>
{/* Emoji */}
<span className="text-xl shrink-0" aria-hidden="true">
{suggestion.emoji}
</span>
{/* Text */}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
{highlightMatch(suggestion.label, query)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate mt-0.5">
{highlightMatch(suggestion.subtitle, query)}
</div>
</div>
{/* Type badge */}
<span className="text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-500 shrink-0 ml-1">
{suggestion.type === "plant" ? "Plant" : "Disease"}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Browse API — fetches plants with disease counts from the Turso DB
* for the browse page. Runs server-side only.
*/
import { sql, eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plants, diseases } from "@/lib/db/schema";
import type { PlantCardData } from "@/components/PlantCard";
export type { PlantCardData };
/**
* Get all plants with their disease counts for the browse page.
*/
export async function getBrowsePlants(): Promise<PlantCardData[]> {
const db = getDb();
// LEFT JOIN to include plants with zero diseases
const rows = await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
family: plants.family,
category: plants.category,
diseaseCount: sql<number>`COUNT(${diseases.id})`,
})
.from(plants)
.leftJoin(diseases, eq(diseases.plantId, plants.id))
.groupBy(plants.id)
.orderBy(plants.commonName);
return rows.map((r) => ({
id: r.id,
commonName: r.commonName,
scientificName: r.scientificName,
family: r.family,
category: r.category,
diseaseCount: r.diseaseCount,
}));
}
/**
* Get a single plant with disease count (for detail page lookups).
*/
export async function getBrowsePlant(id: string): Promise<PlantCardData | null> {
const db = getDb();
const rows = await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
family: plants.family,
category: plants.category,
diseaseCount: sql<number>`COUNT(${diseases.id})`,
})
.from(plants)
.leftJoin(diseases, eq(diseases.plantId, plants.id))
.where(eq(plants.id, id))
.groupBy(plants.id)
.limit(1);
return rows[0] ?? null;
}
/**
* Get featured plants for the homepage (subset).
*/
const FEATURED_IDS = [
"tomato",
"basil",
"rose",
"monstera",
"snake-plant",
"pepper",
"apple",
"corn",
"wheat",
"strawberry",
"blueberry",
"lettuce",
];
export async function getFeaturedPlants(): Promise<PlantCardData[]> {
const all = await getBrowsePlants();
const featured = all.filter((p) => FEATURED_IDS.includes(p.id));
// If fewer than expected are found, pad with first available plants
if (featured.length < 6) {
const rest = all.filter((p) => !FEATURED_IDS.includes(p.id));
return [...featured, ...rest].slice(0, 12);
}
return featured.slice(0, 12);
}

View File

@@ -50,6 +50,7 @@ function toDisease(row: typeof diseases.$inferSelect): Disease {
prevention: row.prevention as string[],
lookalikeDiseaseIds: (row.lookalikeIds as string[]) ?? [],
severity: row.severity as Severity,
imageUrl: (row.imageUrl as string) || undefined,
};
}

View File

@@ -0,0 +1,44 @@
/**
* Homepage data — fetches featured plants from the Turso DB.
* Uses React's cache() to ensure one fetch per render pass.
* Backed by the async fetch for SSR but stays sync in exported interface
* via a module-level cache pattern.
*/
import { unstable_cache } from "next/cache";
// Re-export the type for convenience
export type { PlantCardData } from "@/components/PlantCard";
/**
* Get featured plants for the homepage.
* Cached via next/cache to avoid repeated DB calls.
*/
export const getFeaturedPlants = unstable_cache(
async () => {
const { getBrowsePlants } = await import("./browse");
const all = await getBrowsePlants();
const FEATURED_IDS = [
"tomato",
"basil",
"rose",
"monstera",
"snake-plant",
"pepper",
"apple",
"corn",
"wheat",
"strawberry",
"blueberry",
"lettuce",
];
const featured = all.filter((p) => FEATURED_IDS.includes(p.id));
if (featured.length < 6) {
const rest = all.filter((p) => !FEATURED_IDS.includes(p.id));
return [...featured, ...rest].slice(0, 12);
}
return featured.slice(0, 12);
},
["featured-plants"],
{ revalidate: 3600 },
);

View File

@@ -57,6 +57,7 @@ export const diseases = sqliteTable(
severity: text("severity", {
enum: ["low", "moderate", "high", "critical"],
}).notNull(),
imageUrl: text("image_url").notNull().default(""),
sourceUrl: text("source_url").notNull().default(""),
createdAt: text("created_at")
.notNull()

View File

@@ -0,0 +1,47 @@
/**
* Display helpers for the browse UI that bridge the DB types
* to display-friendly values (emoji icons, descriptions).
*/
const CATEGORY_EMOJIS: Record<string, string> = {
vegetable: "🥬",
fruit: "🍎",
herb: "🌿",
flower: "🌸",
houseplant: "🪴",
succulent: "🌵",
tree: "🌳",
};
const FALLBACK_EMOJI = "🌱";
export function getEmojiForCategory(category: string): string {
return CATEGORY_EMOJIS[category] ?? FALLBACK_EMOJI;
}
export function getPlantDescription(
commonName: string,
scientificName: string,
category: string,
family: string,
): string {
return `${commonName} (${scientificName}) is a ${category} in the ${family} family. Preventative care and early identification of diseases are key to keeping your ${commonName.toLowerCase()} healthy.`;
}
export function getDescriptionForCategory(category: string): string {
const descriptions: Record<string, string> = {
vegetable:
"Vegetables are garden favorites grown for their edible parts. They can be affected by various fungal, bacterial, and viral diseases that impact yield and quality.",
fruit:
"Fruit plants produce delicious harvests but require attention to disease management for optimal production.",
herb: "Herbs are aromatic plants used in cooking and medicine. Most are relatively disease-resistant but can be affected in humid conditions.",
flower:
"Ornamental flowers add beauty to gardens. They may be susceptible to various foliar and root diseases.",
houseplant:
"Houseplants bring nature indoors. The most common issues are overwatering, insufficient light, and fungal leaf spots.",
succulent:
"Succulents store water in their leaves and stems. Overwatering is the most common cause of problems.",
tree: "Trees provide shade, fruit, and beauty. They can be affected by cankers, rots, wilts, and foliar diseases.",
};
return descriptions[category] ?? `This plant belongs to the ${category} category.`;
}

View File

@@ -69,6 +69,8 @@ export interface Disease {
lookalikeDiseaseIds: string[];
/** Overall severity of the disease */
severity: Severity;
/** URL to a representative image showing disease symptoms */
imageUrl?: string;
}
/** Query parameters for listing/searching plants */

View File

@@ -12,7 +12,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
- [x] 04 — ML model loading, inference pipeline, and confidence scoring → `04-ml-model-integration.md`
- [x] 05 — Results page with disease cards, symptom comparison, and treatment steps → `05-identification-results-page.md`
- [x] 06 — Responsive UI, homepage, navigation, loading states, and error handling → `06-user-interface-and-polish.md`
- [~] 07 — Test suite, Vercel deployment config, and CI pipeline → `07-testing-and-deployment.md`
- [x] 07 — Test suite, Vercel deployment config, and CI pipeline → `07-testing-and-deployment.md`
## Dependencies