search, db integration
This commit is contained in:
1
apps/web/drizzle/0001_add-disease-images.sql
Normal file
1
apps/web/drizzle/0001_add-disease-images.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `diseases` ADD `image_url` text DEFAULT '' NOT NULL;
|
||||
348
apps/web/drizzle/meta/0001_snapshot.json
Normal file
348
apps/web/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
23
apps/web/scripts/apply-migration.ts
Normal file
23
apps/web/scripts/apply-migration.ts
Normal 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);
|
||||
215
apps/web/scripts/scrape-disease-images.ts
Normal file
215
apps/web/scripts/scrape-disease-images.ts
Normal 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); });
|
||||
62
apps/web/scripts/test-wiki-images.ts
Normal file
62
apps/web/scripts/test-wiki-images.ts
Normal 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);
|
||||
98
apps/web/src/app/api/plants/suggestions/route.ts
Normal file
98
apps/web/src/app/api/plants/suggestions/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
50
apps/web/src/components/FeaturedPlantsSection.tsx
Normal file
50
apps/web/src/components/FeaturedPlantsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
350
apps/web/src/components/SearchSuggestions.tsx
Normal file
350
apps/web/src/components/SearchSuggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/lib/api/browse.ts
Normal file
94
apps/web/src/lib/api/browse.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
44
apps/web/src/lib/api/home.ts
Normal file
44
apps/web/src/lib/api/home.ts
Normal 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 },
|
||||
);
|
||||
@@ -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()
|
||||
|
||||
47
apps/web/src/lib/display-helpers.ts
Normal file
47
apps/web/src/lib/display-helpers.ts
Normal 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.`;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user