From 71d7a9d6f057fe5f1606cdb063ab360db9e81ae5 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 5 Jun 2026 21:47:00 -0400 Subject: [PATCH] search, db integration --- apps/web/drizzle/0001_add-disease-images.sql | 1 + apps/web/drizzle/meta/0001_snapshot.json | 348 +++++++++++++++++ apps/web/drizzle/meta/_journal.json | 7 + apps/web/scripts/apply-migration.ts | 23 ++ apps/web/scripts/scrape-disease-images.ts | 215 +++++++++++ apps/web/scripts/test-wiki-images.ts | 62 ++++ .../src/app/api/plants/suggestions/route.ts | 98 +++++ .../web/src/app/browse/BrowseContent.test.tsx | 83 ++++- apps/web/src/app/browse/BrowseContent.tsx | 33 +- apps/web/src/app/browse/[plantId]/page.tsx | 123 +++--- apps/web/src/app/browse/page.tsx | 12 +- apps/web/src/app/page.test.tsx | 25 +- apps/web/src/app/page.tsx | 29 +- .../src/components/FeaturedPlantsSection.tsx | 50 +++ apps/web/src/components/Navbar.tsx | 98 +---- apps/web/src/components/PlantCard.test.tsx | 39 +- apps/web/src/components/PlantCard.tsx | 30 +- apps/web/src/components/SearchSuggestions.tsx | 350 ++++++++++++++++++ apps/web/src/lib/api/browse.ts | 94 +++++ apps/web/src/lib/api/diseases-db.ts | 1 + apps/web/src/lib/api/home.ts | 44 +++ apps/web/src/lib/db/schema.ts | 1 + apps/web/src/lib/display-helpers.ts | 47 +++ apps/web/src/lib/types.ts | 2 + .../hyper-specific-plant-disease-id/README.md | 2 +- 25 files changed, 1573 insertions(+), 244 deletions(-) create mode 100644 apps/web/drizzle/0001_add-disease-images.sql create mode 100644 apps/web/drizzle/meta/0001_snapshot.json create mode 100644 apps/web/scripts/apply-migration.ts create mode 100644 apps/web/scripts/scrape-disease-images.ts create mode 100644 apps/web/scripts/test-wiki-images.ts create mode 100644 apps/web/src/app/api/plants/suggestions/route.ts create mode 100644 apps/web/src/components/FeaturedPlantsSection.tsx create mode 100644 apps/web/src/components/SearchSuggestions.tsx create mode 100644 apps/web/src/lib/api/browse.ts create mode 100644 apps/web/src/lib/api/home.ts create mode 100644 apps/web/src/lib/display-helpers.ts diff --git a/apps/web/drizzle/0001_add-disease-images.sql b/apps/web/drizzle/0001_add-disease-images.sql new file mode 100644 index 0000000..ce79e85 --- /dev/null +++ b/apps/web/drizzle/0001_add-disease-images.sql @@ -0,0 +1 @@ +ALTER TABLE `diseases` ADD `image_url` text DEFAULT '' NOT NULL; \ No newline at end of file diff --git a/apps/web/drizzle/meta/0001_snapshot.json b/apps/web/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..1560246 --- /dev/null +++ b/apps/web/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index b8aee1f..62a3f78 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/web/scripts/apply-migration.ts b/apps/web/scripts/apply-migration.ts new file mode 100644 index 0000000..780d9b2 --- /dev/null +++ b/apps/web/scripts/apply-migration.ts @@ -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); diff --git a/apps/web/scripts/scrape-disease-images.ts b/apps/web/scripts/scrape-disease-images.ts new file mode 100644 index 0000000..7d855db --- /dev/null +++ b/apps/web/scripts/scrape-disease-images.ts @@ -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 { + 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 { + 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 { + 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 { + 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); }); diff --git a/apps/web/scripts/test-wiki-images.ts b/apps/web/scripts/test-wiki-images.ts new file mode 100644 index 0000000..05e60bc --- /dev/null +++ b/apps/web/scripts/test-wiki-images.ts @@ -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 } }; +} + +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); diff --git a/apps/web/src/app/api/plants/suggestions/route.ts b/apps/web/src/app/api/plants/suggestions/route.ts new file mode 100644 index 0000000..6d27546 --- /dev/null +++ b/apps/web/src/app/api/plants/suggestions/route.ts @@ -0,0 +1,98 @@ +/** + * GET /api/plants/suggestions?q= + * + * 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 }); +} diff --git a/apps/web/src/app/browse/BrowseContent.test.tsx b/apps/web/src/app/browse/BrowseContent.test.tsx index a793674..856980b 100644 --- a/apps/web/src/app/browse/BrowseContent.test.tsx +++ b/apps/web/src/app/browse/BrowseContent.test.tsx @@ -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 }) => (
{plant.commonName} - {plant.emoji}
), })); @@ -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(); + render(); expect(screen.getByText("Browse Plants")).toBeInTheDocument(); }); it("renders search input", () => { - render(); + render(); const searchInput = screen.getByRole("searchbox", { name: /Search plants and diseases/i, }); @@ -49,7 +100,7 @@ describe("BrowseContent", () => { }); it("filters plants by search query", () => { - render(); + render(); const searchInput = screen.getByRole("searchbox") as HTMLInputElement; fireEvent.change(searchInput, { target: { value: "tomato" } }); @@ -59,12 +110,12 @@ describe("BrowseContent", () => { }); it("shows results count", () => { - render(); + render(); expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument(); }); it("renders category filter tabs", () => { - render(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); // 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(); + render(); 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(); + render(); const searchInput = screen.getByRole("searchbox") as HTMLInputElement; fireEvent.change(searchInput, { target: { value: "solanaceae" } }); diff --git a/apps/web/src/app/browse/BrowseContent.tsx b/apps/web/src/app/browse/BrowseContent.tsx index cae7db3..299065b 100644 --- a/apps/web/src/app/browse/BrowseContent.tsx +++ b/apps/web/src/app/browse/BrowseContent.tsx @@ -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("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 (
{/* Page header */}
-

- Browse Plants -

+

Browse Plants

- Explore our database of {plants.length} plants and their common - diseases. + Explore our database of {allPlants.length} plants and their common diseases.

@@ -79,7 +80,7 @@ export default function BrowseContent() { 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() {
{/* Category filter chips */} -
+
{PLANT_CATEGORIES.map((cat) => ( -
- + {/* Desktop search */} + {/* Mobile hamburger button */} - + setMobileOpen(false)} + />
diff --git a/apps/web/src/components/PlantCard.test.tsx b/apps/web/src/components/PlantCard.test.tsx index cf58bbc..dd6bde9 100644 --- a/apps/web/src/components/PlantCard.test.tsx +++ b/apps/web/src/components/PlantCard.test.tsx @@ -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(); - expect(screen.getByText("๐Ÿ…")).toBeInTheDocument(); + // Vegetable category โ†’ ๐Ÿฅฌ emoji + expect(screen.getByText("๐Ÿฅฌ")).toBeInTheDocument(); }); it("renders plant family", () => { diff --git a/apps/web/src/components/PlantCard.tsx b/apps/web/src/components/PlantCard.tsx index bfb7731..5d9b1fe 100644 --- a/apps/web/src/components/PlantCard.tsx +++ b/apps/web/src/components/PlantCard.tsx @@ -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 ( - {plant.imageEmoji} + {emoji} @@ -37,14 +43,12 @@ export default function PlantCard({

{plant.commonName}

-

- {plant.family} -

+

{plant.family}

{showDiseaseCount && (

- {diseaseCount === 0 + {plant.diseaseCount === 0 ? "No known diseases in database" - : `${diseaseCount} ${diseaseCount === 1 ? "disease" : "diseases"} tracked`} + : `${plant.diseaseCount} ${plant.diseaseCount === 1 ? "disease" : "diseases"} tracked`}

)} diff --git a/apps/web/src/components/SearchSuggestions.tsx b/apps/web/src/components/SearchSuggestions.tsx new file mode 100644 index 0000000..24589a2 --- /dev/null +++ b/apps/web/src/components/SearchSuggestions.tsx @@ -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 element */ + inputClassName?: string; + /** Additional CSS classes for the outer wrapper div */ + wrapperClassName?: string; + /** Additional CSS classes for the
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 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 ( + + {part} + + ); + } + 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([]); + const [showDropdown, setShowDropdown] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const debounceRef = useRef | 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) => { + 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) => { + 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 ( +
+ { + e.preventDefault(); + submitQuery(); + }} + role="search" + > +
+ + + { + 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 && ( + + + + {/* Suggestions dropdown */} + {showDropdown && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/api/browse.ts b/apps/web/src/lib/api/browse.ts new file mode 100644 index 0000000..85317cf --- /dev/null +++ b/apps/web/src/lib/api/browse.ts @@ -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 { + 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`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 { + 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`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 { + 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); +} diff --git a/apps/web/src/lib/api/diseases-db.ts b/apps/web/src/lib/api/diseases-db.ts index 244b202..e6c2472 100644 --- a/apps/web/src/lib/api/diseases-db.ts +++ b/apps/web/src/lib/api/diseases-db.ts @@ -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, }; } diff --git a/apps/web/src/lib/api/home.ts b/apps/web/src/lib/api/home.ts new file mode 100644 index 0000000..52e0736 --- /dev/null +++ b/apps/web/src/lib/api/home.ts @@ -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 }, +); diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts index cae4e7b..f1187ca 100644 --- a/apps/web/src/lib/db/schema.ts +++ b/apps/web/src/lib/db/schema.ts @@ -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() diff --git a/apps/web/src/lib/display-helpers.ts b/apps/web/src/lib/display-helpers.ts new file mode 100644 index 0000000..9230317 --- /dev/null +++ b/apps/web/src/lib/display-helpers.ts @@ -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 = { + 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 = { + 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.`; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 56d8035..d128277 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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 */ diff --git a/tasks/hyper-specific-plant-disease-id/README.md b/tasks/hyper-specific-plant-disease-id/README.md index 74a6d36..bd0a968 100644 --- a/tasks/hyper-specific-plant-disease-id/README.md +++ b/tasks/hyper-specific-plant-disease-id/README.md @@ -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