ooooeee
This commit is contained in:
@@ -2,13 +2,23 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Turbopack config (Next.js 16 default)
|
// 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 (fallback)
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
config.resolve.alias = {
|
config.resolve.alias = {
|
||||||
...config.resolve.alias,
|
...config.resolve.alias,
|
||||||
sharp: false,
|
sharp: false,
|
||||||
"detect-libc": false,
|
"detect-libc": false,
|
||||||
|
"@tensorflow/tfjs": false,
|
||||||
|
"@tensorflow/tfjs-node": false,
|
||||||
|
"onnxruntime-node": false,
|
||||||
};
|
};
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|||||||
297
apps/web/scripts/.image-results.json
Normal file
297
apps/web/scripts/.image-results.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ async function main() {
|
|||||||
console.log("Applying migration: add image_url to diseases...");
|
console.log("Applying migration: add image_url to diseases...");
|
||||||
await db.execute("ALTER TABLE diseases ADD COLUMN image_url TEXT DEFAULT ''");
|
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");
|
await db.execute("UPDATE diseases SET image_url = '' WHERE image_url IS NULL");
|
||||||
|
|
||||||
// Mark migration as applied
|
// Mark migration as applied
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('0001_add-disease-images', datetime('now'))",
|
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('0001_add-disease-images', datetime('now'))",
|
||||||
|
|||||||
440
apps/web/scripts/fill-disease-images.ts
Normal file
440
apps/web/scripts/fill-disease-images.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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<string, { thumbnail?: { source: string } }> };
|
||||||
|
};
|
||||||
|
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<string | null> {
|
||||||
|
// 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<Response> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
const imgPages = imgData?.query?.pages;
|
||||||
|
if (!imgPages) return null;
|
||||||
|
|
||||||
|
for (const [, pg] of Object.entries(imgPages)) {
|
||||||
|
const p = pg as Record<string, unknown>;
|
||||||
|
const info = (p.imageinfo as Array<Record<string, string>> | 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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string, ImageResult> = {};
|
||||||
|
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<null>((_, 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<string, ImageResult>) {
|
||||||
|
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); });
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* Strategy: Convert disease names to Wikipedia page titles, query 50
|
||||||
* and retrieves the main infobox image.
|
* 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
|
* 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 "dotenv/config";
|
||||||
@@ -16,200 +15,205 @@ import { sql } from "drizzle-orm";
|
|||||||
import { getDb, closeDb } from "../src/lib/db/index";
|
import { getDb, closeDb } from "../src/lib/db/index";
|
||||||
import { diseases } from "../src/lib/db/schema";
|
import { diseases } from "../src/lib/db/schema";
|
||||||
|
|
||||||
const WIKI_API = "https://en.wikipedia.org/w/api.php";
|
const API = "https://en.wikipedia.org/w/api.php";
|
||||||
const COMMONS_API = "https://commons.wikimedia.org/w/api.php";
|
const BATCH_SIZE = 50; // Max titles per query
|
||||||
const MIN_DELAY_MS = 350; // Be respectful
|
const DELAY_MS = 2000; // Between batches
|
||||||
|
|
||||||
let lastCall = 0;
|
/** Convert disease name to Wikipedia page title format */
|
||||||
|
function toPageTitle(name: string): string {
|
||||||
async function rateLimit() {
|
return name
|
||||||
const now = Date.now();
|
.trim()
|
||||||
const elapsed = now - lastCall;
|
.replace(/\s+/g, " ")
|
||||||
if (elapsed < MIN_DELAY_MS) {
|
.split(" ")
|
||||||
await new Promise((r) => setTimeout(r, MIN_DELAY_MS - elapsed));
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
}
|
.join("_")
|
||||||
lastCall = Date.now();
|
.replace(/[()]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WikiSearchResult {
|
/** Fetch thumbnails for up to 50 page titles in one call */
|
||||||
title: string;
|
async function batchFetchImages(titles: string[]): Promise<Map<string, string>> {
|
||||||
pageid: number;
|
const url = `${API}?action=query&titles=${encodeURIComponent(titles.join("|"))}&prop=pageimages&pithumbsize=400&redirects=1&format=json&origin=*`;
|
||||||
}
|
|
||||||
|
|
||||||
async function searchWikipedia(term: string): Promise<WikiSearchResult | null> {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
await rateLimit();
|
try {
|
||||||
const url = `${WIKI_API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=1&origin=*`;
|
const res = await fetch(url, {
|
||||||
try {
|
headers: { "User-Agent": "PlantHealthKB/1.0 (plant-id)" },
|
||||||
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
|
});
|
||||||
const data = await res.json() as any;
|
if (res.status === 429) {
|
||||||
const results = data?.query?.search;
|
const wait = Math.min(60000, 3000 * Math.pow(2, attempt));
|
||||||
if (results && results.length > 0) {
|
console.log(` 429 — waiting ${wait / 1000}s...`);
|
||||||
return { title: results[0].title, pageid: results[0].pageid };
|
await new Promise((r) => setTimeout(r, wait));
|
||||||
}
|
continue;
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
if (!res.ok) return new Map();
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
const pages = data?.query?.pages;
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string | null> {
|
/** Generate candidate page titles from disease name + scientific name */
|
||||||
await rateLimit();
|
function getTitleCandidates(name: string, sciName: string): string[] {
|
||||||
const url = `${COMMONS_API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=3&origin=*`;
|
const candidates: string[] = [];
|
||||||
try {
|
candidates.push(toPageTitle(name));
|
||||||
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> {
|
// Try scientific name
|
||||||
await rateLimit();
|
if (sciName && sciName.length > 3) {
|
||||||
const url = `${COMMONS_API}?action=query&titles=${encodeURIComponent(title)}&prop=imageinfo&iiprop=url&iiurlwidth=400&format=json&origin=*`;
|
// Full scientific name as page title (e.g., "Phytophthora infestans")
|
||||||
try {
|
candidates.push(sciName.trim());
|
||||||
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
|
|
||||||
const data = await res.json() as any;
|
// Genus alone (e.g., "Alternaria")
|
||||||
const pages = data?.query?.pages;
|
const genus = sciName.split(/\s+/)[0];
|
||||||
if (pages) {
|
if (genus && genus.length > 3) {
|
||||||
const page = Object.values(pages)[0] as any;
|
candidates.push(genus);
|
||||||
if (page?.imageinfo?.[0]?.thumburl) {
|
|
||||||
return page.imageinfo[0].thumburl;
|
|
||||||
}
|
|
||||||
if (page?.imageinfo?.[0]?.url) {
|
|
||||||
return page.imageinfo[0].url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
// Deduplicate
|
||||||
|
return [...new Set(candidates)];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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 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({
|
const rawClient = createClient({
|
||||||
url: process.env.DATABASE_URL!,
|
url: process.env.DATABASE_URL!,
|
||||||
authToken: process.env.DATABASE_TOKEN!,
|
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 found = 0;
|
||||||
let skipped = 0;
|
let pending = 0;
|
||||||
let batch: { sql: string; args: any[] }[] = [];
|
let updates: { id: string; url: string }[] = [];
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||||
let i = 0;
|
const chunk = rows.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
for (const row of rows) {
|
// Collect all unique candidate titles for this batch
|
||||||
i++;
|
const titleMap = new Map<string, { id: string; name: string; sciName: string }[]>();
|
||||||
// Build search terms: try scientific name + disease name, then disease name alone
|
for (const r of chunk) {
|
||||||
const searchTerms = [
|
const candidates = getTitleCandidates(r.name, r.sciName || "");
|
||||||
`${row.sciName || ""} ${row.name}`.trim(),
|
for (const t of candidates) {
|
||||||
row.name,
|
const key = t.toLowerCase();
|
||||||
`${row.name} (${row.sciName})`.trim(),
|
if (!titleMap.has(key)) titleMap.set(key, []);
|
||||||
].filter(Boolean);
|
titleMap.get(key)!.push(r);
|
||||||
|
|
||||||
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://")) {
|
// Try exact disease name titles (first candidate for each)
|
||||||
imageUrl = null;
|
const primaryTitles = chunk.map((r) => getTitleCandidates(r.name, r.sciName || "")[0]);
|
||||||
}
|
const imageMap = await batchFetchImages(primaryTitles);
|
||||||
|
|
||||||
if (imageUrl) {
|
// For unmatched, try additional candidates
|
||||||
batch.push({
|
const unmatched = chunk.filter(
|
||||||
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
|
(r) => !imageMap.has(getTitleCandidates(r.name, r.sciName || "")[0].toLowerCase()),
|
||||||
args: [imageUrl, row.id],
|
);
|
||||||
});
|
let secondPassMap = new Map<string, string>();
|
||||||
if (i % 100 === 0) {
|
if (unmatched.length > 0) {
|
||||||
process.stdout.write(` 🔍 found ${found} so far...\n`);
|
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
|
// Collect results
|
||||||
if (batch.length >= BATCH_SIZE) {
|
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(
|
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",
|
"write",
|
||||||
);
|
);
|
||||||
process.stdout.write(` 📦 flushed ${batch.length} updates (${i}/${rows.length})\n`);
|
updates = [];
|
||||||
batch = [];
|
}
|
||||||
|
|
||||||
|
// 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
|
// Mark remaining as empty
|
||||||
if (batch.length > 0) {
|
if (pending < rows.length) {
|
||||||
|
const remaining = rows.slice(pending);
|
||||||
await rawClient.batch(
|
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",
|
"write",
|
||||||
);
|
);
|
||||||
process.stdout.write(` 📦 final flush: ${batch.length} updates\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rawClient.close();
|
rawClient.close();
|
||||||
closeDb();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ const API = "https://en.wikipedia.org/w/api.php";
|
|||||||
async function search(term: string) {
|
async function search(term: string) {
|
||||||
const url = `${API}?action=query&list=search&srsearch=${encodeURIComponent(term)}&format=json&srlimit=1&origin=*`;
|
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" } });
|
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) {
|
async function getImg(title: string) {
|
||||||
const url = `${API}?action=query&titles=${encodeURIComponent(title)}&prop=pageimages&format=json&pithumbsize=400&origin=*`;
|
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" } });
|
const res = await fetch(url, { headers: { "User-Agent": "PlantHealthKB/1.0" } });
|
||||||
return await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source: string } }> } };
|
return (await res.json()) as {
|
||||||
|
query?: { pages?: Record<string, { thumbnail?: { source: string } }> };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testOne(term: string) {
|
async function testOne(term: string) {
|
||||||
@@ -22,7 +24,10 @@ async function testOne(term: string) {
|
|||||||
if (page) {
|
if (page) {
|
||||||
const img = await getImg(page.title);
|
const img = await getImg(page.title);
|
||||||
const pages = img?.query?.pages;
|
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 first = Object.values(pages)[0] as { thumbnail?: { source: string } };
|
||||||
const thumb = first?.thumbnail?.source;
|
const thumb = first?.thumbnail?.source;
|
||||||
console.log(`${term.padEnd(40)} → ${page.title.padEnd(50)} → ${thumb ?? "NO IMG"}`);
|
console.log(`${term.padEnd(40)} → ${page.title.padEnd(50)} → ${thumb ?? "NO IMG"}`);
|
||||||
|
|||||||
247
apps/web/src/app/browse/[plantId]/DiseaseCards.tsx
Normal file
247
apps/web/src/app/browse/[plantId]/DiseaseCards.tsx
Normal file
@@ -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<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",
|
||||||
|
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<Severity, string> = {
|
||||||
|
low: "Low",
|
||||||
|
moderate: "Moderate",
|
||||||
|
high: "High",
|
||||||
|
critical: "Critical",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
|
||||||
|
>
|
||||||
|
{severity === "critical" ? "🚨 " : ""}
|
||||||
|
{labels[severity]} Severity
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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",
|
||||||
|
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 === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Disease card ───
|
||||||
|
|
||||||
|
function DiseaseCard({
|
||||||
|
disease,
|
||||||
|
onImageClick,
|
||||||
|
}: {
|
||||||
|
disease: Disease;
|
||||||
|
onImageClick: (disease: Disease) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`disease-${disease.id}`}
|
||||||
|
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="p-5 sm:p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{disease.name}
|
||||||
|
</h3>
|
||||||
|
{disease.scientificName && (
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
|
||||||
|
{disease.scientificName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<TypeBadge type={disease.causalAgentType} />
|
||||||
|
<SeverityBadge severity={disease.severity} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disease image or placeholder */}
|
||||||
|
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||||
|
{disease.imageUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onImageClick(disease)}
|
||||||
|
className="block w-full cursor-pointer group"
|
||||||
|
aria-label={`View larger image of ${disease.name} symptoms`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={disease.imageUrl}
|
||||||
|
alt={`${disease.name} symptoms`}
|
||||||
|
className="w-full h-48 sm:h-64 object-cover transition-all duration-200 group-hover:brightness-75 group-hover:scale-[1.02]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-36 sm:h-48 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-800 dark:to-zinc-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-5xl block mb-2" aria-hidden="true">
|
||||||
|
{disease.causalAgentType === "fungal"
|
||||||
|
? "🍄"
|
||||||
|
: disease.causalAgentType === "bacterial"
|
||||||
|
? "🦠"
|
||||||
|
: disease.causalAgentType === "viral"
|
||||||
|
? "🧬"
|
||||||
|
: disease.causalAgentType === "environmental"
|
||||||
|
? "🌡️"
|
||||||
|
: "🔬"}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-zinc-400 dark:text-zinc-500">
|
||||||
|
{disease.causalAgentType === "fungal"
|
||||||
|
? "Fungal pathogen"
|
||||||
|
: disease.causalAgentType === "bacterial"
|
||||||
|
? "Bacterial infection"
|
||||||
|
: disease.causalAgentType === "viral"
|
||||||
|
? "Viral infection"
|
||||||
|
: "Environmental disorder"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
|
||||||
|
{disease.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Details grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Symptoms */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
|
||||||
|
<span aria-hidden="true">⚠️</span> Symptoms
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{disease.symptoms.map((symptom, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
|
||||||
|
{symptom}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Causes */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
|
||||||
|
<span aria-hidden="true">🔍</span> Causes
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{disease.causes.map((cause, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
|
||||||
|
{cause}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Treatment Steps */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||||
|
<span aria-hidden="true">💊</span> Treatment Steps
|
||||||
|
</h4>
|
||||||
|
<ol className="space-y-1.5 list-decimal list-inside">
|
||||||
|
{disease.treatment.map((step, i) => (
|
||||||
|
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prevention Tips */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||||
|
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{disease.prevention.map((tip, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
|
||||||
|
{tip}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{diseases.map((disease) => (
|
||||||
|
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxOpen && images.length > 0 && (
|
||||||
|
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
||||||
import { getEmojiForCategory, getPlantDescription } from "@/lib/display-helpers";
|
import { getEmojiForCategory, getPlantDescription } from "@/lib/display-helpers";
|
||||||
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
|
import DiseaseCards from "./DiseaseCards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ plantId: string }>;
|
params: Promise<{ plantId: string }>;
|
||||||
@@ -33,171 +33,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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",
|
|
||||||
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<Severity, string> = {
|
|
||||||
low: "Low",
|
|
||||||
moderate: "Moderate",
|
|
||||||
high: "High",
|
|
||||||
critical: "Critical",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
|
|
||||||
>
|
|
||||||
{severity === "critical" ? "🚨 " : ""}
|
|
||||||
{labels[severity]} Severity
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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",
|
|
||||||
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 === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Disease card ───
|
|
||||||
|
|
||||||
function DiseaseCard({ disease }: { disease: Disease }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={`disease-${disease.id}`}
|
|
||||||
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
{/* Card header */}
|
|
||||||
<div className="p-5 sm:p-6">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
||||||
{disease.name}
|
|
||||||
</h3>
|
|
||||||
{disease.scientificName && (
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
|
|
||||||
{disease.scientificName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Details grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Symptoms */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
|
|
||||||
<span aria-hidden="true">⚠️</span> Symptoms
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{disease.symptoms.map((symptom, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
|
|
||||||
{symptom}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Causes */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
|
|
||||||
<span aria-hidden="true">🔍</span> Causes
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{disease.causes.map((cause, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
|
|
||||||
{cause}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Treatment Steps */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
|
||||||
<span aria-hidden="true">💊</span> Treatment Steps
|
|
||||||
</h4>
|
|
||||||
<ol className="space-y-1.5 list-decimal list-inside">
|
|
||||||
{disease.treatment.map((step, i) => (
|
|
||||||
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
|
|
||||||
{step}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prevention Tips */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
|
||||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{disease.prevention.map((tip, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
|
|
||||||
{tip}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Plant Detail Page ───
|
// ─── Plant Detail Page ───
|
||||||
|
|
||||||
export default async function PlantDetailPage({ params }: Props) {
|
export default async function PlantDetailPage({ params }: Props) {
|
||||||
@@ -307,11 +142,7 @@ export default async function PlantDetailPage({ params }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{diseases.length > 0 ? (
|
{diseases.length > 0 ? (
|
||||||
<div className="space-y-6">
|
<DiseaseCards diseases={diseases} />
|
||||||
{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">
|
<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 className="text-4xl block mb-3" aria-hidden="true">
|
||||||
|
|||||||
143
apps/web/src/components/ImageLightbox.tsx
Normal file
143
apps/web/src/components/ImageLightbox.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Image viewer"
|
||||||
|
>
|
||||||
|
{/* Faded backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Close button — top right */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 z-10 rounded-full p-2 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Close image"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Navigation — previous */}
|
||||||
|
{images.length > 1 && currentIndex > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goTo(currentIndex - 1)}
|
||||||
|
className="absolute left-4 z-10 rounded-full p-2 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation — next */}
|
||||||
|
{images.length > 1 && currentIndex < images.length - 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goTo(currentIndex + 1)}
|
||||||
|
className="absolute right-4 z-10 rounded-full p-2 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full image */}
|
||||||
|
<div className="relative z-0 max-w-[90vw] max-h-[85vh] flex flex-col items-center">
|
||||||
|
<img
|
||||||
|
src={current.src}
|
||||||
|
alt={current.alt}
|
||||||
|
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-sm text-white/70 text-center max-w-lg">{current.alt}</p>
|
||||||
|
|
||||||
|
{/* Image counter */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<p className="mt-1 text-xs text-white/50">
|
||||||
|
{currentIndex + 1} / {images.length}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,10 +21,12 @@ export const NAV_LINKS = [
|
|||||||
|
|
||||||
export const PLANT_CATEGORIES = [
|
export const PLANT_CATEGORIES = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "vegetables", label: "Vegetables" },
|
{ value: "vegetable", label: "Vegetables" },
|
||||||
{ value: "herbs", label: "Herbs" },
|
{ value: "herb", label: "Herbs" },
|
||||||
{ value: "houseplants", label: "Houseplants" },
|
{ value: "houseplant", label: "Houseplants" },
|
||||||
{ value: "flowers", label: "Flowers" },
|
{ value: "flower", label: "Flowers" },
|
||||||
|
{ value: "succulent", label: "Succulents" },
|
||||||
|
{ value: "fruit", label: "Fruits" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const FEATURED_PLANT_IDS = [
|
export const FEATURED_PLANT_IDS = [
|
||||||
|
|||||||
1
apps/web/src/stubs/empty.ts
Normal file
1
apps/web/src/stubs/empty.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Empty stub for optional dependencies not installed at build time.
|
||||||
Reference in New Issue
Block a user