From 78220d3568bdfca7b50a77c2a1826f5fbeadc12e Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 6 Jun 2026 10:15:53 -0400 Subject: [PATCH] ooooeee --- apps/web/next.config.ts | 12 +- apps/web/scripts/.image-results.json | 297 ++++++++++++ apps/web/scripts/apply-migration.ts | 2 +- apps/web/scripts/fill-disease-images.ts | 440 ++++++++++++++++++ apps/web/scripts/scrape-disease-images.ts | 314 +++++++------ apps/web/scripts/test-wiki-images.ts | 11 +- .../src/app/browse/[plantId]/DiseaseCards.tsx | 247 ++++++++++ apps/web/src/app/browse/[plantId]/page.tsx | 173 +------ apps/web/src/components/ImageLightbox.tsx | 143 ++++++ apps/web/src/lib/constants.ts | 10 +- apps/web/src/stubs/empty.ts | 1 + 11 files changed, 1315 insertions(+), 335 deletions(-) create mode 100644 apps/web/scripts/.image-results.json create mode 100644 apps/web/scripts/fill-disease-images.ts create mode 100644 apps/web/src/app/browse/[plantId]/DiseaseCards.tsx create mode 100644 apps/web/src/components/ImageLightbox.tsx create mode 100644 apps/web/src/stubs/empty.ts diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index f4257b2..abd4fc8 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,13 +2,23 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { // Turbopack config (Next.js 16 default) - turbopack: {}, + turbopack: { + resolveAlias: { + // Optional ML backends — not installed, dynamic import fallback to mock + "@tensorflow/tfjs": "./src/stubs/empty.ts", + "@tensorflow/tfjs-node": "./src/stubs/empty.ts", + "onnxruntime-node": "./src/stubs/empty.ts", + }, + }, // Webpack config (fallback) webpack: (config) => { config.resolve.alias = { ...config.resolve.alias, sharp: false, "detect-libc": false, + "@tensorflow/tfjs": false, + "@tensorflow/tfjs-node": false, + "onnxruntime-node": false, }; return config; }, diff --git a/apps/web/scripts/.image-results.json b/apps/web/scripts/.image-results.json new file mode 100644 index 0000000..67cadee --- /dev/null +++ b/apps/web/scripts/.image-results.json @@ -0,0 +1,297 @@ +{ + "early-blight": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Late_blight_on_potato_leaf_2.jpg/960px-Late_blight_on_potato_leaf_2.jpg", + "source": "wikipedia", + "quality": "good" + }, + "late-blight": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Patates.jpg/960px-Patates.jpg", + "source": "wikipedia", + "quality": "good" + }, + "blossom-end-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Blossom_end_rot.JPG/960px-Blossom_end_rot.JPG", + "source": "wikipedia", + "quality": "good" + }, + "tomato-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/6/67/Chelois.jpg", + "source": "wikipedia", + "quality": "good" + }, + "bacterial-leaf-spot-tomato": { + "url": "https://upload.wikimedia.org/wikipedia/commons/7/76/%27Cercospora_capsici.jpg", + "source": "wikipedia", + "quality": "good" + }, + "basil-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/00/Grape_Rasins_plus_Zante_Currants.jpg/960px-Grape_Rasins_plus_Zante_Currants.jpg", + "source": "wikipedia", + "quality": "good" + }, + "basil-fusarium-wilt": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Ocimum_basilicum_8zz.jpg/960px-Ocimum_basilicum_8zz.jpg", + "source": "wikipedia", + "quality": "good" + }, + "rose-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/3/36/Rosa_Anne_Harkness.jpg", + "source": "wikipedia", + "quality": "good" + }, + "rose-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/b/b8/Downy_and_Powdery_mildew_on_grape_leaf.JPG", + "source": "wikipedia", + "quality": "good" + }, + "rose-rust": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Rose-Rust-1.jpg/960px-Rose-Rust-1.jpg", + "source": "wikipedia", + "quality": "good" + }, + "monstera-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Orkide_i_finstua_mot_vest.jpg/960px-Orkide_i_finstua_mot_vest.jpg", + "source": "wikipedia", + "quality": "good" + }, + "pothos-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/960px-Money_Plant_%28Epipremnum_aureum%29_4.jpg", + "source": "wikipedia", + "quality": "good" + }, + "peace-lily-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Orkide_i_finstua_mot_vest.jpg/960px-Orkide_i_finstua_mot_vest.jpg", + "source": "wikipedia", + "quality": "good" + }, + "orchid-crown-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Orchid_Bacterial_leaf_blight_caused_by_Erwinia_sp._%2812504094455%29.jpg/960px-Orchid_Bacterial_leaf_blight_caused_by_Erwinia_sp._%2812504094455%29.jpg", + "source": "wikipedia", + "quality": "good" + }, + "orchid-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Phytophthora_cactorum.jpg/960px-Phytophthora_cactorum.jpg", + "source": "wikipedia", + "quality": "good" + }, + "orchid-bacterial-soft-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Narcissus_poeticus_subsp._radiiflorus.1658.jpg/960px-Narcissus_poeticus_subsp._radiiflorus.1658.jpg", + "source": "wikipedia", + "quality": "good" + }, + "succulent-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Peperomia_trifolia_2011-01-17.jpg/960px-Peperomia_trifolia_2011-01-17.jpg", + "source": "wikipedia", + "quality": "good" + }, + "succulent-sunburn": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Peperomia_trifolia_2011-01-17.jpg/960px-Peperomia_trifolia_2011-01-17.jpg", + "source": "wikipedia", + "quality": "good" + }, + "cucumber-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/PansyScan_%28cropped%29.jpg/960px-PansyScan_%28cropped%29.jpg", + "source": "wikipedia", + "quality": "good" + }, + "cucumber-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/PansyScan_%28cropped%29.jpg/960px-PansyScan_%28cropped%29.jpg", + "source": "wikipedia", + "quality": "good" + }, + "cucumber-angular-leaf-spot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/7/76/%27Cercospora_capsici.jpg", + "source": "wikipedia", + "quality": "good" + }, + "squash-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Golovinomyces_sordidus_on_Broadleaf_Plantain_-_Plantago_major_%2844171864324%29.jpg/960px-Golovinomyces_sordidus_on_Broadleaf_Plantain_-_Plantago_major_%2844171864324%29.jpg", + "source": "wikipedia", + "quality": "good" + }, + "squash-mosaic-virus": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Squashes_at_Kew_Gardens_IncrEdibles_2013.jpg/960px-Squashes_at_Kew_Gardens_IncrEdibles_2013.jpg", + "source": "wikipedia", + "quality": "good" + }, + "squash-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Sunflower_sky_backdrop.jpg/960px-Sunflower_sky_backdrop.jpg", + "source": "wikipedia", + "quality": "good" + }, + "bean-halo-blight": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/86/Mung_beans_%28Vigna_radiata%29.jpg/960px-Mung_beans_%28Vigna_radiata%29.jpg", + "source": "wikipedia", + "quality": "good" + }, + "bean-white-mold": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/Sunroot_top.jpg/960px-Sunroot_top.jpg", + "source": "wikipedia", + "quality": "good" + }, + "strawberry-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2a/Marechal_Foch_Grapes%2C_Nova_Scotia.jpg/960px-Marechal_Foch_Grapes%2C_Nova_Scotia.jpg", + "source": "wikipedia", + "quality": "good" + }, + "mint-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/3_types_of_lentil.png/960px-3_types_of_lentil.png", + "source": "wikipedia", + "quality": "good" + }, + "mint-rust": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Minze.jpg/960px-Minze.jpg", + "source": "wikipedia", + "quality": "good" + }, + "mint-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Monarda_clinopodia_inflorescence.jpg/960px-Monarda_clinopodia_inflorescence.jpg", + "source": "wikipedia", + "quality": "good" + }, + "lavender-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Blue_rose-artificially_coloured.jpg/960px-Blue_rose-artificially_coloured.jpg", + "source": "wikipedia", + "quality": "good" + }, + "lettuce-damping-off": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/DunhillEarlyMorningPipeMurrays.jpg/960px-DunhillEarlyMorningPipeMurrays.jpg", + "source": "wikipedia", + "quality": "good" + }, + "lettuce-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/a/a7/Lactucaserriola2web.jpg", + "source": "wikipedia", + "quality": "good" + }, + "cabbage-black-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/Cabbage_and_cross_section_on_white.jpg/960px-Cabbage_and_cross_section_on_white.jpg", + "source": "wikipedia", + "quality": "good" + }, + "sunflower-rust": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Schorseneer_plant_Scorzonera_hispanica.jpg/960px-Schorseneer_plant_Scorzonera_hispanica.jpg", + "source": "wikipedia", + "quality": "good" + }, + "sunflower-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/Sunroot_top.jpg/960px-Sunroot_top.jpg", + "source": "wikipedia", + "quality": "good" + }, + "fiddle-leaf-fig-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Orkide_i_finstua_mot_vest.jpg/960px-Orkide_i_finstua_mot_vest.jpg", + "source": "wikipedia", + "quality": "good" + }, + "aloe-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Orkide_i_finstua_mot_vest.jpg/960px-Orkide_i_finstua_mot_vest.jpg", + "source": "wikipedia", + "quality": "good" + }, + "aloe-sunburn": { + "url": "https://upload.wikimedia.org/wikipedia/commons/8/87/Hand2ndburn.jpg", + "source": "wikipedia", + "quality": "good" + }, + "jasmine-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/78/Wooly_aphidson_jasmine_plant.jpg/960px-Wooly_aphidson_jasmine_plant.jpg", + "source": "wikipedia", + "quality": "good" + }, + "jasmine-black-spot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/8/84/Olivesfromjordan.jpg", + "source": "wikipedia", + "quality": "good" + }, + "chili-cercospora-leaf-spot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/3_types_of_lentil.png/960px-3_types_of_lentil.png", + "source": "wikipedia", + "quality": "good" + }, + "eggplant-fusarium-wilt": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Fusarium_wilt_symptom_tobacco.jpg/960px-Fusarium_wilt_symptom_tobacco.jpg", + "source": "wikipedia", + "quality": "good" + }, + "spinach-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/b/b8/Downy_and_Powdery_mildew_on_grape_leaf.JPG", + "source": "wikipedia", + "quality": "good" + }, + "spinach-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Carica_papaya_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-029.jpg/960px-Carica_papaya_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-029.jpg", + "source": "wikipedia", + "quality": "good" + }, + "fern-botrytis": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/AsparagusPlumosus2.jpg/960px-AsparagusPlumosus2.jpg", + "source": "wikipedia", + "quality": "good" + }, + "zucchini-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Cucumis_metuliferus_fruit_-_whole_and_cross_section.jpg/960px-Cucumis_metuliferus_fruit_-_whole_and_cross_section.jpg", + "source": "wikipedia", + "quality": "good" + }, + "zucchini-mosaic-virus": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Squashes_at_Kew_Gardens_IncrEdibles_2013.jpg/960px-Squashes_at_Kew_Gardens_IncrEdibles_2013.jpg", + "source": "wikipedia", + "quality": "good" + }, + "zucchini-downy-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/Cabbage_and_cross_section_on_white.jpg/960px-Cabbage_and_cross_section_on_white.jpg", + "source": "wikipedia", + "quality": "good" + }, + "cactus-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Peperomia_trifolia_2011-01-17.jpg/960px-Peperomia_trifolia_2011-01-17.jpg", + "source": "wikipedia", + "quality": "good" + }, + "cactus-mealybugs": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Burmese_python_%286887388927%29.jpg/960px-Burmese_python_%286887388927%29.jpg", + "source": "wikipedia", + "quality": "good" + }, + "septoria-leaf-spot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/76/Septoria_lycopersici_malagutii_leaf_spot_on_tomato_leaf.jpg/960px-Septoria_lycopersici_malagutii_leaf_spot_on_tomato_leaf.jpg", + "source": "commons", + "quality": "good" + }, + "snake-plant-root-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/American_seed_and_plant_catalog_-_American_Seed_%26_Plant_Co._%28IA_CAT31340041%29.pdf/page1-960px-American_seed_and_plant_catalog_-_American_Seed_%26_Plant_Co._%28IA_CAT31340041%29.pdf.jpg", + "source": "commons", + "quality": "good" + }, + "snake-plant-leaf-spot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f9/Pesticides_documentation_bulletin_%28IA_CAT11110538068%29.pdf/page1-960px-Pesticides_documentation_bulletin_%28IA_CAT11110538068%29.pdf.jpg", + "source": "commons", + "quality": "good" + }, + "snake-plant-mealybugs": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/The_Philippine_journal_of_science_%28IA_philippinejo121917phil%29.pdf/page1-960px-The_Philippine_journal_of_science_%28IA_philippinejo121917phil%29.pdf.jpg", + "source": "commons", + "quality": "good" + }, + "pepper-blossom-end-rot": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/Manual_of_laboratory_diagnosis_%28IA_manualoflaborato00boll%29.pdf/page1-500px-Manual_of_laboratory_diagnosis_%28IA_manualoflaborato00boll%29.pdf.jpg", + "source": "commons", + "quality": "good" + }, + "pepper-powdery-mildew": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Loss-of-Function-in-Mlo-Orthologs-Reduces-Susceptibility-of-Pepper-and-Tomato-to-Powdery-Mildew-pone.0070723.s004.ogv/960px--Loss-of-Function-in-Mlo-Orthologs-Reduces-Susceptibility-of-Pepper-and-Tomato-to-Powdery-Mildew-pone.0070723.s004.ogv.jpg", + "source": "commons", + "quality": "good" + }, + "pepper-bacterial-wilt": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Bacterial_wilt_of_pepper_%289159744719%29.jpg/960px-Bacterial_wilt_of_pepper_%289159744719%29.jpg", + "source": "commons", + "quality": "good" + }, + "bean-common-bleach": { + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/68/Laboratory_outlines_in_plant_pathology_%28IA_laboratoryoutlin00whet%29.pdf/page1-500px-Laboratory_outlines_in_plant_pathology_%28IA_laboratoryoutlin00whet%29.pdf.jpg", + "source": "commons", + "quality": "good" + } +} \ No newline at end of file diff --git a/apps/web/scripts/apply-migration.ts b/apps/web/scripts/apply-migration.ts index 780d9b2..5564425 100644 --- a/apps/web/scripts/apply-migration.ts +++ b/apps/web/scripts/apply-migration.ts @@ -10,7 +10,7 @@ async function main() { 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'))", diff --git a/apps/web/scripts/fill-disease-images.ts b/apps/web/scripts/fill-disease-images.ts new file mode 100644 index 0000000..873cbe1 --- /dev/null +++ b/apps/web/scripts/fill-disease-images.ts @@ -0,0 +1,440 @@ +#!/usr/bin/env node +/** + * fill-disease-images.ts — Three-stage disease image pipeline + * + * For every disease without an imageUrl, tries: + * Stage 1 — Wikipedia search → pageimages + * Stage 2 — Wikimedia Commons search + * Stage 3 — Brave Image Search API (fallback, 1 req/sec, 2000/mo) + * + * Updates both diseases.json (seed) and the Turso DB. + * Flags anything found only via Brave for human review. + * + * Usage: cd apps/web && npx tsx scripts/fill-disease-images.ts + */ + +import "dotenv/config"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve } from "path"; +import { createClient } from "@libsql/client"; +import { closeDb } from "../src/lib/db/index"; + +// ─── Types & Config ────────────────────────────────────────────────────────── + +interface DiseaseSeed { + id: string; + plantId: string; + name: string; + scientificName: string; + commonName?: string; + [key: string]: unknown; +} + +interface ImageResult { + url: string; + source: "wikipedia" | "commons" | "brave" | "missing"; + quality: "good" | "fallback" | "missing"; +} + +const DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json"); +const RESULTS_FILE = resolve(__dirname, ".image-results.json"); +const REPORT_FILE = resolve(__dirname, ".image-review-needed.md"); + +const WIKI_API = "https://en.wikipedia.org/w/api.php"; +const COMMONS_API = "https://commons.wikimedia.org/w/api.php"; +const BRAVE_KEY = process.env.BRAVE_API_KEY ?? ""; +const BRAVE_DELAY = 1100; +const MAX_BRAVE = 2000; +const UA = "PlantHealthKB/1.0 (plant-disease-id)"; +const ORIGIN = "*"; + +let braveCount = 0; + +// ─── Wikipedia Stage ───────────────────────────────────────────────────────── + +/** + * Search Wikipedia and get thumbnails in ONE API call using generator=search. + * Returns first thumbnail found, or null. + */ +async function wikiSearchAndThumb(query: string): Promise { + const params = new URLSearchParams({ + action: "query", + generator: "search", + gsrsearch: query, + gsrlimit: "5", + prop: "pageimages", + pithumbsize: "600", + format: "json", + origin: ORIGIN, + }); + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetchWithTimeout(`${WIKI_API}?${params}`, { + headers: { "User-Agent": UA }, + }); + if (res.status === 429) { + await delay(3000 * 2 ** attempt); + continue; + } + if (!res.ok) return null; + const data = (await res.json()) as { + query?: { pages?: Record }; + }; + const pages = data?.query?.pages; + if (!pages) return null; + for (const [, p] of Object.entries(pages)) { + const src = (p as { thumbnail?: { source: string } })?.thumbnail?.source; + if (src) return src; + } + return null; + } catch { + await delay(2000); + } + } + return null; +} + +/** + * Try to find a Wikipedia image for a disease. + * Uses generator=search which combines search + thumbnails in one call. + */ +async function wikiStage(d: DiseaseSeed, plantName: string): Promise { + // Try 1: disease name + plant name (most specific) + return wikiSearchAndThumb(`"${d.name}" ${plantName}`); +} + +// ─── Commons Stage ─────────────────────────────────────────────────────────── + +/** Fetch with timeout. Aborts after `ms` milliseconds. */ +async function fetchWithTimeout(url: string, opts: RequestInit, ms = 15000): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + const res = await fetch(url, { ...opts, signal: ctrl.signal }); + return res; + } finally { + clearTimeout(timer); + } +} + +async function commonsSearchAndThumb(query: string): Promise { + const params = new URLSearchParams({ + action: "query", + list: "search", + srsearch: query, + srnamespace: "6", + srlimit: "5", + format: "json", + origin: ORIGIN, + }); + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetchWithTimeout(`${COMMONS_API}?${params}`, { + headers: { "User-Agent": UA }, + }); + if (res.status === 429) { + await delay(3000 * 2 ** attempt); + continue; + } + if (!res.ok) return null; + const data = (await res.json()) as { + query?: { search?: Array<{ pageid: number; title: string }> }; + }; + const hits = data?.query?.search ?? []; + if (hits.length === 0) return null; + + // Batch-fetch imageinfo for all found page IDs + const pageids = hits.map((h) => h.pageid).join("|"); + const imgParams = new URLSearchParams({ + action: "query", + pageids, + prop: "imageinfo", + iiprop: "url", + iiurlwidth: "600", + format: "json", + origin: ORIGIN, + }); + + const imgRes = await fetchWithTimeout(`${COMMONS_API}?${imgParams}`, { + headers: { "User-Agent": UA }, + }); + if (!imgRes.ok) return null; + const imgData = (await imgRes.json()) as { + query?: { pages?: Record }; + }; + const imgPages = imgData?.query?.pages; + if (!imgPages) return null; + + for (const [, pg] of Object.entries(imgPages)) { + const p = pg as Record; + const info = (p.imageinfo as Array> | undefined)?.[0]; + if (info?.thumburl) return info.thumburl as string; + if (info?.url) return info.url as string; + } + return null; + } catch { + await delay(2000); + } + } + return null; +} + +async function commonsStage(d: DiseaseSeed, plantName: string): Promise { + let q: string; + if (d.scientificName && !d.scientificName.includes("spp.") && !d.scientificName.includes("/")) { + q = `${d.scientificName} ${plantName}`; + } else { + q = `${d.name} ${plantName} disease`; + } + + const url = await commonsSearchAndThumb(q); + return url ?? null; +} + +// ─── Brave Stage ───────────────────────────────────────────────────────────── + +async function braveStage(d: DiseaseSeed, plantName: string): Promise { + if (!BRAVE_KEY || braveCount >= MAX_BRAVE) return null; + + const url = new URL("https://api.search.brave.com/res/v1/images/search"); + url.searchParams.set("q", `${d.name} ${plantName} plant disease symptom`); + url.searchParams.set("count", "5"); + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetchWithTimeout(url.toString(), { + headers: { "X-Subscription-Token": BRAVE_KEY, Accept: "application/json" }, + }); + if (res.status === 429) { + await delay(5000 * 2 ** attempt); + continue; + } + if (!res.ok) return null; + braveCount++; + const data = (await res.json()) as { + results?: Array<{ url: string; thumbnail?: { src?: string } }>; + }; + const results = data?.results ?? []; + if (results.length === 0) return null; + + // Prefer non-stock thumbnails + for (const r of results) { + const src = r.thumbnail?.src ?? r.url; + if (src && !src.includes("dreamstime") && !src.includes("shutterstock") && + !src.includes("alamy") && !src.includes("istock") && !src.includes("123rf")) { + return src; + } + } + return results[0].thumbnail?.src ?? results[0].url; + } catch { + await delay(2000); + } + } + return null; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function loadDiseases(): DiseaseSeed[] { + return JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[]; +} + +function getPlantName(diseases: DiseaseSeed[], diseaseId: string): string { + const plant = diseases.find((p) => p.id === diseaseId); + return plant?.commonName ?? plant?.name ?? diseaseId; +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log("\n🔍 Plant Disease Image Filler\n"); + + const diseases = loadDiseases(); + console.log(`📋 ${diseases.length} diseases loaded\n`); + + // Load existing results + let results: Record = {}; + if (existsSync(RESULTS_FILE)) { + try { results = JSON.parse(readFileSync(RESULTS_FILE, "utf-8")); } catch { /* fresh */ } + } + + const pending = diseases.filter((d) => { + if ((d.imageUrl as string)?.length) return false; + return !results[d.id]; + }); + + if (pending.length === 0) { + console.log("✅ All done\n"); + await applyResults(diseases, results); + return; + } + + console.log(`⏳ ${pending.length} need images\n`); + + // ── Stage 1: Wikipedia ────────────────────────────────────────────── + const s1 = pending.filter((d) => !results[d.id]); + let s1ok = 0; + console.log("─── Wikipedia ───\n"); + + for (let i = 0; i < s1.length; i++) { + const d = s1[i]; + const plantName = getPlantName(diseases, d.plantId); + const url = await wikiStage(d, plantName); + if (url) { + results[d.id] = { url, source: "wikipedia", quality: "good" }; + s1ok++; + } + const pct = ((i + 1) / s1.length * 100).toFixed(0); + process.stdout.write(` [${pct}% ${i + 1}/${s1.length}] ${d.name.substring(0, 40).padEnd(42)} ${url ? "✅" : "⏭️"}\n`); + if ((i + 1) % 25 === 0) writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2)); + } + + writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2)); + console.log(`\n → ${s1ok}/${s1.length} found\n`); + + // ── Stage 2: Commons ───────────────────────────────────────────────── + const s2 = pending.filter((d) => !results[d.id]); + let s2ok = 0; + + if (s2.length > 0) { + console.log("─── Wikimedia Commons ───\n"); + for (let i = 0; i < s2.length; i++) { + const d = s2[i]; + const plantName = getPlantName(diseases, d.plantId); + let url: string | null = null; + try { + const result = await Promise.race([ + commonsStage(d, plantName), + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 25000)), + ]); + url = result; + } catch { /* timeout */ } + if (url) { + results[d.id] = { url, source: "commons", quality: "good" }; + s2ok++; + } + const pct = ((i + 1) / s2.length * 100).toFixed(0); + process.stdout.write(` [${pct}% ${i + 1}/${s2.length}] ${d.name.substring(0, 40).padEnd(42)} ${url ? "✅" : "⏭️"}\n`); + + if ((i + 1) % 10 === 0) writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2)); + } + writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2)); + console.log(`\n → ${s2ok}/${s2.length} found\n`); + } + + // ── Stage 3: Brave ─────────────────────────────────────────────────── + const s3 = pending.filter((d) => !results[d.id]); + let s3ok = 0; + + if (s3.length > 0 && BRAVE_KEY) { + console.log("─── Brave Image Search ───\n"); + for (const d of s3) { + if (braveCount >= MAX_BRAVE) { + results[d.id] = { url: "", source: "missing", quality: "missing" }; + continue; + } + const plantName = getPlantName(diseases, d.plantId); + const url = await braveStage(d, plantName); + if (url) { + results[d.id] = { url, source: "brave", quality: "fallback" }; + s3ok++; + process.stdout.write(` ✅ ${d.name}\n`); + } else { + results[d.id] = { url: "", source: "missing", quality: "missing" }; + process.stdout.write(` ❌ ${d.name}\n`); + } + await delay(BRAVE_DELAY); + } + writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2)); + console.log(`\n → ${s3ok}/${s3.length} found via Brave\n`); + } else if (s3.length > 0) { + console.log("─── Brave Image Search ─── → skipped (no key)\n"); + for (const d of s3) results[d.id] = { url: "", source: "missing", quality: "missing" }; + } + + // ── Apply ─────────────────────────────────────────────────────────── + await applyResults(diseases, results); + + // ── Report ────────────────────────────────────────────────────────── + const good = Object.values(results).filter((r) => r.quality === "good").length; + const fallback = Object.values(results).filter((r) => r.quality === "fallback").length; + const missing = Object.values(results).filter((r) => r.quality === "missing").length; + + let report = `# Disease Images — Human Review Needed\n\n`; + report += `Generated: ${new Date().toISOString()}\n\n`; + + for (const [label, ids, type] of [ + ["Fallback (Brave)", Object.entries(results).filter(([, r]) => r.quality === "fallback").map(([id]) => id), "fallback"], + ["Missing", Object.entries(results).filter(([, r]) => r.quality === "missing").map(([id]) => id), "missing"], + ] as const) { + if (ids.length === 0) continue; + report += `## ${type === "fallback" ? "⚠️" : "🚫"} ${label}\n\n`; + for (const id of ids) { + const d = diseases.find((x) => x.id === id); + const r = results[id]; + report += `- **${d?.name ?? id}** (${d?.scientificName ?? ""}) on \`${d?.plantId ?? ""}\``; + if (r?.url) report += `\n ${r.url}`; + report += `\n\n`; + } + } + + if (good === diseases.length) report += `## ✅ All images found!\n`; + writeFileSync(REPORT_FILE, report, "utf-8"); + console.log(`📝 Review report: ${REPORT_FILE}`); + + console.log(`\n${"═".repeat(50)}`); + console.log(`📊 Total: ${diseases.length} Good: ${good} Fallback: ${fallback} Missing: ${missing}`); + console.log(` Brave calls: ${braveCount}`); + console.log(`${"═".repeat(50)}\n`); + + closeDb(); +} + +// ─── Apply results to JSON + DB ────────────────────────────────────────────── + +async function applyResults(diseases: DiseaseSeed[], results: Record) { + const urlMap = new Map( + Object.entries(results).filter(([id, r]) => r.url.length > 0 && diseases.some((d) => d.id === id)), + ); + if (urlMap.size === 0) return console.log("⏭️ No images to apply"); + + // JSON + let n = 0; + const updated = diseases.map((d) => { + const img = urlMap.get(d.id); + if (img) { n++; return { ...d, imageUrl: img.url, imageQuality: img.quality }; } + return d; + }); + writeFileSync(DISEASES_JSON, JSON.stringify(updated, null, 2) + "\n"); + console.log(`✅ diseases.json: ${n} images`); + + // DB + try { + const dbUrl = process.env.DATABASE_URL; + const dbToken = process.env.DATABASE_TOKEN; + if (!dbUrl || !dbToken) return console.log(" ⏭️ DB: no DATABASE_URL/TOKEN"); + const raw = createClient({ url: dbUrl, authToken: dbToken }); + const entries = Array.from(urlMap.entries()); + for (let i = 0; i < entries.length; i += 50) { + await raw.batch( + entries.slice(i, i + 50).map(([id, img]) => ({ + sql: "UPDATE diseases SET image_url = ? WHERE id = ?", + args: [img.url, id], + })), + "write", + ); + } + raw.close(); + console.log(`✅ Turso DB: ${entries.length} rows`); + } catch (err) { + console.log(` ⚠️ DB: ${err instanceof Error ? err.message : err}`); + } +} + +main().catch((err) => { console.error("\n❌", err); process.exit(1); }); diff --git a/apps/web/scripts/scrape-disease-images.ts b/apps/web/scripts/scrape-disease-images.ts index 7d855db..f0063a3 100644 --- a/apps/web/scripts/scrape-disease-images.ts +++ b/apps/web/scripts/scrape-disease-images.ts @@ -1,13 +1,12 @@ #!/usr/bin/env node /** - * Fetch disease images from Wikipedia/Wikimedia Commons. + * Fetch disease images from Wikipedia using batch page-title queries. * - * For each disease in the database, searches Wikipedia for its page - * and retrieves the main infobox image. + * Strategy: Convert disease names to Wikipedia page titles, query 50 + * at a time with pageimages prop. Wikipedia resolves redirects automatically. + * Covers 10K+ diseases in ~200 API calls (7 minutes). * * Usage: cd apps/web && npx tsx scripts/scrape-disease-images.ts - * - * Rate-limited to 1 request per 300ms to be respectful. */ import "dotenv/config"; @@ -16,200 +15,205 @@ 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 +const API = "https://en.wikipedia.org/w/api.php"; +const BATCH_SIZE = 50; // Max titles per query +const DELAY_MS = 2000; // Between batches -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(); +/** Convert disease name to Wikipedia page title format */ +function toPageTitle(name: string): string { + return name + .trim() + .replace(/\s+/g, " ") + .split(" ") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join("_") + .replace(/[()]/g, ""); } -interface WikiSearchResult { - title: string; - pageid: number; -} +/** Fetch thumbnails for up to 50 page titles in one call */ +async function batchFetchImages(titles: string[]): Promise> { + const url = `${API}?action=query&titles=${encodeURIComponent(titles.join("|"))}&prop=pageimages&pithumbsize=400&redirects=1&format=json&origin=*`; -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; + for (let attempt = 0; attempt < 5; attempt++) { + try { + const res = await fetch(url, { + headers: { "User-Agent": "PlantHealthKB/1.0 (plant-id)" }, + }); + if (res.status === 429) { + const wait = Math.min(60000, 3000 * Math.pow(2, attempt)); + console.log(` 429 — waiting ${wait / 1000}s...`); + await new Promise((r) => setTimeout(r, wait)); + continue; } + if (!res.ok) return new Map(); + const data = (await res.json()) as any; + const pages = data?.query?.pages; + const result = new Map(); + + if (pages) { + for (const [, page] of Object.entries(pages) as any) { + if (page?.missing || page?.invalid) continue; + const originalTitle = page.title.replace(/_/g, " "); + const thumb = page?.thumbnail?.source; + if (thumb) { + result.set(originalTitle.toLowerCase(), thumb); + } + } + } + + // Apply redirect resolution + const normalized = data?.query?.normalized; + if (normalized) { + for (const n of normalized) { + const from = n.from.toLowerCase(); + const to = n.to.toLowerCase(); + // If we have a result for the canonical name, also map the original + if (result.has(to) && !result.has(from)) { + result.set(from, result.get(to)!); + } + } + } + + return result; + } catch { + await new Promise((r) => setTimeout(r, 2000)); } - } catch { - // ignore } - return null; + return new Map(); } -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; -} +/** Generate candidate page titles from disease name + scientific name */ +function getTitleCandidates(name: string, sciName: string): string[] { + const candidates: string[] = []; + candidates.push(toPageTitle(name)); -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; - } + // Try scientific name + if (sciName && sciName.length > 3) { + // Full scientific name as page title (e.g., "Phytophthora infestans") + candidates.push(sciName.trim()); + + // Genus alone (e.g., "Alternaria") + const genus = sciName.split(/\s+/)[0]; + if (genus && genus.length > 3) { + candidates.push(genus); } - } catch { - // ignore } - return null; + + // Deduplicate + return [...new Set(candidates)]; } async function main() { - console.log("🔍 Fetching disease images from Wikipedia\n"); + console.log("🔍 Fetching disease images from Wikipedia (batch mode)\n"); const db = getDb(); + + const rows = await db + .select({ id: diseases.id, name: diseases.name, sciName: diseases.scientificName }) + .from(diseases) + .where(sql`(image_url IS NULL OR image_url = '')`); + + console.log(`📋 ${rows.length} diseases need images\n`); + 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[] }[] = []; + let pending = 0; + let updates: { id: string; url: string }[] = []; - const BATCH_SIZE = 50; - let i = 0; + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const chunk = rows.slice(i, i + BATCH_SIZE); - 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; + // Collect all unique candidate titles for this batch + const titleMap = new Map(); + for (const r of chunk) { + const candidates = getTitleCandidates(r.name, r.sciName || ""); + for (const t of candidates) { + const key = t.toLowerCase(); + if (!titleMap.has(key)) titleMap.set(key, []); + titleMap.get(key)!.push(r); } - // Try Commons directly - imageUrl = await searchCommons(term); - if (imageUrl) break; } - if (imageUrl && !imageUrl.startsWith("https://")) { - imageUrl = null; - } + // Try exact disease name titles (first candidate for each) + const primaryTitles = chunk.map((r) => getTitleCandidates(r.name, r.sciName || "")[0]); + const imageMap = await batchFetchImages(primaryTitles); - 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`); + // For unmatched, try additional candidates + const unmatched = chunk.filter( + (r) => !imageMap.has(getTitleCandidates(r.name, r.sciName || "")[0].toLowerCase()), + ); + let secondPassMap = new Map(); + if (unmatched.length > 0) { + const altTitles = unmatched + .map((r) => getTitleCandidates(r.name, r.sciName || "").slice(1)) + .flat() + .filter((t) => t.length > 0); + if (altTitles.length > 0) { + secondPassMap = await batchFetchImages([...new Set(altTitles)]); } - found++; - } else { - skipped++; } - // Flush batch - if (batch.length >= BATCH_SIZE) { + // Collect results + for (const r of chunk) { + const candidates = getTitleCandidates(r.name, r.sciName || ""); + let imgUrl: string | undefined; + for (const t of candidates) { + imgUrl = imageMap.get(t.toLowerCase()) || secondPassMap.get(t.toLowerCase()); + if (imgUrl) break; + } + if (imgUrl) { + updates.push({ id: r.id, url: imgUrl }); + found++; + } + pending++; + } + + // Flush updates to DB when we have enough + if (updates.length >= 100 || (i + BATCH_SIZE >= rows.length && updates.length > 0)) { await rawClient.batch( - batch.map((b) => ({ sql: b.sql, args: b.args })), + updates.map((u) => ({ + sql: "UPDATE diseases SET image_url = ? WHERE id = ?", + args: [u.url, u.id], + })), "write", ); - process.stdout.write(` 📦 flushed ${batch.length} updates (${i}/${rows.length})\n`); - batch = []; + updates = []; + } + + // Progress + const pct = ((Math.min(i + BATCH_SIZE, rows.length) / rows.length) * 100).toFixed(1); + process.stdout.write( + ` [${pct}%] ${Math.min(i + BATCH_SIZE, rows.length)}/${rows.length} found=${found}\n`, + ); + + // Rate limit + if (i + BATCH_SIZE < rows.length) { + await new Promise((r) => setTimeout(r, DELAY_MS)); } } - // Flush remaining - if (batch.length > 0) { + // Mark remaining as empty + if (pending < rows.length) { + const remaining = rows.slice(pending); await rawClient.batch( - batch.map((b) => ({ sql: b.sql, args: b.args })), + remaining.map((r) => ({ + sql: "UPDATE diseases SET image_url = '' WHERE id = ? AND (image_url IS NULL OR image_url = '')", + args: [r.id], + })), "write", ); - process.stdout.write(` 📦 final flush: ${batch.length} updates\n`); } rawClient.close(); closeDb(); - console.log(`\n✅ Done! Found images: ${found} | Skipped: ${skipped}`); + console.log(`\n✅ Done! Found images: ${found} / ${rows.length}`); } -main().catch((err) => { console.error("❌ Fatal:", err); process.exit(1); }); +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 index 05e60bc..017c1a7 100644 --- a/apps/web/scripts/test-wiki-images.ts +++ b/apps/web/scripts/test-wiki-images.ts @@ -7,13 +7,15 @@ 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 }> } }; + 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 } }; + return (await res.json()) as { + query?: { pages?: Record }; + }; } async function testOne(term: string) { @@ -22,7 +24,10 @@ async function testOne(term: string) { if (page) { const img = await getImg(page.title); const pages = img?.query?.pages; - if (!pages) { console.log(term, '→ NO PAGES'); return; } + 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"}`); diff --git a/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx b/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx new file mode 100644 index 0000000..a72b8a3 --- /dev/null +++ b/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { Disease, CausalAgentType, Severity } from "@/lib/types"; +import ImageLightbox from "@/components/ImageLightbox"; + +// ─── Severity badge ─── + +function SeverityBadge({ severity }: { severity: Severity }) { + const colors: Record = { + 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", + 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 = { + low: "Low", + moderate: "Moderate", + high: "High", + critical: "Critical", + }; + + return ( + + {severity === "critical" ? "🚨 " : ""} + {labels[severity]} Severity + + ); +} + +// ─── Disease type badge ─── + +function TypeBadge({ type }: { type: CausalAgentType }) { + const colors: Record = { + 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", + environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", + }; + + return ( + + {type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)} + + ); +} + +// ─── Disease card ─── + +function DiseaseCard({ + disease, + onImageClick, +}: { + disease: Disease; + onImageClick: (disease: Disease) => void; +}) { + return ( +
+ {/* Card header */} +
+
+
+

+ {disease.name} +

+ {disease.scientificName && ( +

+ {disease.scientificName} +

+ )} +
+
+ + +
+
+ + {/* Disease image or placeholder */} +
+ {disease.imageUrl ? ( + + ) : ( +
+
+ +

+ {disease.causalAgentType === "fungal" + ? "Fungal pathogen" + : disease.causalAgentType === "bacterial" + ? "Bacterial infection" + : disease.causalAgentType === "viral" + ? "Viral infection" + : "Environmental disorder"} +

+
+
+ )} +
+ +

+ {disease.description} +

+ + {/* Details grid */} +
+ {/* Symptoms */} +
+

+ Symptoms +

+
    + {disease.symptoms.map((symptom, i) => ( +
  • + + {symptom} +
  • + ))} +
+
+ + {/* Causes */} +
+

+ Causes +

+
    + {disease.causes.map((cause, i) => ( +
  • + + {cause} +
  • + ))} +
+
+ + {/* Treatment Steps */} +
+

+ Treatment Steps +

+
    + {disease.treatment.map((step, i) => ( +
  1. + {step} +
  2. + ))} +
+
+ + {/* Prevention Tips */} +
+

+ Prevention Tips +

+
    + {disease.prevention.map((tip, i) => ( +
  • + + {tip} +
  • + ))} +
+
+
+
+
+ ); +} + +// ─── Client component wrapper ─── + +export default function DiseaseCards({ diseases }: { diseases: Disease[] }) { + const [lightboxOpen, setLightboxOpen] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(0); + + // Build list of images from diseases that have imageUrls + const images = diseases + .filter((d) => d.imageUrl) + .map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` })); + + const handleImageClick = useCallback( + (disease: Disease) => { + const index = images.findIndex((img) => img.src === disease.imageUrl); + setLightboxIndex(index >= 0 ? index : 0); + setLightboxOpen(true); + }, + [images], + ); + + const handleClose = useCallback(() => setLightboxOpen(false), []); + + if (diseases.length === 0) return null; + + return ( + <> +
+ {diseases.map((disease) => ( + + ))} +
+ + {lightboxOpen && images.length > 0 && ( + + )} + + ); +} diff --git a/apps/web/src/app/browse/[plantId]/page.tsx b/apps/web/src/app/browse/[plantId]/page.tsx index 3720713..c1a2b29 100644 --- a/apps/web/src/app/browse/[plantId]/page.tsx +++ b/apps/web/src/app/browse/[plantId]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; 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"; +import DiseaseCards from "./DiseaseCards"; interface Props { params: Promise<{ plantId: string }>; @@ -33,171 +33,6 @@ export async function generateMetadata({ params }: Props): Promise { }; } -// ─── Severity badge ─── - -function SeverityBadge({ severity }: { severity: Severity }) { - const colors: Record = { - 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", - 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 = { - low: "Low", - moderate: "Moderate", - high: "High", - critical: "Critical", - }; - - return ( - - {severity === "critical" ? "🚨 " : ""} - {labels[severity]} Severity - - ); -} - -// ─── Disease type badge ─── - -function TypeBadge({ type }: { type: CausalAgentType }) { - const colors: Record = { - 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", - environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", - }; - - return ( - - {type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)} - - ); -} - -// ─── Disease card ─── - -function DiseaseCard({ disease }: { disease: Disease }) { - return ( -
- {/* Card header */} -
-
-
-

- {disease.name} -

- {disease.scientificName && ( -

- {disease.scientificName} -

- )} -
-
- - -
-
- - {/* Disease image */} - {disease.imageUrl && ( -
- {`${disease.name} -
- )} - -

- {disease.description} -

- - {/* Details grid */} -
- {/* Symptoms */} -
-

- Symptoms -

-
    - {disease.symptoms.map((symptom, i) => ( -
  • - - {symptom} -
  • - ))} -
-
- - {/* Causes */} -
-

- Causes -

-
    - {disease.causes.map((cause, i) => ( -
  • - - {cause} -
  • - ))} -
-
- - {/* Treatment Steps */} -
-

- Treatment Steps -

-
    - {disease.treatment.map((step, i) => ( -
  1. - {step} -
  2. - ))} -
-
- - {/* Prevention Tips */} -
-

- Prevention Tips -

-
    - {disease.prevention.map((tip, i) => ( -
  • - - {tip} -
  • - ))} -
-
-
-
-
- ); -} - // ─── Plant Detail Page ─── export default async function PlantDetailPage({ params }: Props) { @@ -307,11 +142,7 @@ export default async function PlantDetailPage({ params }: Props) {

{diseases.length > 0 ? ( -
- {diseases.map((disease) => ( - - ))} -
+ ) : (