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
{diseases.length > 0 ? (
-
- {diseases.map((disease) => (
-
- ))}
-
+
) : (
diff --git a/apps/web/src/components/ImageLightbox.tsx b/apps/web/src/components/ImageLightbox.tsx
new file mode 100644
index 0000000..5de81e8
--- /dev/null
+++ b/apps/web/src/components/ImageLightbox.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+
+interface ImageLightboxProps {
+ images: { src: string; alt: string }[];
+ initialIndex: number;
+ onClose: () => void;
+}
+
+export default function ImageLightbox({ images, initialIndex, onClose }: ImageLightboxProps) {
+ const [currentIndex, setCurrentIndex] = useState(
+ Math.max(0, Math.min(initialIndex, images.length - 1)),
+ );
+
+ const goTo = useCallback(
+ (i: number) => {
+ setCurrentIndex(Math.max(0, Math.min(i, images.length - 1)));
+ },
+ [images.length],
+ );
+
+ // Close on Escape key, navigate with arrows
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ if (e.key === "ArrowLeft") goTo(currentIndex - 1);
+ if (e.key === "ArrowRight") goTo(currentIndex + 1);
+ };
+ window.addEventListener("keydown", handleKey);
+ return () => window.removeEventListener("keydown", handleKey);
+ }, [onClose, currentIndex, goTo]);
+
+ // Prevent body scroll while open
+ useEffect(() => {
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }, []);
+
+ if (!images.length) return null;
+
+ const current = images[currentIndex];
+
+ return (
+
+ {/* Faded backdrop */}
+
+
+ {/* Close button — top right */}
+
+
+ {/* Navigation — previous */}
+ {images.length > 1 && currentIndex > 0 && (
+
+ )}
+
+ {/* Navigation — next */}
+ {images.length > 1 && currentIndex < images.length - 1 && (
+
+ )}
+
+ {/* Full image */}
+
+

+
{current.alt}
+
+ {/* Image counter */}
+ {images.length > 1 && (
+
+ {currentIndex + 1} / {images.length}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts
index a473da6..149f055 100644
--- a/apps/web/src/lib/constants.ts
+++ b/apps/web/src/lib/constants.ts
@@ -21,10 +21,12 @@ export const NAV_LINKS = [
export const PLANT_CATEGORIES = [
{ value: "all", label: "All" },
- { value: "vegetables", label: "Vegetables" },
- { value: "herbs", label: "Herbs" },
- { value: "houseplants", label: "Houseplants" },
- { value: "flowers", label: "Flowers" },
+ { value: "vegetable", label: "Vegetables" },
+ { value: "herb", label: "Herbs" },
+ { value: "houseplant", label: "Houseplants" },
+ { value: "flower", label: "Flowers" },
+ { value: "succulent", label: "Succulents" },
+ { value: "fruit", label: "Fruits" },
] as const;
export const FEATURED_PLANT_IDS = [
diff --git a/apps/web/src/stubs/empty.ts b/apps/web/src/stubs/empty.ts
new file mode 100644
index 0000000..82977c0
--- /dev/null
+++ b/apps/web/src/stubs/empty.ts
@@ -0,0 +1 @@
+// Empty stub for optional dependencies not installed at build time.