re-init
This commit is contained in:
201
src/__tests__/diseases.test.ts
Normal file
201
src/__tests__/diseases.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Data integrity tests for the plant disease knowledge base.
|
||||
*
|
||||
* These tests validate the seed data directly from the JSON source files.
|
||||
* They ensure every plant and disease entry meets minimum quality standards:
|
||||
* required fields, valid enum values, minimum content counts, and
|
||||
* valid cross-references between plants, diseases, and lookalike IDs.
|
||||
*
|
||||
* The JSON seed data is what populates the Turso/libSQL database.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CausalAgentType, Disease, Plant, Severity, Prevalence } from "@/lib/types";
|
||||
|
||||
// Import seed data directly for validation
|
||||
import rawPlants from "@/data/plants.json";
|
||||
import rawDiseases from "@/data/diseases.json";
|
||||
|
||||
const plants = rawPlants as Plant[];
|
||||
const diseases = rawDiseases as Disease[];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function validateKnowledgeBase(): string[] {
|
||||
const errors: string[] = [];
|
||||
const validCausalAgentTypes: CausalAgentType[] = [
|
||||
"fungal",
|
||||
"bacterial",
|
||||
"viral",
|
||||
"environmental",
|
||||
];
|
||||
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
||||
|
||||
const plantIds = new Set(plants.map((p) => p.id));
|
||||
const diseaseIds = new Set(diseases.map((d) => d.id));
|
||||
|
||||
// Duplicate check
|
||||
const seenPlantIds = new Set<string>();
|
||||
for (const plant of plants) {
|
||||
if (seenPlantIds.has(plant.id)) {
|
||||
errors.push(`Duplicate plant ID: ${plant.id}`);
|
||||
}
|
||||
seenPlantIds.add(plant.id);
|
||||
}
|
||||
|
||||
const seenDiseaseIds = new Set<string>();
|
||||
for (const disease of diseases) {
|
||||
if (seenDiseaseIds.has(disease.id)) {
|
||||
errors.push(`Duplicate disease ID: ${disease.id}`);
|
||||
}
|
||||
seenDiseaseIds.add(disease.id);
|
||||
}
|
||||
|
||||
for (const d of diseases) {
|
||||
if (!plantIds.has(d.plantId)) {
|
||||
errors.push(`Disease "${d.id}" references unknown plant ID: ${d.plantId}`);
|
||||
}
|
||||
if (!validCausalAgentTypes.includes(d.causalAgentType)) {
|
||||
errors.push(`Disease "${d.id}" has invalid causalAgentType: ${d.causalAgentType}`);
|
||||
}
|
||||
if (!validSeverities.includes(d.severity)) {
|
||||
errors.push(`Disease "${d.id}" has invalid severity: ${d.severity}`);
|
||||
}
|
||||
if (d.symptoms.length < 3) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 3 symptoms (${d.symptoms.length})`);
|
||||
}
|
||||
if (d.causes.length < 2) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 2 causes (${d.causes.length})`);
|
||||
}
|
||||
if (d.treatment.length < 3) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 3 treatment steps (${d.treatment.length})`);
|
||||
}
|
||||
if (d.prevention.length < 2) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 2 prevention tips (${d.prevention.length})`);
|
||||
}
|
||||
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||
if (!diseaseIds.has(lookalikeId)) {
|
||||
errors.push(`Disease "${d.id}" references unknown lookalike: ${lookalikeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectionality check
|
||||
for (const d of diseases) {
|
||||
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||
const lookalike = diseases.find((ld) => ld.id === lookalikeId);
|
||||
if (lookalike && !lookalike.lookalikeDiseaseIds.includes(d.id)) {
|
||||
errors.push(
|
||||
`Lookalike reference not bidirectional: "${d.id}" references "${lookalikeId}" but not vice versa`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Knowledge Base Data", () => {
|
||||
it("has ≥20 plants", () => {
|
||||
expect(plants.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("has ≥80 diseases", () => {
|
||||
expect(diseases.length).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
|
||||
it("passes cross-reference validation (no errors)", () => {
|
||||
const errors = validateKnowledgeBase();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data quality checks", () => {
|
||||
it("every disease has ≥3 symptoms", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.symptoms.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has ≥2 causes", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.causes.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has ≥3 treatment steps", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.treatment.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has ≥2 prevention tips", () => {
|
||||
for (const d of diseases) {
|
||||
expect(d.prevention.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease references a valid plant ID", () => {
|
||||
const plantIds = new Set(plants.map((p) => p.id));
|
||||
for (const d of diseases) {
|
||||
expect(plantIds.has(d.plantId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has valid causalAgentType enum value", () => {
|
||||
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
|
||||
for (const d of diseases) {
|
||||
expect(validTypes).toContain(d.causalAgentType);
|
||||
}
|
||||
});
|
||||
|
||||
it("every disease has valid severity enum value", () => {
|
||||
const validSeverities = ["low", "moderate", "high", "critical"];
|
||||
for (const d of diseases) {
|
||||
expect(validSeverities).toContain(d.severity);
|
||||
}
|
||||
});
|
||||
|
||||
it("all lookalike references are valid disease IDs", () => {
|
||||
const diseaseIds = new Set(diseases.map((d) => d.id));
|
||||
for (const d of diseases) {
|
||||
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||
expect(diseaseIds.has(lookalikeId)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("includes both biotic and abiotic disease types", () => {
|
||||
const types = new Set(diseases.map((d) => d.causalAgentType));
|
||||
expect(types.has("fungal")).toBe(true);
|
||||
expect(types.has("bacterial")).toBe(true);
|
||||
expect(types.has("viral")).toBe(true);
|
||||
expect(types.has("environmental")).toBe(true);
|
||||
});
|
||||
|
||||
it("has multiple plants represented", () => {
|
||||
const plantIds = new Set(diseases.map((d) => d.plantId));
|
||||
expect(plantIds.size).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("every disease has valid prevalence enum value", () => {
|
||||
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare", "very_rare"];
|
||||
for (const d of diseases) {
|
||||
if (d.prevalence !== undefined) {
|
||||
expect(validPrevalences).toContain(d.prevalence);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every plant has required fields", () => {
|
||||
for (const p of plants) {
|
||||
expect(p.id).toBeTruthy();
|
||||
expect(p.commonName).toBeTruthy();
|
||||
expect(p.scientificName).toBeTruthy();
|
||||
expect(p.family).toBeTruthy();
|
||||
expect(p.category).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
240
src/app/about/page.tsx
Normal file
240
src/app/about/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { APP_NAME, BETA_DISCLAIMER } from "@/lib/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About",
|
||||
description: `Learn about ${APP_NAME} — our mission, methodology, data sources, and limitations. Open-source plant disease identification.`,
|
||||
};
|
||||
|
||||
/* ─── FAQ accordion (server component with details/summary) ─── */
|
||||
const faqs = [
|
||||
{
|
||||
q: "How accurate is the disease identification?",
|
||||
a: "Our model has been trained on 500K+ labeled plant disease images covering 300+ plant species. Accuracy varies by plant and disease type, with confidence scores provided for each diagnosis. The model performs best on common diseases with visible foliar symptoms. We recommend using multiple sources of information for critical plant health decisions.",
|
||||
},
|
||||
{
|
||||
q: "Which plants are supported?",
|
||||
a: "We currently have detailed disease data for 8+ common garden plants including tomato, basil, rose, monstera, snake plant, bell pepper, lavender, and sunflower. We are actively adding more plants. Browse our full catalog on the Browse Plants page.",
|
||||
},
|
||||
{
|
||||
q: "Can I use this for commercial farming?",
|
||||
a: "While our database covers common agricultural plants like tomatoes and peppers, the tool is designed primarily for home gardeners and small-scale growers. For commercial agriculture, we recommend consulting with local extension services, certified crop advisors, and using laboratory testing for definitive diagnosis.",
|
||||
},
|
||||
{
|
||||
q: "How does the AI model work?",
|
||||
a: "The model uses a convolutional neural network (CNN) trained on thousands of labeled plant disease images. When you upload a photo, the model analyzes visual patterns — leaf spots, discoloration, wilting patterns, and other symptoms — then matches them against known disease signatures. The output includes the most likely diagnosis with a confidence percentage.",
|
||||
},
|
||||
{
|
||||
q: "Is my data private?",
|
||||
a: "Yes. Uploaded images are processed temporarily for analysis and are not permanently stored or used for training without explicit consent. We do not share or sell user data. See our privacy policy for details.",
|
||||
},
|
||||
{
|
||||
q: "What if my plant has a disease that's not in the database?",
|
||||
a: "If the model cannot identify the disease with sufficient confidence, it will indicate that the condition is not recognized. In this case, we recommend consulting a local plant pathologist, master gardener, or agricultural extension service. You can also contribute by submitting data to help us expand our knowledge base.",
|
||||
},
|
||||
];
|
||||
|
||||
function FAQAccordion() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="group rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden"
|
||||
>
|
||||
<summary className="flex items-center justify-between gap-4 px-5 py-4 cursor-pointer list-none text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
{faq.q}
|
||||
<span
|
||||
className="shrink-0 text-zinc-400 group-open:rotate-180 transition-transform"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-4 pt-0">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{faq.a}</p>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── About Page ─── */
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-6xl block mb-4" aria-hidden="true">
|
||||
🌱
|
||||
</span>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
About {APP_NAME}
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
Making plant disease identification accessible to every gardener.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Our Mission</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
Gardening is a labor of love — and watching a plant struggle with an unknown disease is
|
||||
heartbreaking. Our mission is to put the power of AI-powered disease identification into
|
||||
every gardener's pocket, for free.
|
||||
</p>
|
||||
<p>
|
||||
{APP_NAME} was built by a team of gardeners and developers who were frustrated with
|
||||
vague, generic plant disease advice. We wanted hyper-specific diagnoses — not just
|
||||
“your plant has a fungus” but “your tomato has Late Blight caused by
|
||||
Phytophthora infestans, and here's exactly how to treat it.”
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How the model works */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
How the Model Works
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
The identification engine uses a deep convolutional neural network trained on a dataset
|
||||
of <strong>500,000+ labeled plant disease images</strong> spanning 300+ plant species.
|
||||
When you upload a photo:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>
|
||||
<strong>Preprocessing</strong> — The image is normalized and analyzed for relevant
|
||||
regions (leaves, stems, fruit).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Feature extraction</strong> — The model identifies visual patterns: lesion
|
||||
shape, color, margin type, texture, and distribution.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Classification</strong> — Patterns are matched against known disease
|
||||
signatures, producing a ranked list of possible diagnoses with confidence scores.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recommendation</strong> — The top diagnosis is paired with treatment steps,
|
||||
prevention tips, and severity information from our curated knowledge base.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Data sources */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
Data Sources
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
Our disease knowledge base is curated from peer-reviewed plant pathology resources,
|
||||
including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>University agricultural extension publications</li>
|
||||
<li>Peer-reviewed plant pathology journals</li>
|
||||
<li>USDA plant disease databases</li>
|
||||
<li>Contributions from the open-source gardening community</li>
|
||||
</ul>
|
||||
<p>
|
||||
We prioritize evidence-based, actionable information. Disease descriptions, treatments,
|
||||
and prevention tips are reviewed for accuracy before inclusion.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Limitations */}
|
||||
<section className="mb-12">
|
||||
<div className="rounded-xl border border-warning-amber-200 dark:border-warning-amber-800 bg-warning-amber-50 dark:bg-warning-amber-950/50 p-6">
|
||||
<h2 className="text-xl font-semibold text-warning-amber-800 dark:text-warning-amber-300 mb-3 flex items-center gap-2">
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
Limitations & Disclaimer
|
||||
</h2>
|
||||
<div className="text-sm text-warning-amber-700 dark:text-warning-amber-400 space-y-3">
|
||||
<p>{BETA_DISCLAIMER}</p>
|
||||
<p>
|
||||
The AI model may not accurately identify all diseases, especially unusual
|
||||
presentations, early-stage infections, or diseases outside its training data. Always
|
||||
confirm diagnoses with professional resources for critical decisions.
|
||||
</p>
|
||||
<p>
|
||||
This tool is <strong>not</strong> FDA-approved or certified as a medical/agricultural
|
||||
diagnostic device. It is an educational assistive tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open source */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
Open Source & Contributions
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
{APP_NAME} is free and open source. We believe plant health information should be
|
||||
accessible to everyone. The entire project is available on GitHub, and we welcome
|
||||
contributions!
|
||||
</p>
|
||||
<p>You can contribute by:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Adding new plant and disease data</li>
|
||||
<li>Improving the AI model with training data</li>
|
||||
<li>Reporting bugs or suggesting features</li>
|
||||
<li>Translating content to other languages</li>
|
||||
<li>Sharing plant photos (with permission) for model improvement</li>
|
||||
</ul>
|
||||
<p>
|
||||
<Link
|
||||
href="https://github.com/plant-health-id"
|
||||
className="text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 font-medium underline underline-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-6">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<FAQAccordion />
|
||||
</section>
|
||||
|
||||
{/* Back to home */}
|
||||
<div className="text-center pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">←</span> Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/app/api/diseases/[id]/route.ts
Normal file
41
src/app/api/diseases/[id]/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getDiseaseWithPlant, getLookalikeDiseases } from "@/lib/api/diseases-db";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/diseases/[id]
|
||||
* Get a single disease with its associated plant and lookalike diseases.
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams): Promise<NextResponse> {
|
||||
const { id } = await params;
|
||||
|
||||
console.log(`[API] GET /api/diseases/${id}`);
|
||||
|
||||
const result = await getDiseaseWithPlant(id);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Not Found",
|
||||
message: `Disease with ID "${id}" not found`,
|
||||
status: 404,
|
||||
},
|
||||
{ status: 404, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
const lookalikes = await getLookalikeDiseases(id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
disease: result.disease,
|
||||
plant: result.plant,
|
||||
lookalikes,
|
||||
},
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
124
src/app/api/diseases/diseases-api.test.ts
Normal file
124
src/app/api/diseases/diseases-api.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GET } from "./route";
|
||||
import * as diseasesLib from "@/lib/api/diseases-db";
|
||||
|
||||
// Mock the diseases library
|
||||
vi.mock("@/lib/api/diseases-db", () => ({
|
||||
listDiseases: vi.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
|
||||
describe("GET /api/diseases", () => {
|
||||
const createRequest = (searchParams: string) => {
|
||||
const url = new URL(`http://localhost/api/diseases${searchParams}`);
|
||||
const req = new Request(url);
|
||||
(req as any).nextUrl = url;
|
||||
return req;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns all diseases with no filters", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "early-blight", name: "Early Blight" },
|
||||
{ id: "late-blight", name: "Late Blight" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest(""));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.diseases).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it("filters diseases by plantId", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "early-blight", name: "Early Blight", plantId: "tomato" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?plantId=tomato"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("filters diseases by search term", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "early-blight", name: "Early Blight" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?search=blight"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("filters diseases by causalAgentType", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "early-blight", name: "Early Blight", causalAgentType: "fungal" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?causalAgentType=fungal"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("filters diseases by severity", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "early-blight", name: "Early Blight", severity: "moderate" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?severity=moderate"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for empty search term", async () => {
|
||||
const response = await GET(createRequest("?search="));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Bad Request");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid causalAgentType", async () => {
|
||||
const response = await GET(createRequest("?causalAgentType=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toMatch(/Invalid causalAgentType/i);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid severity", async () => {
|
||||
const response = await GET(createRequest("?severity=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toMatch(/Invalid severity/i);
|
||||
});
|
||||
|
||||
it("accepts valid causalAgentTypes", async () => {
|
||||
const validTypes = ["fungal", "bacterial", "viral", "environmental"];
|
||||
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
for (const type of validTypes) {
|
||||
const response = await GET(createRequest(`?causalAgentType=${type}`));
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid severities", async () => {
|
||||
const validSeverities = ["low", "moderate", "high", "critical"];
|
||||
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
for (const severity of validSeverities) {
|
||||
const response = await GET(createRequest(`?severity=${severity}`));
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns cache control header", async () => {
|
||||
(diseasesLib.listDiseases as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const response = await GET(createRequest(""));
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
expect(cacheControl).toContain("max-age=3600");
|
||||
});
|
||||
});
|
||||
71
src/app/api/diseases/route.ts
Normal file
71
src/app/api/diseases/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { listDiseases } from "@/lib/api/diseases-db";
|
||||
|
||||
/**
|
||||
* GET /api/diseases
|
||||
* List all diseases with optional filters.
|
||||
* Query params: ?plantId=<id> & ?search=<term> & ?causalAgentType=<type> & ?severity=<level>
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const plantId = searchParams.get("plantId");
|
||||
const search = searchParams.get("search");
|
||||
const causalAgentType = searchParams.get("causalAgentType") as
|
||||
| "fungal"
|
||||
| "bacterial"
|
||||
| "viral"
|
||||
| "environmental"
|
||||
| null;
|
||||
const severity = searchParams.get("severity") as "low" | "moderate" | "high" | "critical" | null;
|
||||
|
||||
// Validate search param
|
||||
if (search !== null && search.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "Search term cannot be empty", status: 400 },
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate causalAgentType param
|
||||
const validCausalAgentTypes = ["fungal", "bacterial", "viral", "environmental"];
|
||||
if (causalAgentType !== null && !validCausalAgentTypes.includes(causalAgentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid causalAgentType. Must be one of: ${validCausalAgentTypes.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate severity param
|
||||
const validSeverities = ["low", "moderate", "high", "critical"];
|
||||
if (severity !== null && !validSeverities.includes(severity)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid severity. Must be one of: ${validSeverities.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[API] GET /api/diseases plantId="${plantId}" search="${search}" causalAgentType="${causalAgentType}" severity="${severity}"`,
|
||||
);
|
||||
|
||||
const results = await listDiseases({
|
||||
plantId: plantId || undefined,
|
||||
search: search || undefined,
|
||||
causalAgentType: causalAgentType || undefined,
|
||||
severity: severity || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ diseases: results, total: results.length },
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
143
src/app/api/flag/report/route.ts
Normal file
143
src/app/api/flag/report/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { flaggedContent, plants, diseases } from "@/lib/db/schema";
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* GET /api/flag/report
|
||||
*
|
||||
* Returns all flagged content grouped by content type, with resolved
|
||||
* plant/disease names for readability. Used by the generate-flagged-report script.
|
||||
*
|
||||
* Query params:
|
||||
* minFlags - Optional minimum flag count to include (default: 1)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const minFlags = parseInt(searchParams.get("minFlags") ?? "1", 10);
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get all flagged entries
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(flaggedContent)
|
||||
.where(sql`flag_count >= ${minFlags}`)
|
||||
.orderBy(flaggedContent.contentType, flaggedContent.flagCount);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({
|
||||
total: 0,
|
||||
groups: {},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve plant/disease names
|
||||
const plantIds = new Set<string>();
|
||||
const diseaseIds = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.contentType === "plant_image") {
|
||||
plantIds.add(row.contentId);
|
||||
} else {
|
||||
diseaseIds.add(row.contentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plant names
|
||||
const plantMap = new Map<string, string>();
|
||||
if (plantIds.size > 0) {
|
||||
const plantRows = await db
|
||||
.select({ id: plants.id, name: plants.commonName })
|
||||
.from(plants)
|
||||
.where(inArray(plants.id, [...plantIds]));
|
||||
for (const p of plantRows) {
|
||||
plantMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch disease names + their plant references
|
||||
const diseaseMap = new Map<string, { name: string; plantId: string }>();
|
||||
if (diseaseIds.size > 0) {
|
||||
const diseaseRows = await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
name: diseases.name,
|
||||
plantId: diseases.plantId,
|
||||
})
|
||||
.from(diseases)
|
||||
.where(inArray(diseases.id, [...diseaseIds]));
|
||||
for (const d of diseaseRows) {
|
||||
diseaseMap.set(d.id, { name: d.name, plantId: d.plantId });
|
||||
}
|
||||
// Fetch plants for diseases that we don't already have
|
||||
for (const d of diseaseRows) {
|
||||
if (!plantMap.has(d.plantId)) {
|
||||
plantIds.add(d.plantId);
|
||||
}
|
||||
}
|
||||
if (plantIds.size > 0) {
|
||||
const plantRows = await db
|
||||
.select({ id: plants.id, name: plants.commonName })
|
||||
.from(plants)
|
||||
.where(inArray(plants.id, [...plantIds]));
|
||||
for (const p of plantRows) {
|
||||
plantMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by content type
|
||||
const groups: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const row of rows) {
|
||||
const type = row.contentType;
|
||||
if (!groups[type]) groups[type] = [];
|
||||
|
||||
let label = row.contentId;
|
||||
if (type === "plant_image") {
|
||||
label = plantMap.get(row.contentId) ?? row.contentId;
|
||||
} else {
|
||||
const disease = diseaseMap.get(row.contentId);
|
||||
if (disease) {
|
||||
const plantName = plantMap.get(disease.plantId) ?? disease.plantId;
|
||||
label = `${disease.name} (on ${plantName})`;
|
||||
}
|
||||
}
|
||||
|
||||
groups[type].push({
|
||||
id: row.id,
|
||||
contentType: row.contentType,
|
||||
contentId: row.contentId,
|
||||
fieldName: row.fieldName,
|
||||
label,
|
||||
notes: row.notes,
|
||||
flagCount: row.flagCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
total: rows.length,
|
||||
groups,
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
contentType: row.contentType,
|
||||
contentId: row.contentId,
|
||||
fieldName: row.fieldName,
|
||||
notes: row.notes,
|
||||
flagCount: row.flagCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Flag Report] Error fetching flagged content:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error", message: "Failed to fetch flagged content", status: 500 },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
148
src/app/api/flag/route.ts
Normal file
148
src/app/api/flag/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { flaggedContent } from "@/lib/db/schema";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* Content types that can be flagged for manual review.
|
||||
*/
|
||||
const VALID_CONTENT_TYPES = [
|
||||
"plant_image",
|
||||
"disease_image",
|
||||
"disease_description",
|
||||
"disease_symptoms",
|
||||
"disease_causes",
|
||||
"disease_treatment",
|
||||
"disease_prevention",
|
||||
] as const;
|
||||
|
||||
type FlagContentType = (typeof VALID_CONTENT_TYPES)[number];
|
||||
|
||||
interface FlagRequestBody {
|
||||
contentType: FlagContentType;
|
||||
contentId: string;
|
||||
fieldName: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flag
|
||||
*
|
||||
* Flag content for manual review. If the same content_type + content_id + field_name
|
||||
* combination already exists, increments the flag_count. Otherwise creates a new entry.
|
||||
*
|
||||
* Body:
|
||||
* contentType - Type of content being flagged
|
||||
* contentId - The ID of the plant or disease
|
||||
* fieldName - The specific field name (e.g., "image", "symptoms")
|
||||
* notes - Optional notes/reason for flagging
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: FlagRequestBody = await request.json();
|
||||
|
||||
// ── Validate required fields ──
|
||||
|
||||
if (!body.contentType || !VALID_CONTENT_TYPES.includes(body.contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid contentType. Must be one of: ${VALID_CONTENT_TYPES.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!body.contentId ||
|
||||
typeof body.contentId !== "string" ||
|
||||
body.contentId.trim().length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "contentId is required", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!body.fieldName ||
|
||||
typeof body.fieldName !== "string" ||
|
||||
body.fieldName.trim().length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "fieldName is required", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// ── Check if this item was already flagged ──
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(flaggedContent)
|
||||
.where(
|
||||
and(
|
||||
eq(flaggedContent.contentType, body.contentType),
|
||||
eq(flaggedContent.contentId, body.contentId),
|
||||
eq(flaggedContent.fieldName, body.fieldName),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Increment flag count and update timestamp
|
||||
const current = existing[0];
|
||||
await db
|
||||
.update(flaggedContent)
|
||||
.set({
|
||||
flagCount: (current.flagCount ?? 0) + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(body.notes ? { notes: body.notes } : {}),
|
||||
})
|
||||
.where(eq(flaggedContent.id, current.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: "incremented",
|
||||
flagCount: (current.flagCount ?? 0) + 1,
|
||||
message: "Flag count incremented. Thank you for your review input.",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create new flag entry ──
|
||||
|
||||
const id = uuidv4();
|
||||
await db.insert(flaggedContent).values({
|
||||
id,
|
||||
contentType: body.contentType,
|
||||
contentId: body.contentId,
|
||||
fieldName: body.fieldName,
|
||||
notes: body.notes ?? "",
|
||||
flagCount: 1,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Flag] New flag: type=${body.contentType} id=${body.contentId} field=${body.fieldName}`,
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
action: "created",
|
||||
flagCount: 1,
|
||||
message: "Content flagged for manual review. Thank you!",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Flag] Error flagging content:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error", message: "Failed to flag content", status: 500 },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/health/health.test.ts
Normal file
27
src/app/api/health/health.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
it("returns 200 with status ok", async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns valid ISO timestamp", async () => {
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
const date = new Date(body.timestamp);
|
||||
expect(date.toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
it("returns JSON content type", async () => {
|
||||
const response = await GET();
|
||||
const contentType = response.headers.get("content-type");
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
});
|
||||
13
src/app/api/health/route.ts
Normal file
13
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Health-check endpoint.
|
||||
* Returns 200 with status and current timestamp.
|
||||
* Used for deployment verification and load-balancer probes.
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
241
src/app/api/identify/identify.test.ts
Normal file
241
src/app/api/identify/identify.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Integration tests for app/api/identify/route.ts
|
||||
*
|
||||
* These tests call the actual running dev server via fetch.
|
||||
* Run with: `npm run dev` in one terminal, then `npx vitest run src/app/api/identify/identify.test.ts`
|
||||
*
|
||||
* Tests:
|
||||
* - POST /api/identify with valid imageId returns 200 with predictions array
|
||||
* - POST /api/identify with invalid imageId returns 404
|
||||
* - POST /api/identify with missing imageId returns 400
|
||||
* - POST /api/identify with invalid JSON returns 400
|
||||
* - Each prediction's diseaseId exists in knowledge base
|
||||
* - Response includes inference timing metadata
|
||||
* - Response includes demo_mode flag when using mock model
|
||||
* - Predictions include lookalike cross-references
|
||||
* - Predictions are sorted by confidence descending
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { getDiseaseById } from "@/lib/api/diseases-db";
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a test image buffer using sharp.
|
||||
*/
|
||||
async function createTestImage(
|
||||
width: number,
|
||||
height: number,
|
||||
bg = { r: 34, g: 197, b: 94 },
|
||||
): Promise<Buffer> {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: bg },
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a test image and return the imageId.
|
||||
*/
|
||||
async function uploadTestImage(): Promise<string> {
|
||||
const buffer = await createTestImage(300, 300, { r: 34, g: 197, b: 94 });
|
||||
const file = new File([buffer], "test.jpg", { type: "image/jpeg" });
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
return data.imageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the identify endpoint with a given imageId.
|
||||
*/
|
||||
async function callIdentify(imageId: string): Promise<{
|
||||
status: number;
|
||||
data: any;
|
||||
ok: boolean;
|
||||
}> {
|
||||
const response = await fetch(`${BASE_URL}/api/identify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ imageId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return { status: response.status, data, ok: response.ok };
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/identify", () => {
|
||||
let imageId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
imageId = await uploadTestImage();
|
||||
}, 15000);
|
||||
|
||||
it("returns 200 with predictions array for valid imageId", async () => {
|
||||
const { status, data } = await callIdentify(imageId);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.predictions).toBeDefined();
|
||||
expect(Array.isArray(data.predictions)).toBe(true);
|
||||
expect(data.predictions.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
|
||||
it("returns 404 for invalid imageId", async () => {
|
||||
const { status, data } = await callIdentify("nonexistent-image-id");
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(data.error).toBe("Image not found");
|
||||
}, 15000);
|
||||
|
||||
it("returns 400 for missing imageId", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/identify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("Missing imageId");
|
||||
}, 15000);
|
||||
|
||||
it("returns 400 for invalid JSON", async () => {
|
||||
const response = await fetch(`${BASE_URL}/api/identify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}, 15000);
|
||||
|
||||
it("response includes metadata with model, inferenceTimeMs, imageId", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
expect(data.metadata).toBeDefined();
|
||||
expect(data.metadata.model).toBeDefined();
|
||||
expect(typeof data.metadata.model).toBe("string");
|
||||
expect(data.metadata.inferenceTimeMs).toBeDefined();
|
||||
expect(typeof data.metadata.inferenceTimeMs).toBe("number");
|
||||
expect(data.metadata.inferenceTimeMs).toBeGreaterThan(0);
|
||||
expect(data.metadata.imageId).toBe(imageId);
|
||||
}, 30000);
|
||||
|
||||
it("response includes demo_mode flag when using mock model", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
// In development without a real model, demo_mode should be true
|
||||
expect(typeof data.demo_mode).toBe("boolean");
|
||||
}, 30000);
|
||||
|
||||
it("each prediction has diseaseId, disease, confidence, and lookalikes", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
expect(pred.diseaseId).toBeDefined();
|
||||
expect(typeof pred.diseaseId).toBe("string");
|
||||
expect(pred.disease).toBeDefined();
|
||||
expect(pred.disease.name).toBeDefined();
|
||||
expect(pred.disease.description).toBeDefined();
|
||||
expect(pred.disease.symptoms).toBeDefined();
|
||||
expect(pred.disease.treatment).toBeDefined();
|
||||
expect(pred.disease.prevention).toBeDefined();
|
||||
expect(pred.confidence).toBeDefined();
|
||||
expect(pred.confidence.raw).toBeDefined();
|
||||
expect(pred.confidence.adjusted).toBeDefined();
|
||||
expect(pred.confidence.label).toMatch(/^(high|medium|low)$/);
|
||||
expect(pred.lookalikes).toBeDefined();
|
||||
expect(Array.isArray(pred.lookalikes)).toBe(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("each prediction's diseaseId exists in knowledge base", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
const disease = await getDiseaseById(pred.diseaseId);
|
||||
expect(disease).toBeDefined();
|
||||
expect(disease!.id).toBe(pred.diseaseId);
|
||||
expect(disease!.name).toBe(pred.disease.name);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("predictions are sorted by confidence descending", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (let i = 0; i < data.predictions.length - 1; i++) {
|
||||
expect(data.predictions[i].confidence.adjusted).toBeGreaterThanOrEqual(
|
||||
data.predictions[i + 1].confidence.adjusted,
|
||||
);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("lookalike references are valid disease IDs", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
for (const lookalikeId of pred.lookalikes) {
|
||||
const lookalike = await getDiseaseById(lookalikeId);
|
||||
expect(lookalike).toBeDefined();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("inference completes under 3 seconds", async () => {
|
||||
const start = performance.now();
|
||||
const { data } = await callIdentify(imageId);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
expect(data.metadata.inferenceTimeMs).toBeLessThan(3000);
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
}, 10000);
|
||||
|
||||
it("confidence scores are in valid range", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
expect(pred.confidence.raw).toBeGreaterThanOrEqual(0);
|
||||
expect(pred.confidence.raw).toBeLessThanOrEqual(1);
|
||||
expect(pred.confidence.adjusted).toBeGreaterThanOrEqual(0);
|
||||
expect(pred.confidence.adjusted).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("disease entries have required fields", async () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
const disease = pred.disease;
|
||||
expect(disease.id).toBeDefined();
|
||||
expect(disease.plantId).toBeDefined();
|
||||
expect(disease.name).toBeDefined();
|
||||
expect(disease.scientificName).toBeDefined();
|
||||
expect(disease.causalAgentType).toMatch(/^(fungal|bacterial|viral|environmental)$/);
|
||||
expect(disease.description).toBeDefined();
|
||||
expect(disease.symptoms.length).toBeGreaterThanOrEqual(3);
|
||||
expect(disease.causes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(disease.treatment.length).toBeGreaterThanOrEqual(3);
|
||||
expect(disease.prevention.length).toBeGreaterThanOrEqual(2);
|
||||
expect(disease.severity).toMatch(/^(low|moderate|high|critical)$/);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
268
src/app/api/identify/route.ts
Normal file
268
src/app/api/identify/route.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Plant disease identification endpoint.
|
||||
*
|
||||
* Accepts POST with { imageId } from a previous /api/upload call.
|
||||
* Loads the uploaded image, preprocesses it (resize + normalize),
|
||||
* runs ML inference, enriches results with the knowledge base,
|
||||
* and returns ranked predictions with confidence scores.
|
||||
*
|
||||
* When no model is available, returns deterministic mock predictions
|
||||
* with demo_mode: true so the UI still works for development.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
|
||||
import { runInference } from "@/lib/ml/inference";
|
||||
import { calibrateConfidence } from "@/lib/ml/confidence";
|
||||
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
|
||||
import { getModel } from "@/lib/ml/model-loader";
|
||||
import { getDiseaseById, getPlantById, getLookalikeDiseases } from "@/lib/api/diseases-db";
|
||||
import type { IdentifyRequest, IdentifyResponse, PredictionResult } from "@/lib/types";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
|
||||
/** ImageNet normalization constants */
|
||||
const IMAGENET_MEAN = [0.485, 0.456, 0.406] as const;
|
||||
const IMAGENET_STD = [0.229, 0.224, 0.225] as const;
|
||||
|
||||
/** Model input size */
|
||||
const MODEL_SIZE = 160;
|
||||
|
||||
// ─── Server-side image preprocessing ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load an uploaded image and preprocess it into a Float32Array tensor
|
||||
* with shape [3, 160, 160] (NCHW without batch dim) using ImageNet normalization.
|
||||
*
|
||||
* @param imageId - The image ID from the upload endpoint
|
||||
* @returns Float32Array tensor ready for inference
|
||||
* @throws Error if image not found
|
||||
*/
|
||||
async function loadImageAndPreprocess(imageId: string): Promise<Float32Array> {
|
||||
// Find the image file — try original first, then resized version
|
||||
const uploads = await fs.readdir(UPLOADS_DIR).catch(() => []);
|
||||
|
||||
// Find files matching this imageId
|
||||
const matchingFiles = uploads.filter((f) => f.startsWith(imageId) && !f.includes("-resized"));
|
||||
if (matchingFiles.length === 0) {
|
||||
// Try the resized version
|
||||
const resizedFile = `${imageId}-resized.jpg`;
|
||||
if (fsSync.existsSync(path.join(UPLOADS_DIR, resizedFile))) {
|
||||
return preprocessImageBuffer(await fs.readFile(path.join(UPLOADS_DIR, resizedFile)));
|
||||
}
|
||||
throw new Error(`Image not found: ${imageId}`);
|
||||
}
|
||||
|
||||
const filename = matchingFiles[0];
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return preprocessImageBuffer(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess an image buffer into a normalized Float32Array tensor.
|
||||
* Uses sharp to resize, then applies ImageNet normalization.
|
||||
*
|
||||
* Output: flat Float32Array of length 3 × 224 × 224 in channel-first order
|
||||
* (all R values, then all G values, then all B values).
|
||||
*
|
||||
* @param buffer - Raw image buffer
|
||||
* @returns Normalized Float32Array tensor
|
||||
*/
|
||||
async function preprocessImageBuffer(buffer: Buffer): Promise<Float32Array> {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
|
||||
// Resize to model input size and get raw pixel data
|
||||
const pipeline = sharp(buffer).resize(MODEL_SIZE, MODEL_SIZE).raw().ensureAlpha(0); // RGB only, no alpha
|
||||
|
||||
const rawBuffer = await pipeline.toBuffer();
|
||||
|
||||
// Convert to Float32Array with channel-first layout and ImageNet normalization
|
||||
const totalPixels = MODEL_SIZE * MODEL_SIZE;
|
||||
const tensor = new Float32Array(3 * totalPixels);
|
||||
|
||||
// Extract channels from raw RGB data
|
||||
const rChannel = tensor.subarray(0, totalPixels);
|
||||
const gChannel = tensor.subarray(totalPixels, 2 * totalPixels);
|
||||
const bChannel = tensor.subarray(2 * totalPixels, 3 * totalPixels);
|
||||
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const idx = i * 3; // RGB stride
|
||||
rChannel[i] = rawBuffer[idx] / 255;
|
||||
gChannel[i] = rawBuffer[idx + 1] / 255;
|
||||
bChannel[i] = rawBuffer[idx + 2] / 255;
|
||||
}
|
||||
|
||||
// Apply ImageNet normalization
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const channel = c === 0 ? rChannel : c === 1 ? gChannel : bChannel;
|
||||
const m = IMAGENET_MEAN[c];
|
||||
const s = IMAGENET_STD[c];
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
channel[i] = (channel[i] - m) / s;
|
||||
}
|
||||
}
|
||||
|
||||
return tensor;
|
||||
}
|
||||
|
||||
// ─── Result enrichment ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enrich raw predictions with knowledge base data.
|
||||
*
|
||||
* For each prediction:
|
||||
* - Look up disease by ID in knowledge base
|
||||
* - Calibrate confidence score
|
||||
* - Include lookalike disease cross-references (IDs and full objects)
|
||||
*
|
||||
* @param topPredictions - Top-K raw predictions from inference
|
||||
* @returns Enriched prediction results
|
||||
*/
|
||||
async function enrichPredictions(
|
||||
topPredictions: Array<{ classIndex: number; probability: number }>,
|
||||
): Promise<PredictionResult[]> {
|
||||
const results: PredictionResult[] = [];
|
||||
|
||||
for (const pred of topPredictions) {
|
||||
const diseaseId = getDiseaseIdForIndex(pred.classIndex);
|
||||
|
||||
// Skip "healthy" and "unknown" — only return actual diseases
|
||||
if (diseaseId === "healthy" || diseaseId === "unknown") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up disease in knowledge base
|
||||
const disease = await getDiseaseById(diseaseId);
|
||||
if (!disease) {
|
||||
// Disease ID from model doesn't exist in knowledge base — skip
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calibrate confidence
|
||||
const confidence = calibrateConfidence(pred.probability);
|
||||
|
||||
// Pre-resolve lookalike disease objects server-side so the client
|
||||
// doesn't need sync access to JSON files
|
||||
const lookalikes = disease.lookalikeDiseaseIds;
|
||||
const lookalikeDiseases = await getLookalikeDiseases(diseaseId);
|
||||
|
||||
// Look up the plant for client convenience
|
||||
const plant = await getPlantById(disease.plantId).catch(() => null);
|
||||
|
||||
results.push({
|
||||
diseaseId,
|
||||
disease,
|
||||
confidence,
|
||||
lookalikes,
|
||||
lookalikeDiseases,
|
||||
plant: plant ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by adjusted confidence descending
|
||||
results.sort((a, b) => b.confidence.adjusted - a.confidence.adjusted);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Route Handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse request body
|
||||
let body: IdentifyRequest;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON", message: "Request body must be valid JSON.", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { imageId } = body;
|
||||
|
||||
// Validate imageId
|
||||
if (!imageId || typeof imageId !== "string") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Missing imageId",
|
||||
message: 'Request body must include "imageId" string.',
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check image exists
|
||||
const uploads = await fs.readdir(UPLOADS_DIR).catch(() => []);
|
||||
const imageExists = uploads.some((f) => f.startsWith(imageId));
|
||||
if (!imageExists) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image not found", message: `No image found with ID: ${imageId}`, status: 404 },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Load and preprocess image
|
||||
const tensor = await loadImageAndPreprocess(imageId);
|
||||
|
||||
// Run inference
|
||||
const { predictions: rawPredictions, inferenceTimeMs } = await runInference(tensor, 10);
|
||||
|
||||
// Get model status to check if we're in demo mode
|
||||
const model = await getModel();
|
||||
const modelStatus = model.getStatus();
|
||||
const demoMode = !modelStatus.loaded;
|
||||
|
||||
// Calibrate and filter predictions
|
||||
const calibratedPredictions = rawPredictions.map((pred) => ({
|
||||
classIndex: pred.classIndex,
|
||||
probability: pred.probability,
|
||||
}));
|
||||
|
||||
// Enrich with knowledge base
|
||||
const enrichedPredictions = await enrichPredictions(calibratedPredictions);
|
||||
|
||||
// Build response
|
||||
const response: IdentifyResponse = {
|
||||
predictions: enrichedPredictions,
|
||||
metadata: {
|
||||
model: modelStatus.modelId,
|
||||
inferenceTimeMs,
|
||||
imageId,
|
||||
},
|
||||
};
|
||||
|
||||
if (demoMode) {
|
||||
response.demo_mode = true;
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const status = message.includes("not found") ? 404 : 500;
|
||||
console.error(`[identify] Error: ${message}`);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: status === 404 ? "Image not found" : "Identification failed",
|
||||
message,
|
||||
status,
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/app/api/plants/[id]/route.ts
Normal file
34
src/app/api/plants/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/plants/[id]
|
||||
* Get a single plant with all its associated diseases.
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams): Promise<NextResponse> {
|
||||
const { id } = await params;
|
||||
|
||||
console.log(`[API] GET /api/plants/${id}`);
|
||||
|
||||
const result = await getPlantWithDiseases(id);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Not Found",
|
||||
message: `Plant with ID "${id}" not found`,
|
||||
status: 404,
|
||||
},
|
||||
{ status: 404, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "public, max-age=3600" },
|
||||
});
|
||||
}
|
||||
38
src/app/api/plants/[id]/view/route.ts
Normal file
38
src/app/api/plants/[id]/view/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* POST /api/plants/[id]/view
|
||||
*
|
||||
* Increments the view count for a plant in the plant_views table.
|
||||
* Called client-side from the plant detail page via a tiny tracker component.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { plantViews } from "@/lib/db/schema";
|
||||
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing plant id" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Upsert: increment view_count if row exists, otherwise insert with count 1
|
||||
await db
|
||||
.insert(plantViews)
|
||||
.values({ plantId: id, viewCount: 1 })
|
||||
.onConflictDoUpdate({
|
||||
target: plantViews.plantId,
|
||||
set: { viewCount: sql`${plantViews.viewCount} + 1` },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[View] Failed to record view for", id, err);
|
||||
// Swallow errors — tracking failure shouldn't break the page
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
98
src/app/api/plants/plants.test.ts
Normal file
98
src/app/api/plants/plants.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GET } from "./route";
|
||||
import * as diseasesLib from "@/lib/api/diseases-db";
|
||||
|
||||
// Mock the diseases library
|
||||
vi.mock("@/lib/api/diseases-db", () => ({
|
||||
listPlants: vi.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
|
||||
describe("GET /api/plants", () => {
|
||||
const createRequest = (searchParams: string) => {
|
||||
const url = new URL(`http://localhost/api/plants${searchParams}`);
|
||||
const req = new Request(url);
|
||||
(req as any).nextUrl = url;
|
||||
return req;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns all plants with no filters", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "tomato", commonName: "Tomato" },
|
||||
{ id: "pepper", commonName: "Pepper" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest(""));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.plants).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it("filters plants by search term", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "tomato", commonName: "Tomato" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?search=tomato"));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.plants[0].commonName).toBe("Tomato");
|
||||
});
|
||||
|
||||
it("filters plants by category", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "tomato", commonName: "Tomato", category: "vegetable" },
|
||||
]);
|
||||
|
||||
const response = await GET(createRequest("?category=vegetable"));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for empty search term", async () => {
|
||||
const response = await GET(createRequest("?search="));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Bad Request");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid category", async () => {
|
||||
const response = await GET(createRequest("?category=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toMatch(/Invalid category/i);
|
||||
});
|
||||
|
||||
it("returns cache control header", async () => {
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const response = await GET(createRequest(""));
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
expect(cacheControl).toContain("max-age=3600");
|
||||
});
|
||||
|
||||
it("accepts valid categories", async () => {
|
||||
const validCategories = [
|
||||
"vegetable",
|
||||
"herb",
|
||||
"houseplant",
|
||||
"flower",
|
||||
"fruit",
|
||||
"succulent",
|
||||
"tree",
|
||||
];
|
||||
|
||||
(diseasesLib.listPlants as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
for (const cat of validCategories) {
|
||||
const response = await GET(createRequest(`?category=${cat}`));
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
63
src/app/api/plants/route.ts
Normal file
63
src/app/api/plants/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { listPlants } from "@/lib/api/diseases-db";
|
||||
|
||||
/**
|
||||
* GET /api/plants
|
||||
* List all plants or search by term.
|
||||
* Query params: ?search=<term> & ?category=<category>
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const search = searchParams.get("search");
|
||||
const category = searchParams.get("category") as
|
||||
| "vegetable"
|
||||
| "herb"
|
||||
| "houseplant"
|
||||
| "flower"
|
||||
| "fruit"
|
||||
| "succulent"
|
||||
| "tree"
|
||||
| null;
|
||||
|
||||
// Validate search param
|
||||
if (search !== null && search.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "Search term cannot be empty", status: 400 },
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate category param
|
||||
const validCategories = [
|
||||
"vegetable",
|
||||
"herb",
|
||||
"houseplant",
|
||||
"flower",
|
||||
"fruit",
|
||||
"succulent",
|
||||
"tree",
|
||||
];
|
||||
if (category !== null && !validCategories.includes(category)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid category. Must be one of: ${validCategories.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400, headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[API] GET /api/plants search="${search}" category="${category}"`);
|
||||
|
||||
const results = await listPlants({
|
||||
search: search || undefined,
|
||||
category: category || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ plants: results, total: results.length },
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
98
src/app/api/plants/suggestions/route.ts
Normal file
98
src/app/api/plants/suggestions/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* GET /api/plants/suggestions?q=<term>
|
||||
*
|
||||
* Returns autocomplete suggestions for the navbar search-as-you-type feature.
|
||||
* Queries both plants and diseases from the database and returns an interleaved
|
||||
* list with at most 8 suggestions total.
|
||||
*
|
||||
* Each suggestion includes: type (plant|disease), id, label, subtitle, emoji, href.
|
||||
* Plants link to their browse detail page; diseases link to the plant page with
|
||||
* a hash anchor to the specific disease card.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { like, or, eq } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { plants, diseases } from "@/lib/db/schema";
|
||||
import { getEmojiForCategory } from "@/lib/display-helpers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SuggestionItem {
|
||||
type: "plant" | "disease";
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
emoji: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||
|
||||
// Empty or very short queries return no suggestions
|
||||
if (q.length < 1) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const term = `%${q.toLowerCase()}%`;
|
||||
|
||||
// Fetch matching plants (by common name or scientific name)
|
||||
const plantRows = await db
|
||||
.select({
|
||||
id: plants.id,
|
||||
commonName: plants.commonName,
|
||||
scientificName: plants.scientificName,
|
||||
category: plants.category,
|
||||
})
|
||||
.from(plants)
|
||||
.where(or(like(plants.commonName, term), like(plants.scientificName, term)))
|
||||
.limit(5);
|
||||
|
||||
// Fetch matching diseases (by name or scientific name) with parent plant info
|
||||
const diseaseRows = await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
name: diseases.name,
|
||||
plantId: diseases.plantId,
|
||||
plantCommonName: plants.commonName,
|
||||
plantCategory: plants.category,
|
||||
})
|
||||
.from(diseases)
|
||||
.leftJoin(plants, eq(diseases.plantId, plants.id))
|
||||
.where(or(like(diseases.name, term), like(diseases.scientificName, term)))
|
||||
.limit(5);
|
||||
|
||||
const plantSuggestions: SuggestionItem[] = plantRows.map((p) => ({
|
||||
type: "plant" as const,
|
||||
id: p.id,
|
||||
label: p.commonName,
|
||||
subtitle: p.scientificName,
|
||||
emoji: getEmojiForCategory(p.category),
|
||||
href: `/browse/${p.id}`,
|
||||
}));
|
||||
|
||||
const diseaseSuggestions: SuggestionItem[] = diseaseRows.map((d) => ({
|
||||
type: "disease" as const,
|
||||
id: d.id,
|
||||
label: d.name,
|
||||
subtitle: `Disease on ${d.plantCommonName ?? "Unknown plant"}`,
|
||||
emoji: getEmojiForCategory(d.plantCategory ?? "houseplant"),
|
||||
href: `/browse/${d.plantId}#disease-${d.id}`,
|
||||
}));
|
||||
|
||||
// Interleave plant and disease results so the dropdown shows variety
|
||||
const interleaved: SuggestionItem[] = [];
|
||||
const maxLen = Math.max(plantSuggestions.length, diseaseSuggestions.length);
|
||||
for (let i = 0; i < maxLen && interleaved.length < 8; i++) {
|
||||
if (i < plantSuggestions.length) {
|
||||
interleaved.push(plantSuggestions[i]);
|
||||
}
|
||||
if (i < diseaseSuggestions.length && interleaved.length < 8) {
|
||||
interleaved.push(diseaseSuggestions[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions: interleaved });
|
||||
}
|
||||
188
src/app/api/upload/route.ts
Normal file
188
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Server-side image upload endpoint.
|
||||
*
|
||||
* Accepts multipart/form-data with field "image".
|
||||
* Validates MIME type, file size, and minimum dimensions.
|
||||
* Saves to public/uploads/{uuid}.{ext}.
|
||||
* Runs server-side preprocessing (resize + normalize).
|
||||
* Returns { imageId, tensorShape, previewUrl }.
|
||||
*
|
||||
* Cleanup: keeps last MAX_UPLOADS files, purges older ones.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_FILE_SIZE,
|
||||
MIN_DIMENSION,
|
||||
MAX_UPLOADS,
|
||||
getTensorShape,
|
||||
} from "@/lib/image-processing";
|
||||
import {
|
||||
mimeTypeToExtension,
|
||||
resizeImageServer,
|
||||
} from "@/lib/server/image-processing-server";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
|
||||
// Uploads directory
|
||||
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
|
||||
// Ensure uploads directory exists
|
||||
async function ensureUploadsDir(): Promise<void> {
|
||||
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* List existing uploads sorted by modification time (oldest first).
|
||||
*/
|
||||
async function listUploads(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(UPLOADS_DIR);
|
||||
const files = await Promise.all(
|
||||
entries.map(async (name) => {
|
||||
const stat = await fs.stat(path.join(UPLOADS_DIR, name));
|
||||
return { name, mtime: stat.mtimeMs };
|
||||
}),
|
||||
);
|
||||
return files.sort((a, b) => a.mtime - b.mtime).map((f) => f.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge oldest uploads to stay within MAX_UPLOADS limit.
|
||||
*/
|
||||
async function cleanupOldUploads(): Promise<void> {
|
||||
const files = await listUploads();
|
||||
const toDelete = files.slice(0, files.length - MAX_UPLOADS);
|
||||
await Promise.all(
|
||||
toDelete.map((name) =>
|
||||
fs.unlink(path.join(UPLOADS_DIR, name)).catch(() => {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image dimensions from a buffer without full decode.
|
||||
* Uses sharp to get metadata efficiently.
|
||||
*/
|
||||
async function getImageDimensions(buffer: Buffer): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
try {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
return {
|
||||
width: metadata.width ?? 0,
|
||||
height: metadata.height ?? 0,
|
||||
};
|
||||
} catch {
|
||||
throw new Error("sharp is required for image dimension validation.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route Handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureUploadsDir();
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await request.formData();
|
||||
const imageFile = formData.get("image");
|
||||
|
||||
if (!imageFile || !(imageFile instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing file", message: 'No "image" field in form data.', status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
const mimeType = imageFile.type;
|
||||
if (!ALLOWED_MIME_TYPES.includes(mimeType as (typeof ALLOWED_MIME_TYPES)[number])) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid MIME type",
|
||||
message: `Type "${mimeType}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}.`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (imageFile.size > MAX_FILE_SIZE) {
|
||||
const mb = (imageFile.size / (1024 * 1024)).toFixed(1);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "File too large",
|
||||
message: `File is ${mb} MB. Maximum is 10 MB.`,
|
||||
status: 413,
|
||||
},
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
// Read file buffer
|
||||
const buffer = Buffer.from(await imageFile.arrayBuffer());
|
||||
|
||||
// Validate dimensions
|
||||
const { width, height } = await getImageDimensions(buffer);
|
||||
if (width < MIN_DIMENSION || height < MIN_DIMENSION) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Image too small",
|
||||
message: `Image is ${width}×${height}. Minimum is ${MIN_DIMENSION}×${MIN_DIMENSION}.`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const imageId = uuidv4();
|
||||
const ext = mimeTypeToExtension(mimeType);
|
||||
const filename = `${imageId}.${ext}`;
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
// Save original file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Server-side preprocessing: resize to model size
|
||||
const modelSize = 224;
|
||||
const resizedBuffer = await resizeImageServer(buffer, modelSize);
|
||||
|
||||
// Save resized version (for preview / model input)
|
||||
const resizedFilename = `${imageId}-resized.jpg`;
|
||||
const resizedPath = path.join(UPLOADS_DIR, resizedFilename);
|
||||
await fs.writeFile(resizedPath, resizedBuffer);
|
||||
|
||||
// Cleanup old uploads
|
||||
await cleanupOldUploads();
|
||||
|
||||
// Return response
|
||||
return NextResponse.json({
|
||||
imageId,
|
||||
tensorShape: getTensorShape(),
|
||||
previewUrl: `/uploads/${filename}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[upload] Error:", message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Upload failed",
|
||||
message,
|
||||
status: 500,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
141
src/app/api/upload/upload.test.ts
Normal file
141
src/app/api/upload/upload.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Integration tests for app/api/upload/route.ts
|
||||
*
|
||||
* These tests call the actual running dev server via fetch.
|
||||
* Run with: `npm run dev` in one terminal, then `npx vitest run src/app/api/upload/upload.test.ts`
|
||||
*
|
||||
* Tests:
|
||||
* - Upload a valid JPG → POST /api/upload returns 200 with expected shape
|
||||
* - Upload a 12 MB file → returns 413 or validation error
|
||||
* - Upload a .txt file → returns 400 with MIME error
|
||||
* - Upload missing field → returns 400
|
||||
* - Upload too-small image → returns 400
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a test image buffer using sharp.
|
||||
*/
|
||||
async function createTestImage(
|
||||
width: number,
|
||||
height: number,
|
||||
bg = { r: 34, g: 197, b: 94 },
|
||||
): Promise<Buffer> {
|
||||
const sharpMod = await import("sharp");
|
||||
const sharp = sharpMod.default;
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: bg },
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the /api/upload endpoint and return the response.
|
||||
*/
|
||||
async function uploadFile(file: File): Promise<{
|
||||
status: number;
|
||||
data: any;
|
||||
ok: boolean;
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return { status: response.status, data, ok: response.ok };
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/upload", () => {
|
||||
beforeAll(() => {
|
||||
// Ensure the dev server is running
|
||||
if (!BASE_URL) {
|
||||
console.warn(
|
||||
"TEST_BASE_URL not set. Using default http://localhost:3000",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid JPEG and returns imageId, tensorShape, previewUrl", async () => {
|
||||
const buffer = await createTestImage(300, 300);
|
||||
const file = new File([buffer], "test.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.imageId).toBeDefined();
|
||||
expect(typeof data.imageId).toBe("string");
|
||||
expect(data.imageId.length).toBeGreaterThan(0);
|
||||
expect(data.tensorShape).toEqual([1, 3, 224, 224]);
|
||||
expect(data.previewUrl).toMatch(/^\/uploads\/.*\.jpg$/);
|
||||
}, 15000);
|
||||
|
||||
it("accepts a valid PNG", async () => {
|
||||
const buffer = await createTestImage(200, 200);
|
||||
const file = new File([buffer], "test.png", { type: "image/png" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.previewUrl).toMatch(/^\/uploads\/.*\.png$/);
|
||||
}, 15000);
|
||||
|
||||
it("accepts a valid WebP", async () => {
|
||||
const buffer = await createTestImage(200, 200);
|
||||
const file = new File([buffer], "test.webp", { type: "image/webp" });
|
||||
const { status } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
}, 15000);
|
||||
|
||||
it("rejects a .txt file with 400 MIME error", async () => {
|
||||
const file = new File(["not an image"], "document.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(data.error).toBe("Invalid MIME type");
|
||||
expect(data.message).toContain("text/plain");
|
||||
}, 15000);
|
||||
|
||||
it("rejects a file >10 MB with 413", async () => {
|
||||
const bigBuffer = Buffer.alloc(12 * 1024 * 1024);
|
||||
const file = new File([bigBuffer], "huge.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(413);
|
||||
expect(data.error).toBe("File too large");
|
||||
}, 30000);
|
||||
|
||||
it("rejects an image that is too small (<150×150)", async () => {
|
||||
const buffer = await createTestImage(50, 50);
|
||||
const file = new File([buffer], "tiny.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(data.error).toBe("Image too small");
|
||||
expect(data.message).toContain("150");
|
||||
}, 15000);
|
||||
|
||||
it("returns tensorShape [1, 3, 224, 224] for any valid input", async () => {
|
||||
// Test with a non-square image
|
||||
const buffer = await createTestImage(400, 200);
|
||||
const file = new File([buffer], "wide.jpg", { type: "image/jpeg" });
|
||||
const { status, data } = await uploadFile(file);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.tensorShape).toEqual([1, 3, 224, 224]);
|
||||
}, 15000);
|
||||
});
|
||||
209
src/app/browse/BrowseContent.test.tsx
Normal file
209
src/app/browse/BrowseContent.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import BrowseContent from "@/app/browse/BrowseContent";
|
||||
import type { PlantCardData } from "@/components/PlantCard";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: vi.fn(() => ({
|
||||
get: vi.fn(() => null),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock PlantCard
|
||||
vi.mock("@/components/PlantCard", () => ({
|
||||
default: ({ plant }: { plant: PlantCardData }) => (
|
||||
<div data-testid={`plant-card-${plant.id}`}>
|
||||
<span>{plant.commonName}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock EmptyState
|
||||
vi.mock("@/components/EmptyState", () => ({
|
||||
default: ({ title, description, actionLabel }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<span data-testid="empty-title">{title}</span>
|
||||
<span data-testid="empty-desc">{description}</span>
|
||||
{actionLabel && <span>{actionLabel}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const MOCK_PLANTS: PlantCardData[] = [
|
||||
{
|
||||
id: "tomato",
|
||||
commonName: "Tomato",
|
||||
scientificName: "Solanum lycopersicum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetable",
|
||||
imageUrl: "https://example.com/tomato.jpg",
|
||||
diseaseCount: 15,
|
||||
},
|
||||
{
|
||||
id: "basil",
|
||||
commonName: "Basil",
|
||||
scientificName: "Ocimum basilicum",
|
||||
family: "Lamiaceae",
|
||||
category: "herb",
|
||||
imageUrl: "https://example.com/basil.jpg",
|
||||
diseaseCount: 3,
|
||||
},
|
||||
{
|
||||
id: "rose",
|
||||
commonName: "Rose",
|
||||
scientificName: "Rosa spp.",
|
||||
family: "Rosaceae",
|
||||
category: "flower",
|
||||
imageUrl: "https://example.com/rose.jpg",
|
||||
diseaseCount: 7,
|
||||
},
|
||||
{
|
||||
id: "monstera",
|
||||
commonName: "Monstera",
|
||||
scientificName: "Monstera deliciosa",
|
||||
family: "Araceae",
|
||||
category: "houseplant",
|
||||
imageUrl: "https://example.com/monstera.jpg",
|
||||
diseaseCount: 5,
|
||||
},
|
||||
{
|
||||
id: "snake-plant",
|
||||
commonName: "Snake Plant",
|
||||
scientificName: "Dracaena trifasciata",
|
||||
family: "Asparagaceae",
|
||||
category: "houseplant",
|
||||
imageUrl: "https://example.com/snake-plant.jpg",
|
||||
diseaseCount: 2,
|
||||
},
|
||||
{
|
||||
id: "pepper",
|
||||
commonName: "Bell Pepper",
|
||||
scientificName: "Capsicum annuum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetable",
|
||||
imageUrl: "https://example.com/pepper.jpg",
|
||||
diseaseCount: 9,
|
||||
},
|
||||
];
|
||||
|
||||
describe("BrowseContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders page header with plant count", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
expect(screen.getByText("Browse Plants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search input", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox", {
|
||||
name: /Search plants and diseases/i,
|
||||
});
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters plants by search query", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
|
||||
// Should show tomato plant
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows results count", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
expect(screen.getByText(/Showing \d+ plants/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders category filter tabs", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const tablist = screen.getByRole("tablist", { name: /Plant categories/i });
|
||||
expect(tablist).toBeInTheDocument();
|
||||
|
||||
// Should have category tabs
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filters by category when tab is clicked", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
|
||||
// Click a category tab (not 'all')
|
||||
const vegTab = tabs.find((t) => t.textContent?.toLowerCase().includes("vegetable"));
|
||||
if (vegTab) {
|
||||
fireEvent.click(vegTab);
|
||||
expect(screen.getByText(/in vegetable/i)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears search when clear button is clicked", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
expect(searchInput.value).toBe("tomato");
|
||||
|
||||
const clearBtn = screen.getByRole("button", { name: /Clear search/i });
|
||||
fireEvent.click(clearBtn);
|
||||
|
||||
expect(searchInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("shows empty state when no plants match search", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
|
||||
|
||||
expect(screen.getByTestId("empty-title")).toHaveTextContent("No plants found");
|
||||
});
|
||||
|
||||
it("shows empty state with search query in description", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "xyznonexistent123" } });
|
||||
|
||||
expect(screen.getByTestId("empty-desc")).toHaveTextContent(/xyznonexistent123/i);
|
||||
});
|
||||
|
||||
it("shows matching text in results count", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "tomato" } });
|
||||
|
||||
expect(screen.getByText(/matching "tomato"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all plant cards when no filter applied", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
// Should show all plants
|
||||
const plantCards = screen.getAllByTestId(/plant-card-/);
|
||||
expect(plantCards.length).toBe(MOCK_PLANTS.length);
|
||||
});
|
||||
|
||||
it("searches by scientific name", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "solanum" } });
|
||||
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches by family name", () => {
|
||||
render(<BrowseContent allPlants={MOCK_PLANTS} />);
|
||||
const searchInput = screen.getByRole("searchbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "solanaceae" } });
|
||||
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
226
src/app/browse/BrowseContent.tsx
Normal file
226
src/app/browse/BrowseContent.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import PlantCard from "@/components/PlantCard";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { PLANT_CATEGORIES } from "@/lib/constants";
|
||||
import type { PlantCardData } from "@/components/PlantCard";
|
||||
|
||||
type SortKey = "name" | "recent" | "popular";
|
||||
|
||||
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
|
||||
{ value: "name", label: "Name (A-Z)" },
|
||||
{ value: "recent", label: "Recently Updated" },
|
||||
{ value: "popular", label: "Most Popular" },
|
||||
];
|
||||
|
||||
interface BrowseContentProps {
|
||||
allPlants: PlantCardData[];
|
||||
}
|
||||
|
||||
type Category = string | "all";
|
||||
|
||||
/**
|
||||
* Client component that handles the interactive browse/search/filter logic.
|
||||
* Receives all plants as props from the parent server component.
|
||||
* Wrapped in a Suspense boundary in the parent page.
|
||||
*/
|
||||
export default function BrowseContent({ allPlants }: BrowseContentProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const initialSearch = searchParams.get("search") || "";
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [activeCategory, setActiveCategory] = useState<Category>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
|
||||
const filteredPlants = useMemo(() => {
|
||||
let result = allPlants;
|
||||
|
||||
if (activeCategory !== "all") {
|
||||
result = result.filter((p) => p.category === activeCategory);
|
||||
}
|
||||
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (q) {
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.commonName.toLowerCase().includes(q) ||
|
||||
p.scientificName.toLowerCase().includes(q) ||
|
||||
p.family.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorted = [...result];
|
||||
if (sortKey === "recent") {
|
||||
sorted.sort((a, b) => {
|
||||
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return bTime - aTime; // newest first
|
||||
});
|
||||
} else if (sortKey === "popular") {
|
||||
sorted.sort((a, b) => (b.viewCount ?? 0) - (a.viewCount ?? 0));
|
||||
} else {
|
||||
sorted.sort((a, b) => a.commonName.localeCompare(b.commonName));
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [activeCategory, searchQuery, allPlants, sortKey]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">Browse Plants</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
Explore our database of {allPlants.length} plants and their common diseases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls row: search + sort */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Search bar */}
|
||||
<div className="relative flex-1">
|
||||
<label htmlFor="browse-search" className="sr-only">
|
||||
Search plants and diseases
|
||||
</label>
|
||||
<div className="relative">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
id="browse-search"
|
||||
type="search"
|
||||
placeholder="Search by plant name, scientific name, or family..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="relative shrink-0">
|
||||
<label htmlFor="sort-select" className="sr-only">
|
||||
Sort by
|
||||
</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
className="w-full sm:w-auto appearance-none rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 px-4 py-3 pr-10 text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm cursor-pointer"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category filter chips */}
|
||||
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Plant categories">
|
||||
{PLANT_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.value}
|
||||
role="tab"
|
||||
aria-selected={activeCategory === cat.value}
|
||||
onClick={() => setActiveCategory(cat.value as Category)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2 ${
|
||||
activeCategory === cat.value
|
||||
? "bg-leaf-green-600 text-white shadow-sm"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
{filteredPlants.length === 0
|
||||
? "No plants found"
|
||||
: `Showing ${filteredPlants.length} ${filteredPlants.length === 1 ? "plant" : "plants"}`}
|
||||
{activeCategory !== "all" &&
|
||||
` in ${PLANT_CATEGORIES.find((c) => c.value === activeCategory)?.label.toLowerCase()}`}
|
||||
{searchQuery.trim() && ` matching "${searchQuery.trim()}"`}
|
||||
</p>
|
||||
|
||||
{/* Plant grid or empty state */}
|
||||
{filteredPlants.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredPlants.map((plant) => (
|
||||
<PlantCard key={plant.id} plant={plant} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
illustration="🔍"
|
||||
title="No plants found"
|
||||
description={
|
||||
searchQuery.trim()
|
||||
? `We couldn't find any plants matching "${searchQuery.trim()}". Try a different search term or browse a different category.`
|
||||
: "No plants in this category yet. We're constantly adding new plants to our database."
|
||||
}
|
||||
actionLabel="Clear filters"
|
||||
actionHref="/browse"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
524
src/app/browse/[plantId]/DiseaseCards.tsx
Normal file
524
src/app/browse/[plantId]/DiseaseCards.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
|
||||
import ImageLightbox from "@/components/ImageLightbox";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
// ─── 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">
|
||||
<PrevalenceBadge prevalence={disease.prevalence} />
|
||||
<TypeBadge type={disease.causalAgentType} />
|
||||
<SeverityBadge severity={disease.severity} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disease image or placeholder */}
|
||||
<div className="mb-2 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700 relative">
|
||||
{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>
|
||||
{/* Flag button for disease image */}
|
||||
<div className="flex justify-end mb-2">
|
||||
<FlagButton
|
||||
contentType="disease_image"
|
||||
contentId={disease.id}
|
||||
fieldName="image"
|
||||
label="disease image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||||
{disease.description}
|
||||
</p>
|
||||
<FlagButton
|
||||
contentType="disease_description"
|
||||
contentId={disease.id}
|
||||
fieldName="description"
|
||||
label="description"
|
||||
small
|
||||
className="shrink-0 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Symptoms */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_symptoms"
|
||||
contentId={disease.id}
|
||||
fieldName="symptoms"
|
||||
label="symptoms"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_causes"
|
||||
contentId={disease.id}
|
||||
fieldName="causes"
|
||||
label="causes"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_treatment"
|
||||
contentId={disease.id}
|
||||
fieldName="treatment"
|
||||
label="treatment"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_prevention"
|
||||
contentId={disease.id}
|
||||
fieldName="prevention"
|
||||
label="prevention tips"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Prevalence badge ───
|
||||
|
||||
function PrevalenceBadge({ prevalence }: { prevalence: Prevalence }) {
|
||||
const icons: Record<Prevalence, string> = {
|
||||
common: "📊",
|
||||
uncommon: "📋",
|
||||
rare: "📌",
|
||||
very_rare: "🔍",
|
||||
};
|
||||
const colors: Record<Prevalence, string> = {
|
||||
common: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
uncommon: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300",
|
||||
rare: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
very_rare: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
};
|
||||
|
||||
const label = prevalence.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[prevalence]}`}
|
||||
>
|
||||
{icons[prevalence]} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sort / Search controls ───
|
||||
|
||||
const SEVERITY_RANK: Record<Severity, number> = {
|
||||
critical: 4,
|
||||
high: 3,
|
||||
moderate: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
const PREVALENCE_RANK: Record<Prevalence, number> = {
|
||||
common: 4,
|
||||
uncommon: 3,
|
||||
rare: 2,
|
||||
very_rare: 1,
|
||||
};
|
||||
|
||||
type SortField = "prevalence" | "danger";
|
||||
|
||||
function SearchSortBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
sortField,
|
||||
onSortFieldChange,
|
||||
sortOrder,
|
||||
onSortOrderToggle,
|
||||
resultCount,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
sortField: SortField;
|
||||
onSortFieldChange: (f: SortField) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
onSortOrderToggle: () => void;
|
||||
resultCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 flex items-center pl-3 text-zinc-400 dark:text-zinc-500 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m21 21-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search diseases by name…"
|
||||
className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 py-2 pl-10 pr-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-colors"
|
||||
aria-label="Search diseases"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="text-zinc-500 dark:text-zinc-400 font-medium">Sort by:</span>
|
||||
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSortFieldChange("prevalence")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
sortField === "prevalence"
|
||||
? "bg-leaf-green-600 text-white"
|
||||
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
Prevalence
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSortFieldChange("danger")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
sortField === "danger"
|
||||
? "bg-leaf-green-600 text-white"
|
||||
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
Danger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Direction toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSortOrderToggle}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
aria-label={
|
||||
sortOrder === "desc"
|
||||
? "Sorted descending, click for ascending"
|
||||
: "Sorted ascending, click for descending"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform ${sortOrder === "asc" ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{sortField === "danger"
|
||||
? sortOrder === "desc"
|
||||
? "Most dangerous first"
|
||||
: "Least dangerous first"
|
||||
: sortOrder === "desc"
|
||||
? "Most prevalent first"
|
||||
: "Least prevalent first"}
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-auto">
|
||||
{resultCount} {resultCount === 1 ? "result" : "results"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Client component wrapper ───
|
||||
|
||||
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("danger");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// ── Filtered + sorted diseases ──
|
||||
|
||||
const processed = useMemo(() => {
|
||||
// Filter
|
||||
let result = diseases;
|
||||
const trimmed = searchQuery.trim().toLowerCase();
|
||||
if (trimmed) {
|
||||
result = result.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(trimmed) ||
|
||||
d.scientificName.toLowerCase().includes(trimmed),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorted = [...result].sort((a, b) => {
|
||||
let cmp: number;
|
||||
if (sortField === "danger") {
|
||||
cmp = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
||||
} else {
|
||||
cmp = PREVALENCE_RANK[a.prevalence] - PREVALENCE_RANK[b.prevalence];
|
||||
}
|
||||
return sortOrder === "desc" ? -cmp : cmp;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [diseases, searchQuery, sortField, sortOrder]);
|
||||
|
||||
// Build list of images from processed diseases that have imageUrls
|
||||
const images = useMemo(
|
||||
() =>
|
||||
processed
|
||||
.filter((d) => d.imageUrl)
|
||||
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` })),
|
||||
[processed],
|
||||
);
|
||||
|
||||
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), []);
|
||||
|
||||
const handleSortOrderToggle = useCallback(() => {
|
||||
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
|
||||
}, []);
|
||||
|
||||
if (diseases.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchSortBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
sortField={sortField}
|
||||
onSortFieldChange={setSortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortOrderToggle={handleSortOrderToggle}
|
||||
resultCount={processed.length}
|
||||
/>
|
||||
|
||||
{processed.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{processed.map((disease) => (
|
||||
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
|
||||
<span className="text-4xl block mb-3" aria-hidden="true">
|
||||
🔍
|
||||
</span>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
|
||||
No diseases match “{searchQuery}”.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxOpen && images.length > 0 && (
|
||||
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
src/app/browse/[plantId]/page.tsx
Normal file
195
src/app/browse/[plantId]/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
||||
import { getPlantDescription } from "@/lib/display-helpers";
|
||||
import BetaNotice from "@/components/BetaNotice";
|
||||
import DiseaseCards from "./DiseaseCards";
|
||||
import PlantViewTracker from "@/components/PlantViewTracker";
|
||||
import FlagPlantImage from "@/components/FlagPlantImage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ plantId: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const { getDb } = await import("@/lib/db/index");
|
||||
const { plants } = await import("@/lib/db/schema");
|
||||
const db = getDb();
|
||||
const rows = await db.select({ id: plants.id }).from(plants);
|
||||
return rows.map((p: { id: string }) => ({
|
||||
plantId: p.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { plantId } = await params;
|
||||
const result = await getPlantWithDiseases(plantId);
|
||||
|
||||
if (!result) {
|
||||
return { title: "Plant Not Found" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${result.plant.commonName} — Diseases & Care`,
|
||||
description: `Learn about ${result.plant.commonName} (${result.plant.scientificName}) diseases, symptoms, causes, and treatments. ${result.diseases.length} diseases documented.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Plant Detail Page ───
|
||||
|
||||
export default async function PlantDetailPage({ params }: Props) {
|
||||
const { plantId } = await params;
|
||||
const result = await getPlantWithDiseases(plantId);
|
||||
|
||||
if (!result) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { plant, diseases } = result;
|
||||
const description = getPlantDescription(
|
||||
plant.commonName,
|
||||
plant.scientificName,
|
||||
plant.category,
|
||||
plant.family,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlantViewTracker plantId={plantId} />
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
||||
>
|
||||
Browse
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<BetaNotice variant="card" className="mb-6" />
|
||||
|
||||
{/* Plant hero */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
|
||||
{/* Plant image */}
|
||||
<div className="relative h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
|
||||
{plant.imageUrl ? (
|
||||
<Image
|
||||
src={plant.imageUrl}
|
||||
alt={plant.commonName}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(min-width: 640px) 16rem, 8rem"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<svg
|
||||
className="w-12 h-12 text-leaf-green-300 dark:text-leaf-green-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<FlagPlantImage plantId={plantId} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
{plant.commonName}
|
||||
</h1>
|
||||
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
|
||||
{plant.scientificName}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Family: <span className="font-medium">{plant.family}</span>
|
||||
{" · "}
|
||||
Category: <span className="font-medium capitalize">{plant.category}</span>
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span aria-hidden="true">💚</span>
|
||||
<span>{plant.careSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identify disease CTA */}
|
||||
<div className="mb-10 rounded-xl bg-gradient-to-r from-leaf-green-50 to-soil-brown-50 dark:from-leaf-green-950 dark:to-soil-brown-950 border border-leaf-green-200 dark:border-leaf-green-800 p-5 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900 ">
|
||||
🧐 Spot a problem on your {plant.commonName.toLowerCase()}?
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
Upload a photo for AI-powered disease identification.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
📸 Identify a Disease
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disease list */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
|
||||
Known Diseases
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
{diseases.length === 0
|
||||
? "No diseases currently documented for this plant."
|
||||
: `${diseases.length} ${
|
||||
diseases.length === 1 ? "disease" : "diseases"
|
||||
} documented for ${plant.commonName}.`}
|
||||
</p>
|
||||
|
||||
{diseases.length > 0 ? (
|
||||
<DiseaseCards diseases={diseases} />
|
||||
) : (
|
||||
<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>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
|
||||
Disease data for {plant.commonName} is being researched and will be added soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/app/browse/page.tsx
Normal file
42
src/app/browse/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Suspense } from "react";
|
||||
import { getBrowsePlants } from "@/lib/api/browse";
|
||||
import BrowseContent from "./BrowseContent";
|
||||
import { PlantCardSkeleton } from "@/components/LoadingSkeleton";
|
||||
import BetaNotice from "@/components/BetaNotice";
|
||||
|
||||
/**
|
||||
* Browse page — fetches plants with disease counts from the database
|
||||
* and passes them to the client-side search/filter component.
|
||||
* Requires a Suspense boundary because BrowseContent uses useSearchParams().
|
||||
*/
|
||||
export default async function BrowsePage() {
|
||||
const allPlants = await getBrowsePlants();
|
||||
|
||||
return (
|
||||
<>
|
||||
<BetaNotice variant="full-width" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<div className="mb-8">
|
||||
<div className="h-9 w-48 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
|
||||
<div className="mt-2 h-5 w-72 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
<div className="mb-6 h-12 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-zinc-700" />
|
||||
<div className="flex gap-2 mb-8">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 w-24 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-700"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PlantCardSkeleton count={8} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BrowseContent allPlants={allPlants} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
120
src/app/globals.css
Normal file
120
src/app/globals.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-inter);
|
||||
|
||||
/* Custom design tokens — plant disease identification theme */
|
||||
--color-leaf-green-50: #f0fdf4;
|
||||
--color-leaf-green-100: #dcfce7;
|
||||
--color-leaf-green-200: #bbf7d0;
|
||||
--color-leaf-green-300: #86efac;
|
||||
--color-leaf-green-400: #4ade80;
|
||||
--color-leaf-green-500: #22c55e;
|
||||
--color-leaf-green-600: #16a34a;
|
||||
--color-leaf-green-700: #15803d;
|
||||
--color-leaf-green-800: #166534;
|
||||
--color-leaf-green-900: #14532d;
|
||||
|
||||
--color-soil-brown-50: #fdf8f6;
|
||||
--color-soil-brown-100: #f2e8e5;
|
||||
--color-soil-brown-200: #e6d5ce;
|
||||
--color-soil-brown-300: #d4b5a9;
|
||||
--color-soil-brown-400: #c0907e;
|
||||
--color-soil-brown-500: #a3705a;
|
||||
--color-soil-brown-600: #8a5a48;
|
||||
--color-soil-brown-700: #724a3c;
|
||||
--color-soil-brown-800: #5e3e34;
|
||||
--color-soil-brown-900: #4d342b;
|
||||
|
||||
--color-warning-amber-50: #fffbeb;
|
||||
--color-warning-amber-100: #fef3c7;
|
||||
--color-warning-amber-200: #fde68a;
|
||||
--color-warning-amber-300: #fcd34d;
|
||||
--color-warning-amber-400: #fbbf24;
|
||||
--color-warning-amber-500: #f59e0b;
|
||||
--color-warning-amber-600: #d97706;
|
||||
--color-warning-amber-700: #b45309;
|
||||
--color-warning-amber-800: #92400e;
|
||||
--color-warning-amber-900: #78350f;
|
||||
|
||||
/* Extended spacing scale */
|
||||
--spacing-72: 18rem;
|
||||
--spacing-80: 20rem;
|
||||
--spacing-96: 24rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Smooth scroll behavior */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Reduce motion for users who prefer it */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus-visible outline for keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 2px solid #16a34a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling (nice on supported browsers) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #d4d4d4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #a3a3a3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #737373;
|
||||
}
|
||||
}
|
||||
52
src/app/layout.tsx
Normal file
52
src/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { APP_NAME, APP_DESCRIPTION, BETA_DISCLAIMER } from "@/lib/constants";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: `${APP_NAME} — ${APP_DESCRIPTION}`,
|
||||
template: `%s | ${APP_NAME}`,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
openGraph: {
|
||||
title: APP_NAME,
|
||||
description: APP_DESCRIPTION,
|
||||
type: "website",
|
||||
siteName: APP_NAME,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} h-full scroll-smooth antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans">
|
||||
<ErrorBoundary>
|
||||
<Navbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
26
src/app/not-found.test.tsx
Normal file
26
src/app/not-found.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import NotFound from "@/app/not-found";
|
||||
|
||||
describe("NotFound (404 page)", () => {
|
||||
it("renders 404 heading", () => {
|
||||
render(<NotFound />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant-themed messaging", () => {
|
||||
const { container } = render(<NotFound />);
|
||||
expect(container.textContent).toMatch(/plant|leaf|garden|grow/i);
|
||||
});
|
||||
|
||||
it("renders link to go home", () => {
|
||||
render(<NotFound />);
|
||||
const homeLink = screen.getByRole("link", { name: /home/i });
|
||||
expect(homeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("renders illustration or emoji", () => {
|
||||
const { container } = render(<NotFound />);
|
||||
expect(container.textContent).toMatch(/[🍂🌿🌱🌻🍃]/);
|
||||
});
|
||||
});
|
||||
38
src/app/not-found.tsx
Normal file
38
src/app/not-found.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found",
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-6 py-16 text-center">
|
||||
<span className="text-7xl mb-6 block" aria-hidden="true">
|
||||
🍃
|
||||
</span>
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 max-w-md leading-relaxed mb-8">
|
||||
This page doesn't seem to exist. Perhaps it wilted away, or the
|
||||
URL got pruned. Let's get you back to healthy ground.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🏠 Go home
|
||||
</Link>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🌿 Browse plants
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/app/page.test.tsx
Normal file
71
src/app/page.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Page from "@/app/page";
|
||||
|
||||
// Mock FeaturedPlantsSection (async server component — mocked for testing)
|
||||
vi.mock("@/components/FeaturedPlantsSection", () => ({
|
||||
FeaturedPlantsGrid: () => (
|
||||
<>
|
||||
<div data-testid="plant-card-tomato">Tomato</div>
|
||||
<div data-testid="plant-card-pepper">Pepper</div>
|
||||
<div data-testid="plant-card-cucumber">Cucumber</div>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Homepage (page.tsx)", () => {
|
||||
it("renders hero section with title", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByText(/Snap. Identify. Treat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant emoji in hero", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getAllByText("🌱").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders how it works section", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByText(/How It Works/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders how it works steps", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getAllByText(/Upload a Photo/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/AI Analysis/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Get Treatment Plan/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders featured plants section", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByText(/Featured Plants/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders featured plant cards", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByTestId("plant-card-tomato")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plant-card-pepper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plant-card-cucumber")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open source section", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getAllByText(/Open Source/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders view all plants link", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByRole("link", { name: /View all plants/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders trust signals", () => {
|
||||
render(<Page />);
|
||||
const trustSignals = screen.queryAllByText(/300\+ plants/i);
|
||||
expect(trustSignals.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("renders learn more link", () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByRole("link", { name: /Learn More/i })).toHaveAttribute("href", "/about");
|
||||
});
|
||||
});
|
||||
175
src/app/page.tsx
Normal file
175
src/app/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import Link from "next/link";
|
||||
import { FeaturedPlantsGrid } from "@/components/FeaturedPlantsSection";
|
||||
import { TRUST_SIGNALS, HOW_IT_WORKS, APP_NAME, APP_TAGLINE } from "@/lib/constants";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* ─── Hero Section ─── */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-leaf-green-50 via-white to-leaf-green-50 dark:from-zinc-950 dark:via-zinc-950 dark:to-leaf-green-950">
|
||||
{/* Decorative background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-leaf-green-100/40 dark:bg-leaf-green-900/20 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 h-80 w-80 rounded-full bg-soil-brown-100/30 dark:bg-soil-brown-900/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-16 sm:py-24 lg:py-32">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Plant emoji hero */}
|
||||
<span className="text-7xl sm:text-8xl mb-6" aria-hidden="true">
|
||||
🌱
|
||||
</span>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight text-zinc-900 dark:text-zinc-50 max-w-3xl">
|
||||
{APP_TAGLINE}
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 max-w-xl">
|
||||
Upload a photo of your plant and get a hyper-specific disease diagnosis with treatment
|
||||
steps, prevention tips, and confidence scores — all within seconds.
|
||||
</p>
|
||||
|
||||
{/* Upload CTA area */}
|
||||
<div className="mt-10 w-full max-w-lg">
|
||||
<Link
|
||||
href="/upload"
|
||||
className="inline-flex items-center gap-3 rounded-2xl border-2 border-dashed border-leaf-green-300 dark:border-leaf-green-700 bg-white/80 dark:bg-zinc-900/80 px-8 py-6 text-left shadow-sm hover:shadow-md hover:border-leaf-green-500 dark:hover:border-leaf-green-500 transition-all duration-200 group w-full"
|
||||
>
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-xl bg-leaf-green-100 dark:bg-leaf-green-900/50 text-2xl group-hover:scale-110 transition-transform">
|
||||
📸
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<span className="block text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Identify a Plant Disease
|
||||
</span>
|
||||
<span className="block text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
Tap to upload a photo and get started
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-leaf-green-600 dark:text-leaf-green-400 text-xl group-hover:translate-x-1 transition-transform"
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Trust signals */}
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-6 sm:gap-10">
|
||||
{TRUST_SIGNALS.map((signal) => (
|
||||
<div
|
||||
key={signal.label}
|
||||
className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
<span aria-hidden="true">{signal.icon}</span>
|
||||
<span>{signal.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── How It Works ─── */}
|
||||
<section className="py-16 sm:py-20 bg-white dark:bg-zinc-950">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center text-zinc-900 dark:text-zinc-100">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="mt-3 text-center text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
Three simple steps to diagnose your plant in seconds.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 gap-8 sm:grid-cols-3">
|
||||
{HOW_IT_WORKS.map((step, index) => (
|
||||
<div key={step.step} className="relative flex flex-col items-center text-center">
|
||||
{/* Connector line (desktop) */}
|
||||
{index < HOW_IT_WORKS.length - 1 && (
|
||||
<div
|
||||
className="hidden sm:block absolute top-12 left-[60%] w-[80%] h-px border-t-2 border-dashed border-leaf-green-200 dark:border-leaf-green-800"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step number badge */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-leaf-green-600 text-white text-sm font-bold mb-4">
|
||||
{step.step}
|
||||
</div>
|
||||
|
||||
<span className="text-5xl mb-4" aria-hidden="true">
|
||||
{step.emoji}
|
||||
</span>
|
||||
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400 max-w-xs">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Featured Plants ─── */}
|
||||
<section className="py-16 sm:py-20 bg-zinc-50 dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Featured Plants
|
||||
</h2>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
Browse our database of common garden plants and their diseases.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-leaf-green-600 hover:text-leaf-green-700 dark:text-leaf-green-400 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
View all plants
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<FeaturedPlantsGrid />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Open Source CTA ─── */}
|
||||
<section className="py-16 sm:py-20 bg-white dark:bg-zinc-950">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<span className="text-5xl mb-4 block" aria-hidden="true">
|
||||
🔓
|
||||
</span>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Open Source & Community Driven
|
||||
</h2>
|
||||
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
{APP_NAME} is free and open source. Contributions, feedback, and plant data are welcome
|
||||
from gardeners and developers alike.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/about"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
<Link
|
||||
href="/browse"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Browse Plants
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/app/results/[imageId]/page.tsx
Normal file
114
src/app/results/[imageId]/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { IdentifyResponse } from "@/lib/types";
|
||||
import { identifyPlant, IdentifyError } from "@/lib/api/identify";
|
||||
import ResultsDashboard from "@/components/ResultsDashboard";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
|
||||
/**
|
||||
* Results page route that takes imageId from URL param.
|
||||
*
|
||||
* Fetches identification results via client-side API call (to avoid serverless timeouts).
|
||||
* Layout: side-by-side on desktop (image left, results right), stacked on mobile.
|
||||
* Loading skeleton state while results are computed.
|
||||
* Error state if identification fails.
|
||||
* Empty/unexpected state.
|
||||
*/
|
||||
export default function ResultsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ imageId: string }>;
|
||||
}) {
|
||||
const { imageId } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [response, setResponse] = useState<IdentifyResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runIdentification = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
|
||||
try {
|
||||
const result = await identifyPlant(imageId);
|
||||
setResponse(result);
|
||||
} catch (err) {
|
||||
const identifyErr = err as IdentifyError;
|
||||
if (identifyErr.status === 404) {
|
||||
setError("Image not found. It may have been deleted or expired.");
|
||||
} else {
|
||||
setError(identifyErr.message || "Identification failed. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [imageId]);
|
||||
|
||||
// Run identification on mount
|
||||
useEffect(() => {
|
||||
runIdentification();
|
||||
}, [runIdentification]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
runIdentification();
|
||||
}, [runIdentification]);
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
// ─── Error state with retry ───────────────────────────────────────────────
|
||||
|
||||
if (error && !loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-6 py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-6xl mb-6" aria-hidden="true">🍂</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Identification Failed
|
||||
</h2>
|
||||
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-6 leading-relaxed">
|
||||
{error}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.824 0c-.158.39-.472.738-.893.893a.75.75 0 11-.53-1.403A4.001 4.001 0 006.5 9a4.001 4.001 0 00-1.924 1.913.75.75 0 11-1.404-.53 5.5 5.5 0 019.824 0c.158-.39.472-.738.893-.893a.75.75 0 11.53 1.403z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTryAgain}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Upload another photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main dashboard (loading + results) ───────────────────────────────────
|
||||
|
||||
return (
|
||||
<ResultsDashboard
|
||||
imageId={imageId}
|
||||
imageUrl={`/uploads/${imageId}`}
|
||||
response={response}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
src/app/results/page.tsx
Normal file
9
src/app/results/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Redirect page — if someone navigates to /results without an imageId,
|
||||
* redirect them to the homepage.
|
||||
*/
|
||||
export default function ResultsRedirectPage() {
|
||||
redirect("/");
|
||||
}
|
||||
86
src/app/results/results-page.test.tsx
Normal file
86
src/app/results/results-page.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as identifyApi from "@/lib/api/identify";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock API
|
||||
vi.mock("@/lib/api/identify", () => ({
|
||||
identifyPlant: vi.fn(),
|
||||
IdentifyError: class IdentifyError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ResultsDashboard
|
||||
vi.mock("@/components/ResultsDashboard", () => ({
|
||||
default: ({ loading, error, response }: any) => (
|
||||
<div data-testid="results-dashboard">
|
||||
{loading && <span>Loading...</span>}
|
||||
{error && <span>Error: {error}</span>}
|
||||
{response && <span>Results for {response.predictions?.length} predictions</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock EmptyState
|
||||
vi.mock("@/components/EmptyState", () => ({
|
||||
default: ({ title }: any) => <div data-testid="empty-state">{title}</div>,
|
||||
}));
|
||||
|
||||
// Mock the page component directly since it uses React.use() for async params
|
||||
vi.mock("@/app/results/[imageId]/page", () => ({
|
||||
default: function MockedResultsPage() {
|
||||
return <div data-testid="mocked-results-page">Results Page</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ResultsPage", () => {
|
||||
it("renders the page component", async () => {
|
||||
const { default: ResultsPage } = await import("@/app/results/[imageId]/page");
|
||||
render(<ResultsPage params={Promise.resolve({ imageId: "test-image-123" })} />);
|
||||
expect(screen.getByTestId("mocked-results-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("identifyPlant returns expected response shape", async () => {
|
||||
(identifyApi.identifyPlant as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
predictions: [
|
||||
{
|
||||
diseaseId: "early-blight",
|
||||
disease: {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: ["Dark spots"],
|
||||
treatment: ["Remove leaves"],
|
||||
lookalikeDiseaseIds: [],
|
||||
plantId: "tomato",
|
||||
},
|
||||
confidence: { raw: 0.85, adjusted: 0.82 },
|
||||
lookalikes: [],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
model: "mock-model",
|
||||
inferenceTimeMs: 150,
|
||||
imageId: "test-image-123",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await identifyApi.identifyPlant("test-image-123");
|
||||
expect(result.predictions).toHaveLength(1);
|
||||
expect(result.metadata.model).toBe("mock-model");
|
||||
});
|
||||
});
|
||||
74
src/app/upload/page.tsx
Normal file
74
src/app/upload/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ImageUpload from "@/components/ImageUpload";
|
||||
import type { UploadResponse } from "@/lib/api/upload";
|
||||
|
||||
/**
|
||||
* Upload page — user uploads a plant image and gets redirected to results.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User uploads image via ImageUpload component
|
||||
* 2. On success, navigate to /results/{imageId}
|
||||
* 3. Results page runs identification via client-side fetch
|
||||
*/
|
||||
export default function UploadPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(response: UploadResponse) => {
|
||||
// Navigate to results page with the imageId
|
||||
router.push(`/results/${response.imageId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleError = useCallback((error: string) => {
|
||||
console.error("[upload] Upload failed:", error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 sm:px-6 lg:px-8 py-12 sm:py-20">
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-5xl block mb-4" aria-hidden="true">📸</span>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Identify a Plant Disease
|
||||
</h1>
|
||||
<p className="mt-3 text-zinc-500 dark:text-zinc-400 max-w-lg mx-auto">
|
||||
Upload a clear photo of the affected plant area. Our AI will analyze
|
||||
it and provide a detailed diagnosis with treatment recommendations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload component */}
|
||||
<ImageUpload onUpload={handleUpload} onError={handleError} />
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-10 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Tips for best results
|
||||
</h2>
|
||||
<ul className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Focus on the affected area — leaves, stems, or fruit showing symptoms
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Good lighting helps — natural daylight is ideal
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Include some healthy tissue for context and comparison
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
Avoid blurry or overly distant shots
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
src/components/.gitkeep
Normal file
0
src/components/.gitkeep
Normal file
44
src/components/BetaNotice.tsx
Normal file
44
src/components/BetaNotice.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* BetaNotice — a banner informing users that the site is in beta,
|
||||
* community-driven, and most data isn't reviewed by humans yet.
|
||||
* Encourages use of the Flag button to flag content for review.
|
||||
*
|
||||
* Two layout variants:
|
||||
* - "full-width" (default): stretches edge-to-edge with an inner max-w wrapper
|
||||
* - "card": rounded card with border, suitable for inside content containers
|
||||
*/
|
||||
|
||||
export default function BetaNotice({
|
||||
variant = "full-width",
|
||||
className = "",
|
||||
}: {
|
||||
variant?: "full-width" | "card";
|
||||
className?: string;
|
||||
}) {
|
||||
const containerClasses =
|
||||
variant === "card"
|
||||
? `rounded-xl bg-warning-amber-50 dark:bg-warning-amber-950/60 border border-warning-amber-200 dark:border-warning-amber-800 ${className}`
|
||||
: `bg-warning-amber-50 dark:bg-warning-amber-950/60 border-b border-warning-amber-200 dark:border-warning-amber-800 ${className}`;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div
|
||||
className={
|
||||
variant === "card" ? "px-4 sm:px-6 py-3" : "mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3"
|
||||
}
|
||||
>
|
||||
<p className="text-xs sm:text-sm text-warning-amber-800 text-center leading-relaxed">
|
||||
<span className="font-semibold">🚧 Beta — Community Driven.</span> Most data here is not
|
||||
reviewed by humans. Spot something wrong or it could be better? Use the{" "}
|
||||
<span className="inline-flex items-center gap-1 font-medium whitespace-nowrap">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3.5 2.75a.75.75 0 00-1.5 0v14.5a.75.75 0 001.5 0v-4.392l1.657-.348a6.453 6.453 0 014.271.572 7.948 7.948 0 005.965.524l2.078-.64A.75.75 0 0018 12.25v-8.5a.75.75 0 00-.904-.734l-2.38.501a7.25 7.25 0 01-4.186-.363l-.502-.2a8.75 8.75 0 00-5.053-.439l-1.475.31V2.75z" />
|
||||
</svg>
|
||||
Flag
|
||||
</span>{" "}
|
||||
button on any image or description to flag it for review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/components/ConfidenceBadge.test.tsx
Normal file
171
src/components/ConfidenceBadge.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
|
||||
import type { ConfidenceResult } from "@/lib/types";
|
||||
|
||||
describe("ConfidenceBadge", () => {
|
||||
function renderBadge(confidence: ConfidenceResult) {
|
||||
return render(<ConfidenceBadge confidence={confidence} />);
|
||||
}
|
||||
|
||||
describe("renders correct color for high confidence (≥0.8)", () => {
|
||||
it("uses green styling", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.85,
|
||||
adjusted: 0.87,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("87% confidence")).toBeInTheDocument();
|
||||
// Check for green color classes
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge).toHaveClass("bg-leaf-green-100");
|
||||
expect(badge).toHaveClass("text-leaf-green-800");
|
||||
});
|
||||
|
||||
it("shows checkmark icon for high confidence", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.9,
|
||||
adjusted: 0.92,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("92% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tooltip with high confidence explanation", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.85,
|
||||
adjusted: 0.87,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
expect(tooltip?.textContent).toContain("High confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders correct color for medium confidence (≥0.5)", () => {
|
||||
it("uses amber styling", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.55,
|
||||
adjusted: 0.56,
|
||||
label: "medium",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("56% confidence")).toBeInTheDocument();
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge).toHaveClass("bg-warning-amber-100");
|
||||
expect(badge).toHaveClass("text-warning-amber-800");
|
||||
});
|
||||
|
||||
it("renders tooltip with medium confidence explanation", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.55,
|
||||
adjusted: 0.56,
|
||||
label: "medium",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
expect(tooltip?.textContent).toContain("Medium confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders correct color for low confidence (<0.5)", () => {
|
||||
it("uses red styling", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.3,
|
||||
adjusted: 0.31,
|
||||
label: "low",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
expect(screen.getByText("31% confidence")).toBeInTheDocument();
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge).toHaveClass("bg-red-100");
|
||||
expect(badge).toHaveClass("text-red-800");
|
||||
});
|
||||
|
||||
it("renders tooltip with low confidence explanation", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.3,
|
||||
adjusted: 0.31,
|
||||
label: "low",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
expect(tooltip?.textContent).toContain("Low confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles exactly 0.8 threshold as high", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.78,
|
||||
adjusted: 0.8,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("80% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles exactly 0.5 threshold as medium", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0.49,
|
||||
adjusted: 0.5,
|
||||
label: "medium",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("50% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles 0% confidence", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 0,
|
||||
adjusted: 0,
|
||||
label: "low",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("0% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles 100% confidence", () => {
|
||||
const confidence: ConfidenceResult = {
|
||||
raw: 1,
|
||||
adjusted: 1,
|
||||
label: "high",
|
||||
};
|
||||
renderBadge(confidence);
|
||||
expect(screen.getByText("100% confidence")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfidenceColors helper", () => {
|
||||
it("returns green colors for high", () => {
|
||||
const colors = getConfidenceColors("high");
|
||||
expect(colors.border).toContain("leaf-green");
|
||||
expect(colors.bg).toContain("leaf-green");
|
||||
expect(colors.accent).toContain("leaf-green");
|
||||
});
|
||||
|
||||
it("returns amber colors for medium", () => {
|
||||
const colors = getConfidenceColors("medium");
|
||||
expect(colors.border).toContain("warning-amber");
|
||||
expect(colors.bg).toContain("warning-amber");
|
||||
expect(colors.accent).toContain("warning-amber");
|
||||
});
|
||||
|
||||
it("returns red colors for low", () => {
|
||||
const colors = getConfidenceColors("low");
|
||||
expect(colors.border).toContain("red");
|
||||
expect(colors.bg).toContain("red");
|
||||
expect(colors.accent).toContain("red");
|
||||
});
|
||||
});
|
||||
});
|
||||
149
src/components/ConfidenceBadge.tsx
Normal file
149
src/components/ConfidenceBadge.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { ConfidenceResult } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Color-coded confidence indicator badge.
|
||||
*
|
||||
* - Green + checkmark for confidence ≥ 0.8 (high)
|
||||
* - Amber + warning for confidence ≥ 0.5 (medium)
|
||||
* - Red + exclamation for confidence < 0.5 (low)
|
||||
*
|
||||
* Shows percentage (e.g., "87% confidence") and a hover tooltip
|
||||
* explaining confidence interpretation.
|
||||
*/
|
||||
export default function ConfidenceBadge({
|
||||
confidence,
|
||||
className = "",
|
||||
}: {
|
||||
confidence: ConfidenceResult;
|
||||
className?: string;
|
||||
}) {
|
||||
const percentage = Math.round(confidence.adjusted * 100);
|
||||
const { colors, icon, tooltip } = getBadgeStyle(confidence.label);
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block group ${className}`}>
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold
|
||||
${colors.bg} ${colors.text} ${colors.ring}
|
||||
ring-1
|
||||
cursor-help select-none
|
||||
transition-colors duration-150
|
||||
`}
|
||||
role="status"
|
||||
aria-label={`${percentage}% confidence — ${confidence.label}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{percentage}% confidence</span>
|
||||
</span>
|
||||
|
||||
{/* Hover tooltip — appears on group hover */}
|
||||
<span
|
||||
className="
|
||||
pointer-events-none absolute -top-2 left-1/2 z-50 w-64 -translate-x-1/2 -translate-y-full
|
||||
rounded-lg bg-zinc-900 dark:bg-zinc-100 px-3 py-2 text-xs leading-relaxed text-zinc-100 dark:text-zinc-900
|
||||
opacity-0 shadow-lg transition-opacity duration-150
|
||||
group-hover:opacity-100
|
||||
"
|
||||
role="tooltip"
|
||||
>
|
||||
{tooltip}
|
||||
{/* Tooltip arrow */}
|
||||
<span className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-4 border-transparent border-t-zinc-900 dark:border-t-zinc-100" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Style helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface BadgeStyle {
|
||||
colors: {
|
||||
bg: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
};
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function getBadgeStyle(label: "high" | "medium" | "low"): BadgeStyle {
|
||||
switch (label) {
|
||||
case "high":
|
||||
return {
|
||||
colors: {
|
||||
bg: "bg-leaf-green-100 dark:bg-leaf-green-900/50",
|
||||
text: "text-leaf-green-800 dark:text-leaf-green-200",
|
||||
ring: "ring-leaf-green-300 dark:ring-leaf-green-700",
|
||||
},
|
||||
icon: (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
tooltip: "High confidence: The model is very certain this matches the disease. Treat this as a strong diagnosis.",
|
||||
};
|
||||
|
||||
case "medium":
|
||||
return {
|
||||
colors: {
|
||||
bg: "bg-warning-amber-100 dark:bg-warning-amber-900/50",
|
||||
text: "text-warning-amber-800 dark:text-warning-amber-200",
|
||||
ring: "ring-warning-amber-300 dark:ring-warning-amber-700",
|
||||
},
|
||||
icon: (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
tooltip: "Medium confidence: The model is somewhat uncertain. Use symptom matching and visual inspection to confirm.",
|
||||
};
|
||||
|
||||
case "low":
|
||||
return {
|
||||
colors: {
|
||||
bg: "bg-red-100 dark:bg-red-900/50",
|
||||
text: "text-red-800 dark:text-red-200",
|
||||
ring: "ring-red-300 dark:ring-red-700",
|
||||
},
|
||||
icon: (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0V4.75A.75.75 0 0110 4zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
tooltip: "Low confidence: The model is not confident in this match. Treat as a suggestion only and verify manually.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge colors for use in DiseaseCard borders/highlights.
|
||||
*/
|
||||
export function getConfidenceColors(label: "high" | "medium" | "low") {
|
||||
switch (label) {
|
||||
case "high":
|
||||
return {
|
||||
border: "border-leaf-green-400 dark:border-leaf-green-500",
|
||||
bg: "bg-leaf-green-50/50 dark:bg-leaf-green-950/20",
|
||||
accent: "bg-leaf-green-600 hover:bg-leaf-green-700",
|
||||
text: "text-leaf-green-700 dark:text-leaf-green-400",
|
||||
};
|
||||
case "medium":
|
||||
return {
|
||||
border: "border-warning-amber-400 dark:border-warning-amber-500",
|
||||
bg: "bg-warning-amber-50/50 dark:bg-warning-amber-950/20",
|
||||
accent: "bg-warning-amber-600 hover:bg-warning-amber-700",
|
||||
text: "text-warning-amber-700 dark:text-warning-amber-400",
|
||||
};
|
||||
case "low":
|
||||
return {
|
||||
border: "border-red-400 dark:border-red-500",
|
||||
bg: "bg-red-50/50 dark:bg-red-950/20",
|
||||
accent: "bg-red-600 hover:bg-red-700",
|
||||
text: "text-red-700 dark:text-red-400",
|
||||
};
|
||||
}
|
||||
}
|
||||
237
src/components/DiseaseCard.test.tsx
Normal file
237
src/components/DiseaseCard.test.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import DiseaseCard from "@/components/DiseaseCard";
|
||||
import type { PredictionResult, Disease, ConfidenceResult } from "@/lib/types";
|
||||
|
||||
// Mock the getLookalikeDiseases function
|
||||
vi.mock("@/lib/api/diseases", () => ({
|
||||
getLookalikeDiseases: vi.fn(() => []),
|
||||
getPlantById: vi.fn(() => ({ id: "tomato", commonName: "Tomato" })),
|
||||
}));
|
||||
|
||||
describe("DiseaseCard", () => {
|
||||
const mockDisease: Disease = {
|
||||
id: "early-blight",
|
||||
plantId: "tomato",
|
||||
name: "Early Blight",
|
||||
scientificName: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
description: "Early blight is one of the most common fungal diseases affecting tomatoes. It primarily attacks lower and older leaves first, progressing upward.",
|
||||
symptoms: [
|
||||
"Dark brown spots with concentric rings on lower leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Premature defoliation starting from bottom of plant",
|
||||
],
|
||||
causes: [
|
||||
"Warm temperatures combined with high humidity",
|
||||
"Fungal spores overwintering in infected plant debris",
|
||||
],
|
||||
treatment: [
|
||||
"Remove and destroy all severely infected leaves immediately",
|
||||
"Apply copper-based fungicide spray every 7-10 days",
|
||||
"Improve air circulation by pruning lower leaves",
|
||||
],
|
||||
prevention: [
|
||||
"Practice 2-3 year crop rotation",
|
||||
"Water at soil level using drip irrigation",
|
||||
],
|
||||
lookalikeDiseaseIds: [],
|
||||
severity: "moderate",
|
||||
};
|
||||
|
||||
const mockConfidence: ConfidenceResult = {
|
||||
raw: 0.85,
|
||||
adjusted: 0.87,
|
||||
label: "high",
|
||||
};
|
||||
|
||||
const mockPrediction: PredictionResult = {
|
||||
diseaseId: "early-blight",
|
||||
disease: mockDisease,
|
||||
confidence: mockConfidence,
|
||||
lookalikes: [],
|
||||
};
|
||||
|
||||
function renderCard(prediction: PredictionResult, isPrimary = true) {
|
||||
return render(
|
||||
<DiseaseCard
|
||||
prediction={prediction}
|
||||
rank={1}
|
||||
isPrimary={isPrimary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("collapsed state", () => {
|
||||
it("shows disease name", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("Early Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows confidence badge", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("87% confidence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows causal agent type icon", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("Fungal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one-sentence summary", () => {
|
||||
renderCard(mockPrediction);
|
||||
// The summary appears as a line-clamp-2 paragraph
|
||||
const summary = document.querySelector('.line-clamp-2');
|
||||
expect(summary?.textContent).toContain("Early blight is one of the most common");
|
||||
});
|
||||
|
||||
it("shows scientific name", () => {
|
||||
renderCard(mockPrediction);
|
||||
expect(screen.getByText("Alternaria solani")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows rank number", () => {
|
||||
renderCard(mockPrediction);
|
||||
// Rank appears inside a rounded-lg div with font-bold
|
||||
const rankElements = document.querySelectorAll('.font-bold');
|
||||
expect(rankElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expanded state", () => {
|
||||
// Helper: find the card's expand/collapse button by its aria-controls attribute
|
||||
function getExpandButton() {
|
||||
return document.querySelector('button[aria-controls]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("expands on click", () => {
|
||||
renderCard(mockPrediction);
|
||||
const button = getExpandButton();
|
||||
expect(button).toBeTruthy();
|
||||
fireEvent.click(button!);
|
||||
|
||||
// After expanding, should show "Description" section header
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses on second click", () => {
|
||||
renderCard(mockPrediction, true); // start expanded
|
||||
const button = getExpandButton();
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.click(button!); // collapse
|
||||
|
||||
// Button should now show collapsed state
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// The expandable body should have max-h-0
|
||||
const body = document.getElementById(`disease-card-body-${mockPrediction.diseaseId}`);
|
||||
expect(body).toHaveClass("max-h-0");
|
||||
});
|
||||
|
||||
it("primary card starts expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
const button = getExpandButton();
|
||||
expect(button).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("non-primary card starts collapsed", () => {
|
||||
renderCard(mockPrediction, false);
|
||||
const button = getExpandButton();
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("shows symptom section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Symptom Check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows causes section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Causes & Contributing Factors")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows treatment plan section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Treatment Plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows prevention tips section when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Prevention Tips")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("primary diagnosis highlight", () => {
|
||||
it("shows primary diagnosis ribbon", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Primary Diagnosis")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show ribbon for non-primary", () => {
|
||||
renderCard(mockPrediction, false);
|
||||
expect(screen.queryByText("Primary Diagnosis")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedback buttons", () => {
|
||||
it("shows feedback buttons when expanded", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Was this diagnosis helpful?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can click Yes feedback", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
|
||||
const yesButton = screen.getByRole("button", { name: /Yes/ });
|
||||
fireEvent.click(yesButton);
|
||||
|
||||
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can click No feedback", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
|
||||
const noButton = screen.getByRole("button", { name: /No/ });
|
||||
fireEvent.click(noButton);
|
||||
|
||||
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismiss functionality", () => {
|
||||
it("calls onDismiss when dismiss button is clicked", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<DiseaseCard
|
||||
prediction={mockPrediction}
|
||||
rank={1}
|
||||
isPrimary={true}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the dismiss button by aria-label
|
||||
const dismissBtn = screen.getByRole("button", { name: "Dismiss this result" });
|
||||
fireEvent.click(dismissBtn);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("treatment urgency badges", () => {
|
||||
it("shows Immediate for first treatment step", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Immediate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Within a week for second treatment step", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getByText("Within a week")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Ongoing for remaining treatment steps", () => {
|
||||
renderCard(mockPrediction, true);
|
||||
expect(screen.getAllByText("Ongoing").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
426
src/components/DiseaseCard.tsx
Normal file
426
src/components/DiseaseCard.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import type { PredictionResult, CausalAgentType } from "@/lib/types";
|
||||
import ConfidenceBadge, { getConfidenceColors } from "@/components/ConfidenceBadge";
|
||||
import SymptomChecker from "@/components/SymptomChecker";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
/**
|
||||
* Individual disease result card with expandable sections.
|
||||
*
|
||||
* Collapsed state: disease name, confidence badge, causal agent type icon, one-sentence summary.
|
||||
* Expanded state: full description, symptom list, cause list, treatment timeline, prevention tips.
|
||||
* Smooth expand/collapse animation.
|
||||
* "Was this helpful?" feedback buttons at the bottom.
|
||||
*/
|
||||
export default function DiseaseCard({
|
||||
prediction,
|
||||
rank,
|
||||
isPrimary,
|
||||
onDismiss,
|
||||
}: {
|
||||
prediction: PredictionResult;
|
||||
rank: number;
|
||||
isPrimary: boolean;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(isPrimary);
|
||||
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
||||
|
||||
const { disease, confidence, lookalikeDiseases } = prediction;
|
||||
const colors = getConfidenceColors(confidence.label);
|
||||
const lookalikes = lookalikeDiseases ?? [];
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
// One-sentence summary (first sentence of description)
|
||||
const summary = disease.description.split(".")[0] + ".";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`
|
||||
group/card relative rounded-xl border-2 overflow-hidden transition-all duration-200
|
||||
${
|
||||
isPrimary
|
||||
? `${colors.border} ${colors.bg} shadow-md`
|
||||
: "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Primary diagnosis ribbon */}
|
||||
{isPrimary && (
|
||||
<div
|
||||
className={`${colors.accent} text-white text-xs font-bold uppercase tracking-wider px-4 py-1.5 flex items-center gap-2`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
Primary Diagnosis
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card header — clickable to expand/collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="w-full px-4 pt-4 pb-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-green-500 focus-visible:ring-offset-2 rounded-t-xl"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`disease-card-body-${disease.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Rank / causal agent icon */}
|
||||
<div
|
||||
className={`
|
||||
flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold
|
||||
${
|
||||
isPrimary
|
||||
? `${colors.accent} text-white`
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{rank}
|
||||
</div>
|
||||
|
||||
{/* Disease info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{disease.name}
|
||||
</h3>
|
||||
<CausalAgentIcon type={disease.causalAgentType} />
|
||||
<ConfidenceBadge confidence={confidence} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs italic text-zinc-500 dark:text-zinc-400">
|
||||
{disease.scientificName}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">{summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<svg
|
||||
className={`h-5 w-5 shrink-0 text-zinc-400 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Card body — expandable content */}
|
||||
<div
|
||||
id={`disease-card-body-${disease.id}`}
|
||||
className={`
|
||||
overflow-hidden transition-all duration-300 ease-in-out
|
||||
${expanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"}
|
||||
`}
|
||||
>
|
||||
<div className="px-4 pb-4 space-y-5">
|
||||
<hr className="border-zinc-200 dark:border-zinc-700" />
|
||||
|
||||
{/* Full description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Description
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_description"
|
||||
contentId={disease.id}
|
||||
fieldName="description"
|
||||
label="description"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
||||
{disease.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Symptom checker */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Symptom Checker
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_symptoms"
|
||||
contentId={disease.id}
|
||||
fieldName="symptoms"
|
||||
label="symptoms"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<SymptomChecker symptoms={disease.symptoms} />
|
||||
</div>
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-zinc-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Causes & Contributing Factors
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_causes"
|
||||
contentId={disease.id}
|
||||
fieldName="causes"
|
||||
label="causes"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5" role="list">
|
||||
{disease.causes.map((cause, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-zinc-400 dark:bg-zinc-500" />
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">{cause}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Treatment timeline */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Treatment Plan
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_treatment"
|
||||
contentId={disease.id}
|
||||
fieldName="treatment"
|
||||
label="treatment"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<TreatmentTimeline
|
||||
steps={treatmentStepsWithUrgency(disease.treatment)}
|
||||
severity={disease.severity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prevention tips */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-leaf-green-600 dark:text-leaf-green-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.32 2.577a49.255 49.255 0 0111.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 01-1.085.67L10 18.089l-9.165 3.583A.75.75 0 010 21V5.507c0-1.47 1.073-2.756 2.57-2.93a49.254 49.254 0 0111.36 0zM12 9a2 2 0 11-4 0 2 2 0 014 0zm-2 3a1 1 0 00-1 1v1a1 1 0 001 1h0a1 1 0 001-1v-1a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Prevention Tips
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_prevention"
|
||||
contentId={disease.id}
|
||||
fieldName="prevention"
|
||||
label="prevention tips"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5" role="list">
|
||||
{disease.prevention.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-leaf-green-500" />
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Lookalike warnings */}
|
||||
{lookalikes.length > 0 && <LookalikeWarning disease={disease} lookalikes={lookalikes} />}
|
||||
|
||||
{/* Feedback buttons */}
|
||||
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Was this diagnosis helpful?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedback("yes")}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
||||
transition-colors
|
||||
${
|
||||
feedback === "yes"
|
||||
? "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300 ring-1 ring-leaf-green-300 dark:ring-leaf-green-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}
|
||||
`}
|
||||
aria-pressed={feedback === "yes"}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||
</svg>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedback("no")}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium
|
||||
transition-colors
|
||||
${
|
||||
feedback === "no"
|
||||
? "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 ring-1 ring-red-300 dark:ring-red-700"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
}
|
||||
`}
|
||||
aria-pressed={feedback === "no"}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 10.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 10.667V5.236a2 2 0 00-1.105-1.795l-.05-.025A4 4 0 0011.057 2H5.641a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" />
|
||||
</svg>
|
||||
No
|
||||
</button>
|
||||
{feedback && (
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-2">
|
||||
Thanks for your feedback!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button (top-right corner, visible on hover) */}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="absolute top-3 right-3 z-10 rounded-lg p-1 text-zinc-400 opacity-0 transition-opacity hover:text-zinc-600 dark:hover:text-zinc-300 group-hover/card:opacity-100"
|
||||
aria-label="Dismiss this result"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Causal agent type icon — shows a small icon based on disease type.
|
||||
*/
|
||||
function CausalAgentIcon({ type }: { type: CausalAgentType }) {
|
||||
const config = getCausalAgentConfig(type);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium
|
||||
${config.bg} ${config.text}
|
||||
`}
|
||||
title={`${config.label} disease`}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="hidden sm:inline">{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CausalAgentConfig {
|
||||
label: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function getCausalAgentConfig(type: CausalAgentType): CausalAgentConfig {
|
||||
switch (type) {
|
||||
case "fungal":
|
||||
return {
|
||||
label: "Fungal",
|
||||
bg: "bg-purple-100 dark:bg-purple-900/50",
|
||||
text: "text-purple-700 dark:text-purple-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="8" cy="6" r="3" />
|
||||
<circle cx="5" cy="10" r="2" />
|
||||
<circle cx="11" cy="10" r="2" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
case "bacterial":
|
||||
return {
|
||||
label: "Bacterial",
|
||||
bg: "bg-blue-100 dark:bg-blue-900/50",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<ellipse cx="8" cy="8" rx="5" ry="2.5" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
case "viral":
|
||||
return {
|
||||
label: "Viral",
|
||||
bg: "bg-pink-100 dark:bg-pink-900/50",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2.5" />
|
||||
<line x1="8" y1="1" x2="8" y2="4" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="8" y1="12" x2="8" y2="15" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="1" y1="8" x2="4" y2="8" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="12" y1="8" x2="15" y2="8" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
case "environmental":
|
||||
return {
|
||||
label: "Environmental",
|
||||
bg: "bg-orange-100 dark:bg-orange-900/50",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
icon: (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 2a1 1 0 011 1v2a1 1 0 01-2 0V4a1 1 0 011-1z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
58
src/components/EmptyState.test.tsx
Normal file
58
src/components/EmptyState.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders title", () => {
|
||||
render(<EmptyState title="No Results" />);
|
||||
expect(screen.getByText("No Results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description", () => {
|
||||
render(
|
||||
<EmptyState
|
||||
title="No Results"
|
||||
description="Try adjusting your search terms."
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Try adjusting your search terms.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders CTA link with label and href", () => {
|
||||
render(
|
||||
<EmptyState
|
||||
title="No Results"
|
||||
actionLabel="Clear Filters"
|
||||
actionHref="/"
|
||||
/>
|
||||
);
|
||||
const link = screen.getByRole("link", { name: /Clear Filters/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("does not render CTA when no actionLabel provided", () => {
|
||||
render(<EmptyState title="No Results" actionHref="/" />);
|
||||
expect(screen.queryByRole("link", { name: /Clear Filters/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render CTA when no actionHref provided", () => {
|
||||
render(<EmptyState title="No Results" actionLabel="Go" />);
|
||||
expect(screen.queryByRole("link", { name: /Go/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders illustration emoji", () => {
|
||||
render(<EmptyState title="No Results" illustration="🔍" />);
|
||||
expect(screen.getByText("🔍")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders default illustration when none provided", () => {
|
||||
render(<EmptyState title="No Results" />);
|
||||
expect(screen.getByText("🔍")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with custom className", () => {
|
||||
const { container } = render(<EmptyState title="No Results" className="custom-class" />);
|
||||
expect(container.querySelector(".custom-class")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
63
src/components/EmptyState.tsx
Normal file
63
src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Emoji or SVG/icon string to display */
|
||||
illustration?: string;
|
||||
/** Primary heading text */
|
||||
title: string;
|
||||
/** Description / subtext */
|
||||
description?: string;
|
||||
/** Optional CTA button label (requires href) */
|
||||
actionLabel?: string;
|
||||
/** CTA button href */
|
||||
actionHref?: string;
|
||||
/** Optional className override */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable empty-state component with illustration, message, and optional CTA.
|
||||
* Used when search/filter returns no results or a list is empty.
|
||||
*/
|
||||
export default function EmptyState({
|
||||
illustration = "🔍",
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
className = "",
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-16 px-6 text-center ${className}`}
|
||||
>
|
||||
<span
|
||||
className="text-6xl mb-6 block"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{illustration}
|
||||
</span>
|
||||
|
||||
<h3 className="text-xl font-semibold text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-md leading-relaxed mb-6">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actionLabel && actionHref && (
|
||||
<Link
|
||||
href={actionHref}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/ErrorBoundary.test.tsx
Normal file
111
src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
|
||||
// Component that throws on render
|
||||
function ThrowOnRender() {
|
||||
throw new Error("Boom!");
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it("renders children when no error occurs", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Hello World</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Something went wrong/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback UI when child throws", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/A leaf must have fallen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom fallback when provided", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div data-testid="custom-fallback">Custom error</div>}>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByTestId("custom-fallback")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Try again' button that resets state", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const tryAgain = screen.getByText(/Try again/);
|
||||
expect(tryAgain).toBeInTheDocument();
|
||||
|
||||
// Clicking Try again resets the error state
|
||||
fireEvent.click(tryAgain);
|
||||
// After reset, the child will throw again, so fallback reappears
|
||||
// But the key is the button exists and is clickable
|
||||
expect(tryAgain).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows 'Go home' link", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const goHome = screen.getByText(/Go home/);
|
||||
expect(goHome).toBeInTheDocument();
|
||||
expect(goHome.closest("a")).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("shows error details in development mode", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Error details \(dev only\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show error details in production mode", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Error details/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logs error to console", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
101
src/components/ErrorBoundary.tsx
Normal file
101
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* React error boundary that catches rendering errors in its child tree.
|
||||
* Falls back to a friendly UI with error details (dev mode), a "Try again" button,
|
||||
* and a "Go home" link.
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log error to your error reporting service in production
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-6 py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
{/* Warning illustration */}
|
||||
<div className="text-6xl mb-6" aria-hidden="true">
|
||||
🍂
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-3">
|
||||
Something went wrong!
|
||||
</h2>
|
||||
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-6 leading-relaxed">
|
||||
A leaf must have fallen on the keyboard. Our team has been
|
||||
notified. Please try again or head back home.
|
||||
</p>
|
||||
|
||||
{/* Dev-mode error detail */}
|
||||
{process.env.NODE_ENV === "development" &&
|
||||
this.state.error && (
|
||||
<details className="mb-6 text-left bg-zinc-100 dark:bg-zinc-800 rounded-lg p-4 overflow-auto">
|
||||
<summary className="text-sm font-mono text-zinc-500 dark:text-zinc-400 cursor-pointer select-none">
|
||||
Error details (dev only)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">
|
||||
{this.state.error.message}
|
||||
{"\n\n"}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() =>
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🔄 Try again
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
🏠 Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
50
src/components/FeaturedPlantsSection.tsx
Normal file
50
src/components/FeaturedPlantsSection.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { Suspense } from "react";
|
||||
import PlantCard from "@/components/PlantCard";
|
||||
import { getFeaturedPlants } from "@/lib/api/home";
|
||||
|
||||
/**
|
||||
* Featured plants section — fetches plant data from the DB and renders cards.
|
||||
* This is an async server component, wrapped in Suspense by the parent.
|
||||
*/
|
||||
export default async function FeaturedPlantsSection() {
|
||||
const featuredPlants = await getFeaturedPlants();
|
||||
|
||||
return (
|
||||
<>
|
||||
{featuredPlants.map((plant) => (
|
||||
<PlantCard key={plant.id} plant={plant} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden animate-pulse"
|
||||
>
|
||||
<div className="h-40 bg-zinc-200 dark:bg-zinc-700" />
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="h-4 w-24 rounded bg-zinc-200 dark:bg-zinc-700" />
|
||||
<div className="h-3 w-32 rounded bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured plants wrapper with Suspense boundary for SSR.
|
||||
* Used by the homepage to avoid making the whole page async.
|
||||
*/
|
||||
export function FeaturedPlantsGrid() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<FeaturedPlantsSection />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
179
src/components/FlagButton.tsx
Normal file
179
src/components/FlagButton.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Content types that can be flagged for manual review.
|
||||
*/
|
||||
export type FlagContentType =
|
||||
| "plant_image"
|
||||
| "disease_image"
|
||||
| "disease_description"
|
||||
| "disease_symptoms"
|
||||
| "disease_causes"
|
||||
| "disease_treatment"
|
||||
| "disease_prevention";
|
||||
|
||||
interface FlagButtonProps {
|
||||
/** Type of content being flagged */
|
||||
contentType: FlagContentType;
|
||||
/** The ID of the plant or disease */
|
||||
contentId: string;
|
||||
/** The specific field name (e.g., "image", "symptoms", "causes", "treatment", "prevention") */
|
||||
fieldName: string;
|
||||
/** Optional human-readable label for display (e.g., "This plant image") */
|
||||
label?: string;
|
||||
/** Optional notes/reason pre-filled for flagging */
|
||||
notes?: string;
|
||||
/** Small variant for inline use */
|
||||
small?: boolean;
|
||||
/** Optional class name override */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlagButton — a small button that lets users flag content for manual review.
|
||||
*
|
||||
* When clicked, it POSTs to /api/flag which either creates or increments
|
||||
* a flag count in the flagged_content table.
|
||||
*
|
||||
* Shows visual feedback: "Flagged!" toast-like state for a few seconds.
|
||||
*/
|
||||
export default function FlagButton({
|
||||
contentType,
|
||||
contentId,
|
||||
fieldName,
|
||||
label,
|
||||
small = false,
|
||||
className = "",
|
||||
}: FlagButtonProps) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "flagged" | "error">("idle");
|
||||
const [flagCount, setFlagCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const handleFlag = useCallback(async () => {
|
||||
if (state === "loading" || state === "flagged") return;
|
||||
|
||||
setState("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/flag", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType, contentId, fieldName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ message: "Failed to flag content" }));
|
||||
throw new Error(data.message || "Failed to flag content");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setFlagCount(data.flagCount ?? 1);
|
||||
setState("flagged");
|
||||
|
||||
// Reset back to idle after a moment so user can flag again if needed
|
||||
setTimeout(() => {
|
||||
setState("idle");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : "Failed to flag");
|
||||
setState("error");
|
||||
|
||||
setTimeout(() => {
|
||||
setState("idle");
|
||||
setErrorMsg("");
|
||||
}, 3000);
|
||||
}
|
||||
}, [contentType, contentId, fieldName, state]);
|
||||
|
||||
// ─── Button state styles ────────────────────────────────────────────────────
|
||||
|
||||
const baseClasses = small
|
||||
? "inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium transition-all"
|
||||
: "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-all";
|
||||
|
||||
const idleClasses =
|
||||
"text-zinc-400 dark:text-zinc-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30 border border-transparent hover:border-amber-200 dark:hover:border-amber-800";
|
||||
|
||||
const loadingClasses = "text-zinc-300 dark:text-zinc-600 cursor-wait";
|
||||
|
||||
const flaggedClasses =
|
||||
"text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-700";
|
||||
|
||||
const errorClasses =
|
||||
"text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800";
|
||||
|
||||
const stateClasses =
|
||||
state === "loading"
|
||||
? loadingClasses
|
||||
: state === "flagged"
|
||||
? flaggedClasses
|
||||
: state === "error"
|
||||
? errorClasses
|
||||
: idleClasses;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFlag}
|
||||
disabled={state === "loading"}
|
||||
className={`${baseClasses} ${stateClasses} ${className}`}
|
||||
title={
|
||||
state === "flagged"
|
||||
? `Flagged (${flagCount}×)`
|
||||
: state === "error"
|
||||
? errorMsg
|
||||
: `Flag for manual review${label ? ` — ${label}` : ""}`
|
||||
}
|
||||
aria-label={
|
||||
state === "flagged"
|
||||
? `Flagged for review (${flagCount} times)`
|
||||
: `Flag this ${label || "content"} for manual review`
|
||||
}
|
||||
>
|
||||
{/* Flag icon */}
|
||||
{state === "loading" ? (
|
||||
<svg
|
||||
className={`${small ? "h-3 w-3" : "h-3.5 w-3.5"} animate-spin`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={`${small ? "h-3 w-3" : "h-3.5 w-3.5"}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3.5 2.75a.75.75 0 00-1.5 0v14.5a.75.75 0 001.5 0v-4.392l1.657-.348a6.453 6.453 0 014.271.572 7.948 7.948 0 005.965.524l2.078-.64A.75.75 0 0018 12.25v-8.5a.75.75 0 00-.904-.734l-2.38.501a7.25 7.25 0 01-4.186-.363l-.502-.2a8.75 8.75 0 00-5.053-.439l-1.475.31V2.75z" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
{state === "flagged" ? (
|
||||
<span>Flagged{flagCount > 1 ? ` (${flagCount}×)` : ""}</span>
|
||||
) : state === "error" ? (
|
||||
<span>Error</span>
|
||||
) : (
|
||||
<span>Flag</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
src/components/FlagPlantImage.tsx
Normal file
26
src/components/FlagPlantImage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
/**
|
||||
* Client component wrapper to add a flag button for plant images
|
||||
* on the detail page (which is a server component).
|
||||
*/
|
||||
export default function FlagPlantImage({ plantId }: { plantId: string }) {
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 right-1 z-10" onClick={handleClick}>
|
||||
<FlagButton
|
||||
contentType="plant_image"
|
||||
contentId={plantId}
|
||||
fieldName="image"
|
||||
label="plant image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/Footer.test.tsx
Normal file
41
src/components/Footer.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
describe("Footer", () => {
|
||||
it("renders footer element", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByRole("contentinfo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders app name", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getAllByText(/Plant Health ID/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders navigation links", () => {
|
||||
render(<Footer />);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders copyright or year", () => {
|
||||
const { container } = render(<Footer />);
|
||||
expect(container.textContent).toMatch(/\d{4}/);
|
||||
});
|
||||
|
||||
it("renders disclaimer text", () => {
|
||||
const { container } = render(<Footer />);
|
||||
expect(container.textContent).toMatch(/beta|preview|accuracy|disclaimer/i);
|
||||
});
|
||||
|
||||
it("renders links section with nav links", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText(/Links/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders about section", () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getAllByText(/About/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
79
src/components/Footer.tsx
Normal file
79
src/components/Footer.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { APP_NAME, APP_TAGLINE, NAV_LINKS, BETA_DISCLAIMER } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Site footer with three-column layout:
|
||||
* about blurb, quick links, and legal disclaimer.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* About blurb */}
|
||||
<div>
|
||||
<Link href="/" className="flex items-center gap-2 text-lg font-bold text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<span aria-hidden="true">🌱</span>
|
||||
{APP_NAME}
|
||||
</Link>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
||||
{APP_TAGLINE} — Upload a photo of your plant and get a
|
||||
hyper-specific disease diagnosis with treatment steps and prevention
|
||||
tips. Built by gardeners, for gardeners.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 uppercase tracking-wide">
|
||||
Quick Links
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-zinc-600 hover:text-leaf-green-700 dark:text-zinc-400 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 uppercase tracking-wide">
|
||||
⚠️ Disclaimer
|
||||
</h3>
|
||||
<p className="mt-4 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||
{BETA_DISCLAIMER}
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-warning-amber-600 dark:text-warning-amber-400">
|
||||
Beta — in active development.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-10 pt-6 border-t border-zinc-200 dark:border-zinc-800 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
© {new Date().getFullYear()} {APP_NAME}. Made by gardeners, for gardeners. 🌻
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<Link
|
||||
href="/about"
|
||||
className="hover:text-leaf-green-700 dark:hover:text-leaf-green-300 transition-colors"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Open source ❤️</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
143
src/components/ImageLightbox.tsx
Normal file
143
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>
|
||||
);
|
||||
}
|
||||
193
src/components/ImageUpload.test.tsx
Normal file
193
src/components/ImageUpload.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import ImageUpload from "@/components/ImageUpload";
|
||||
import * as uploadApi from "@/lib/api/upload";
|
||||
import * as imageProcessing from "@/lib/image-processing";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/api/upload", () => ({
|
||||
uploadImage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-processing", () => ({
|
||||
validateImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ImageUpload", () => {
|
||||
const mockFile = new File(["dummy"], "test.png", { type: "image/png" });
|
||||
const mockResponse = {
|
||||
imageId: "test-id-123",
|
||||
tensorShape: [3, 224, 224],
|
||||
previewUrl: "/uploads/test-id-123.png",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders drop zone with upload prompt", () => {
|
||||
render(<ImageUpload />);
|
||||
expect(screen.getByRole("button", { name: /Upload a plant image/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/PNG, JPG, WebP/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disabled state when disabled prop is true", () => {
|
||||
render(<ImageUpload disabled />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
expect(dropZone).toHaveClass("opacity-50");
|
||||
});
|
||||
|
||||
it("triggers file input click on drop zone click", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.click(dropZone);
|
||||
// The hidden file input exists in the DOM
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers file input on keyboard Enter", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.keyDown(dropZone, { key: "Enter", code: "Enter" });
|
||||
});
|
||||
|
||||
it("triggers file input on keyboard Space", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.keyDown(dropZone, { key: " ", code: "Space" });
|
||||
});
|
||||
|
||||
it("shows drag over state", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.dragEnter(dropZone);
|
||||
expect(screen.getByText(/Drop your image here/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets drag over state on drag leave", () => {
|
||||
render(<ImageUpload />);
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.dragEnter(dropZone);
|
||||
fireEvent.dragLeave(dropZone);
|
||||
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles file selection and shows uploading state", async () => {
|
||||
const onUpload = vi.fn();
|
||||
render(<ImageUpload onUpload={onUpload} />);
|
||||
|
||||
// Simulate file selection via the file input
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Uploading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onUpload callback on success", async () => {
|
||||
const onUpload = vi.fn();
|
||||
render(<ImageUpload onUpload={onUpload} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpload).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success state with image details", async () => {
|
||||
render(<ImageUpload />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Upload Successful/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Upload Another/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError callback when upload fails", async () => {
|
||||
const onError = vi.fn();
|
||||
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Network error")
|
||||
);
|
||||
|
||||
render(<ImageUpload onError={onError} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state with retry and clear buttons", async () => {
|
||||
(uploadApi.uploadImage as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Upload failed")
|
||||
);
|
||||
|
||||
render(<ImageUpload />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Upload Failed/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Retry/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clear/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError with validation error", async () => {
|
||||
const onError = vi.fn();
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
ok: false,
|
||||
error: "File too large",
|
||||
});
|
||||
|
||||
render(<ImageUpload onError={onError} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith("File too large");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears state when Clear/Upload Another button is clicked", async () => {
|
||||
render(<ImageUpload />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput!, { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Upload Successful/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/Upload Another/i));
|
||||
|
||||
expect(screen.getByText(/Upload a Plant Photo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not handle file when disabled", async () => {
|
||||
render(<ImageUpload disabled />);
|
||||
|
||||
const dropZone = screen.getByRole("button");
|
||||
fireEvent.click(dropZone);
|
||||
|
||||
expect(uploadApi.uploadImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
543
src/components/ImageUpload.tsx
Normal file
543
src/components/ImageUpload.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
DragEvent,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import { uploadImage, UploadResponse } from "@/lib/api/upload";
|
||||
import { validateImageFile } from "@/lib/image-processing";
|
||||
import LoadingSkeleton, { UploadSkeleton } from "@/components/LoadingSkeleton";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type UploadStatus = "idle" | "validating" | "uploading" | "success" | "error";
|
||||
|
||||
export interface ImageUploadProps {
|
||||
/** Called when upload succeeds with the server response */
|
||||
onUpload?: (response: UploadResponse) => void;
|
||||
/** Called when upload fails with the error */
|
||||
onError?: (error: string) => void;
|
||||
/** Optional class for the root element */
|
||||
className?: string;
|
||||
/** Optional: disable the component */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ─── SVG Icons (inline, no dependencies) ─────────────────────────────────────
|
||||
|
||||
function UploadCloudIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 16V4m0 0l-4 4m4-4l4 4" />
|
||||
<path d="M20 16.5c0 .83-.97 1.5-2.17 1.5S16 17.33 16 16.5c0-.35.16-.66.42-.87C16.37 14.97 17.5 14 19 14c1.5 0 2.63.97 2.98 2.63.26.21.42.52.42.87Z" />
|
||||
<path d="M4 16.5c0 .83.97 1.5 2.17 1.5S8 17.33 8 16.5c0-.35-.16-.66-.42-.87C7.63 14.97 6.5 14 5 14c-1.5 0-2.63.97-2.98 2.63C1.76 16.91 2 17.17 2 17.5Z" />
|
||||
<path d="M12 13c-3 0-5.5 1.3-7 3.3" />
|
||||
<path d="M12 13c3 0 5.5 1.3 7 3.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinnerIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${className}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RetryIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M3.51 15a9 9 0 105.64-8.36L1 10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Progress Bar ────────────────────────────────────────────────────────────
|
||||
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div className="w-full mt-4" role="progressbar" aria-valuenow={progress} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-leaf-green-500 transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-1 block text-center text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ImageUpload({
|
||||
onUpload,
|
||||
onError,
|
||||
className = "",
|
||||
disabled = false,
|
||||
}: ImageUploadProps) {
|
||||
const [status, setStatus] = useState<UploadStatus>("idle");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadResponse, setUploadResponse] = useState<UploadResponse | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileHandleRef = useRef<File | null>(null);
|
||||
|
||||
// Simulate progress during upload (fetch doesn't support upload progress natively)
|
||||
const simulateProgress = useCallback(() => {
|
||||
setProgress(0);
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(interval);
|
||||
return 90;
|
||||
}
|
||||
return prev + Math.random() * 15;
|
||||
});
|
||||
}, 200);
|
||||
return interval;
|
||||
}, []);
|
||||
|
||||
// Handle file selection
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (disabled) return;
|
||||
|
||||
// Reset state
|
||||
setStatus("validating");
|
||||
setErrorMessage(null);
|
||||
setProgress(0);
|
||||
setUploadResponse(null);
|
||||
|
||||
// Validate
|
||||
const validation = validateImageFile(file);
|
||||
if (!validation.ok) {
|
||||
setStatus("error");
|
||||
setErrorMessage(validation.error);
|
||||
onError?.(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
setFileName(file.name);
|
||||
fileHandleRef.current = file;
|
||||
|
||||
// Upload
|
||||
setStatus("uploading");
|
||||
const progressInterval = simulateProgress();
|
||||
|
||||
try {
|
||||
const response = await uploadImage(file, (pct) => {
|
||||
setProgress(Math.min(pct, 95));
|
||||
});
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
setStatus("success");
|
||||
setUploadResponse(response);
|
||||
onUpload?.(response);
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Upload failed unexpectedly.";
|
||||
setStatus("error");
|
||||
setErrorMessage(msg);
|
||||
onError?.(msg);
|
||||
}
|
||||
},
|
||||
[disabled, onUpload, onError, simulateProgress],
|
||||
);
|
||||
|
||||
// Drag handlers
|
||||
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) setIsDragOver(true);
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
// File input change handler
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
// Click handler for drop zone
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
// Clear / reset
|
||||
const handleClear = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setStatus("idle");
|
||||
setPreviewUrl(null);
|
||||
setFileName(null);
|
||||
setErrorMessage(null);
|
||||
setProgress(0);
|
||||
setUploadResponse(null);
|
||||
fileHandleRef.current = null;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [previewUrl]);
|
||||
|
||||
// Retry with same file
|
||||
const handleRetry = useCallback(() => {
|
||||
if (fileHandleRef.current) {
|
||||
handleFile(fileHandleRef.current);
|
||||
}
|
||||
}, [handleFile]);
|
||||
|
||||
// ─── Render States ────────────────────────────────────────────────────────
|
||||
|
||||
// Error state
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded-2xl border-2 border-warning-amber-300 dark:border-warning-amber-700 bg-warning-amber-50/50 dark:bg-warning-amber-950/20 p-8 text-center ${className}`}
|
||||
>
|
||||
{previewUrl && (
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Selected image preview"
|
||||
className="h-48 w-48 rounded-xl object-cover ring-2 ring-warning-amber-300 dark:ring-warning-amber-700"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="rounded-full bg-red-500/80 p-2">
|
||||
<XIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-warning-amber-700 dark:text-warning-amber-400">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Upload Failed</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400 max-w-sm">
|
||||
{errorMessage || "Something went wrong while uploading your image."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-leaf-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<RetryIcon className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Uploading / validating state
|
||||
if (status === "uploading" || status === "validating") {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded-2xl border-2 border-leaf-green-300 dark:border-leaf-green-700 bg-white dark:bg-zinc-900 p-8 text-center ${className}`}
|
||||
>
|
||||
{previewUrl && (
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploading image preview"
|
||||
className="h-48 w-48 rounded-xl object-cover ring-2 ring-leaf-green-300 dark:ring-leaf-green-700"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/20">
|
||||
<SpinnerIcon className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
{status === "validating" ? "Validating image..." : "Uploading..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fileName && (
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400 truncate max-w-xs">
|
||||
{fileName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ProgressBar progress={Math.round(progress)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (status === "success" && uploadResponse) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded-2xl border-2 border-leaf-green-400 dark:border-leaf-green-600 bg-leaf-green-50/50 dark:bg-leaf-green-950/20 p-8 text-center ${className}`}
|
||||
>
|
||||
{previewUrl && (
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploaded image"
|
||||
className="h-48 w-48 rounded-xl object-cover ring-2 ring-leaf-green-400 dark:ring-leaf-green-600"
|
||||
/>
|
||||
<div className="absolute -right-1 -top-1 flex h-7 w-7 items-center justify-center rounded-full bg-leaf-green-500 shadow">
|
||||
<CheckIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Upload Successful</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 w-full max-w-xs space-y-1 rounded-lg bg-white/60 dark:bg-zinc-900/60 px-4 py-3 text-left">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Image ID</span>
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300 truncate ml-2">
|
||||
{uploadResponse.imageId.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Tensor Shape</span>
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||||
[{uploadResponse.tensorShape.join(", ")}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 dark:border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Upload Another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle / default state (drop zone)
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload a plant image. Drag and drop or click to browse."
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center
|
||||
rounded-2xl border-2 border-dashed p-8 text-center transition-all duration-200
|
||||
cursor-pointer select-none
|
||||
${isDragOver
|
||||
? "border-leaf-green-500 bg-leaf-green-50 dark:border-leaf-green-400 dark:bg-leaf-green-950/30 scale-[1.02]"
|
||||
: "border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 hover:border-leaf-green-400 dark:hover:border-leaf-green-600 hover:bg-leaf-green-50/50 dark:hover:bg-leaf-green-950/10"
|
||||
}
|
||||
${disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
${className}
|
||||
`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Upload icon */}
|
||||
<div className={`flex h-16 w-16 items-center justify-center rounded-2xl transition-colors ${
|
||||
isDragOver
|
||||
? "bg-leaf-green-100 dark:bg-leaf-green-900/50"
|
||||
: "bg-zinc-100 dark:bg-zinc-800"
|
||||
}`}>
|
||||
<UploadCloudIcon className="h-8 w-8 text-leaf-green-600 dark:text-leaf-green-400" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h3 className="mt-4 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{isDragOver ? "Drop your image here" : "Upload a Plant Photo"}
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Drag and drop, or{" "}
|
||||
<span className="font-medium text-leaf-green-600 dark:text-leaf-green-400 underline underline-offset-2">
|
||||
click to browse
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* File type hints */}
|
||||
<div className="mt-4 flex items-center gap-3 text-xs text-zinc-400 dark:text-zinc-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 dark:bg-zinc-800 px-2.5 py-1">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
PNG, JPG, WebP
|
||||
</span>
|
||||
<span>Max 10 MB</span>
|
||||
<span>Min 150×150</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/LoadingSkeleton.test.tsx
Normal file
99
src/components/LoadingSkeleton.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import LoadingSkeleton, {
|
||||
ResultsSkeleton,
|
||||
PlantCardSkeleton,
|
||||
UploadSkeleton,
|
||||
} from "@/components/LoadingSkeleton";
|
||||
|
||||
describe("LoadingSkeleton", () => {
|
||||
it("renders default text variant skeleton", () => {
|
||||
const { container } = render(<LoadingSkeleton />);
|
||||
const pulseElements = container.querySelectorAll(".animate-pulse");
|
||||
expect(pulseElements.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders skeleton with custom className", () => {
|
||||
const { container } = render(<LoadingSkeleton className="custom-class" />);
|
||||
expect(container.querySelector(".custom-class")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple skeletons when count > 1", () => {
|
||||
const { container } = render(<LoadingSkeleton count={3} />);
|
||||
const pulseElements = container.querySelectorAll(".animate-pulse");
|
||||
expect(pulseElements.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingSkeleton variants", () => {
|
||||
it("renders card variant with image and text blocks", () => {
|
||||
const { container } = render(<LoadingSkeleton variant="card" />);
|
||||
expect(container.querySelector(".rounded-xl")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text variant with staggered widths", () => {
|
||||
const { container } = render(<LoadingSkeleton variant="text" />);
|
||||
const lines = container.querySelectorAll(".animate-pulse");
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders image variant", () => {
|
||||
const { container } = render(<LoadingSkeleton variant="image" />);
|
||||
const image = container.querySelector(".animate-pulse");
|
||||
expect(image).toBeInTheDocument();
|
||||
expect(image).toHaveClass("h-48");
|
||||
});
|
||||
|
||||
it("renders circle variant", () => {
|
||||
const { container } = render(<LoadingSkeleton variant="circle" />);
|
||||
expect(container.querySelector(".rounded-full")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders row variant with icon and text", () => {
|
||||
const { container } = render(<LoadingSkeleton variant="row" />);
|
||||
const row = container.querySelector(".flex.items-center.gap-4");
|
||||
expect(row).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResultsSkeleton", () => {
|
||||
it("renders a full-page results skeleton with status role", () => {
|
||||
render(<ResultsSkeleton />);
|
||||
const status = screen.getByRole("status", { name: /Loading results/i });
|
||||
expect(status).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image, text, and card sections", () => {
|
||||
const { container } = render(<ResultsSkeleton />);
|
||||
const pulseElements = container.querySelectorAll(".animate-pulse");
|
||||
expect(pulseElements.length).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlantCardSkeleton", () => {
|
||||
it("renders default 6 card skeletons", () => {
|
||||
const { container } = render(<PlantCardSkeleton />);
|
||||
const cards = container.querySelectorAll(".rounded-xl");
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it("renders custom count of card skeletons", () => {
|
||||
const { container } = render(<PlantCardSkeleton count={3} />);
|
||||
const cards = container.querySelectorAll(".rounded-xl");
|
||||
expect(cards.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UploadSkeleton", () => {
|
||||
it("renders upload area skeleton with status role", () => {
|
||||
render(<UploadSkeleton />);
|
||||
const status = screen.getByRole("status", { name: /Loading upload area/i });
|
||||
expect(status).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders circle and text skeletons inside dashed border", () => {
|
||||
const { container } = render(<UploadSkeleton />);
|
||||
expect(container.querySelector(".border-dashed")).toBeInTheDocument();
|
||||
expect(container.querySelector(".rounded-full")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
126
src/components/LoadingSkeleton.tsx
Normal file
126
src/components/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type SkeletonVariant = "card" | "text" | "image" | "circle" | "row";
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
variant?: SkeletonVariant;
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable loading skeleton with pulse animation.
|
||||
* Supports variants: card, text, image, circle, row.
|
||||
* Accepts a `count` to render multiple skeleton items.
|
||||
*/
|
||||
export default function LoadingSkeleton({
|
||||
variant = "text",
|
||||
count = 1,
|
||||
className = "",
|
||||
}: LoadingSkeletonProps) {
|
||||
const skeletonClass = `animate-pulse rounded bg-zinc-200 dark:bg-zinc-700 ${className}`;
|
||||
|
||||
const renderSkeleton = (index: number) => {
|
||||
switch (variant) {
|
||||
case "card":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
>
|
||||
<div className={`${skeletonClass} h-48 w-full rounded-none`} />
|
||||
<div className="p-4 space-y-3">
|
||||
<div className={`${skeletonClass} h-5 w-3/4`} />
|
||||
<div className={`${skeletonClass} h-4 w-1/2`} />
|
||||
<div className={`${skeletonClass} h-4 w-full`} />
|
||||
<div className={`${skeletonClass} h-4 w-2/3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className={`${skeletonClass} h-4 w-full`} />
|
||||
<div className={`${skeletonClass} h-4 w-5/6`} />
|
||||
<div className={`${skeletonClass} h-4 w-4/6`} />
|
||||
</div>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<div key={index} className={`${skeletonClass} h-48 w-full`} />
|
||||
);
|
||||
case "circle":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${skeletonClass} h-16 w-16 rounded-full`}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<div className={`${skeletonClass} h-12 w-12 rounded-lg shrink-0`} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className={`${skeletonClass} h-4 w-1/3`} />
|
||||
<div className={`${skeletonClass} h-3 w-2/3`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => renderSkeleton(i))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Preset skeletons ─── */
|
||||
|
||||
/**
|
||||
* Full-page results skeleton: image placeholder + text blocks.
|
||||
*/
|
||||
export function ResultsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8" role="status" aria-label="Loading results">
|
||||
<LoadingSkeleton variant="image" className="h-64 rounded-xl" />
|
||||
<LoadingSkeleton variant="text" count={3} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<LoadingSkeleton variant="card" count={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant card grid skeleton.
|
||||
*/
|
||||
export function PlantCardSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<LoadingSkeleton variant="card" count={count} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload area skeleton.
|
||||
*/
|
||||
export function UploadSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed border-zinc-300 dark:border-zinc-600 rounded-2xl p-12 space-y-4"
|
||||
role="status"
|
||||
aria-label="Loading upload area"
|
||||
>
|
||||
<LoadingSkeleton variant="circle" className="h-20 w-20" />
|
||||
<LoadingSkeleton variant="text" className="w-1/2" />
|
||||
<LoadingSkeleton variant="text" className="w-1/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/LookalikeWarning.test.tsx
Normal file
158
src/components/LookalikeWarning.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import type { Disease } from "@/lib/types";
|
||||
|
||||
describe("LookalikeWarning", () => {
|
||||
const mockDisease: Disease = {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: [
|
||||
"Dark brown spots on lower leaves",
|
||||
"Concentric rings in lesions",
|
||||
"Yellowing of older leaves",
|
||||
],
|
||||
treatment: [
|
||||
"Remove affected leaves",
|
||||
"Apply copper fungicide",
|
||||
"Improve air circulation",
|
||||
],
|
||||
lookalikeDiseaseIds: ["late-blight"],
|
||||
};
|
||||
|
||||
const mockLookalike: Disease = {
|
||||
id: "late-blight",
|
||||
name: "Late Blight",
|
||||
causalAgent: "Phytophthora infestans",
|
||||
causalAgentType: "fungal",
|
||||
severity: "high",
|
||||
symptoms: [
|
||||
"Dark brown spots on lower leaves",
|
||||
"Water-soaked lesions",
|
||||
"White fungal growth on undersides",
|
||||
],
|
||||
treatment: [
|
||||
"Remove and destroy infected plants",
|
||||
"Apply systemic fungicide",
|
||||
"Crop rotation",
|
||||
],
|
||||
lookalikeDiseaseIds: ["early-blight"],
|
||||
};
|
||||
|
||||
it("renders nothing when lookalikes array is empty", () => {
|
||||
const { container } = render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[]} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders warning banner with lookalike name", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
expect(screen.getByText(/easily confused with/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Late Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plural 's' when multiple lookalikes", () => {
|
||||
render(
|
||||
<LookalikeWarning
|
||||
disease={mockDisease}
|
||||
lookalikes={[mockLookalike, mockDisease]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/easily confused with/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands comparison table on click", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
const toggleButton = screen.getByRole("button", {
|
||||
name: /easily confused with/i,
|
||||
});
|
||||
|
||||
// Table should not be visible initially
|
||||
expect(screen.queryByText("Symptom")).not.toBeInTheDocument();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Comparison table should now be visible
|
||||
expect(screen.getByText("Symptom")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Early Blight vs\. Late Blight/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses comparison table on second click", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
const toggleButton = screen.getByRole("button", {
|
||||
name: /easily confused with/i,
|
||||
});
|
||||
|
||||
// Expand
|
||||
fireEvent.click(toggleButton);
|
||||
expect(screen.getByText("Symptom")).toBeInTheDocument();
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(toggleButton);
|
||||
expect(screen.queryByText("Symptom")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows symptom comparison columns", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getAllByText("Early Blight").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Late Blight").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("marks shared symptoms as Present", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// "Dark brown spots on lower leaves" is in both
|
||||
const presentCount = screen.getAllByText("Present");
|
||||
expect(presentCount.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("marks unique symptoms with dash", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Symptoms unique to one disease should show "—"
|
||||
const dashes = screen.getAllByText("—");
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders legend for Present and Similar indicators", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText(/Present in both/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Similar symptom/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has aria-expanded attribute on toggle button", () => {
|
||||
render(
|
||||
<LookalikeWarning disease={mockDisease} lookalikes={[mockLookalike]} />
|
||||
);
|
||||
const toggleButton = screen.getByRole("button");
|
||||
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
185
src/components/LookalikeWarning.tsx
Normal file
185
src/components/LookalikeWarning.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import type { Disease } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Warning banner when lookalike diseases exist, with side-by-side comparison toggle.
|
||||
*
|
||||
* Yellow banner: "This disease is easily confused with [lookalike name]."
|
||||
* Click to expand side-by-side symptom comparison table.
|
||||
* Comparison table columns: symptom, this disease, lookalike disease.
|
||||
* Links to lookalike disease detail.
|
||||
*/
|
||||
export default function LookalikeWarning({
|
||||
disease,
|
||||
lookalikes,
|
||||
}: {
|
||||
/** The current predicted disease */
|
||||
disease: Disease;
|
||||
/** Array of lookalike disease objects */
|
||||
lookalikes: Disease[];
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (lookalikes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-warning-amber-200 dark:border-warning-amber-800 bg-warning-amber-50 dark:bg-warning-amber-950/30 overflow-hidden">
|
||||
{/* Banner header — clickable to expand */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-warning-amber-100/50 dark:hover:bg-warning-amber-900/20 transition-colors"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{/* Warning icon */}
|
||||
<svg className="h-5 w-5 shrink-0 text-warning-amber-600 dark:text-warning-amber-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Warning text */}
|
||||
<span className="text-sm font-medium text-warning-amber-800 dark:text-warning-amber-200 flex-1">
|
||||
This disease is easily confused with{" "}
|
||||
{lookalikes.map((d, i) => (
|
||||
<span key={d.id}>
|
||||
{i > 0 ? ", " : ""}
|
||||
<span className="font-semibold underline decoration-warning-amber-400 dark:decoration-warning-amber-600 underline-offset-2">
|
||||
{d.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{lookalikes.length > 1 ? "s" : ""}.
|
||||
</span>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 text-warning-amber-500 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.22 7.22a.75.75 0 011.06 0L10 10.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 8.28a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded comparison table */}
|
||||
{expanded && (
|
||||
<div className="border-t border-warning-amber-200 dark:border-warning-amber-800 px-4 py-4 space-y-4">
|
||||
{lookalikes.map((lookalike) => (
|
||||
<LookalikeComparison
|
||||
key={lookalike.id}
|
||||
disease={disease}
|
||||
lookalike={lookalike}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-by-side comparison of symptoms between the predicted disease and a lookalike.
|
||||
*/
|
||||
function LookalikeComparison({
|
||||
disease,
|
||||
lookalike,
|
||||
}: {
|
||||
disease: Disease;
|
||||
lookalike: Disease;
|
||||
}) {
|
||||
// Build a combined list of symptoms for comparison
|
||||
const allSymptoms = [...new Set([...disease.symptoms, ...lookalike.symptoms])];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-warning-amber-900 dark:text-warning-amber-100 mb-2">
|
||||
{disease.name} vs. {lookalike.name}
|
||||
</h5>
|
||||
|
||||
{/* Comparison table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-warning-amber-200 dark:border-warning-amber-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-warning-amber-100 dark:bg-warning-amber-900/40">
|
||||
<th className="px-3 py-2 text-left font-semibold text-warning-amber-900 dark:text-warning-amber-100 w-1/4">
|
||||
Symptom
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-warning-amber-900 dark:text-warning-amber-100 w-1/3">
|
||||
{disease.name}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-warning-amber-900 dark:text-warning-amber-100 w-1/3">
|
||||
{lookalike.name}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-warning-amber-100 dark:divide-warning-amber-900/30">
|
||||
{allSymptoms.map((symptom, i) => {
|
||||
const hasSymptom = disease.symptoms.includes(symptom);
|
||||
const lookalikeHasSymptom = lookalike.symptoms.includes(symptom);
|
||||
// Check if it's a similar (but not exact) symptom
|
||||
const lookalikeSimilar = !lookalikeHasSymptom && lookalike.symptoms.some(
|
||||
ls => ls.toLowerCase().includes(symptom.toLowerCase().slice(0, 10))
|
||||
);
|
||||
|
||||
return (
|
||||
<tr key={i} className="hover:bg-warning-amber-50/50 dark:hover:bg-warning-amber-900/10">
|
||||
<td className="px-3 py-2 text-zinc-700 dark:text-zinc-300 align-top">
|
||||
{symptom.slice(0, 60)}
|
||||
{symptom.length > 60 ? "…" : ""}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{hasSymptom ? (
|
||||
<span className="inline-flex items-center gap-1 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Present
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-400 dark:text-zinc-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{lookalikeHasSymptom ? (
|
||||
<span className="inline-flex items-center gap-1 text-leaf-green-700 dark:text-leaf-green-400">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Present
|
||||
</span>
|
||||
) : lookalikeSimilar ? (
|
||||
<span className="inline-flex items-center gap-1 text-warning-amber-700 dark:text-warning-amber-400">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Similar
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-400 dark:text-zinc-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Key differences summary */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-leaf-green-500" />
|
||||
Present in both
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-warning-amber-500" />
|
||||
Similar symptom
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/Navbar.test.tsx
Normal file
70
src/components/Navbar.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
})),
|
||||
usePathname: vi.fn(() => "/"),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, className, ...props }: any) => (
|
||||
<a href={href} className={className} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Navbar", () => {
|
||||
it("renders header with app name", () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Plant Health ID").length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders navigation links", () => {
|
||||
render(<Navbar />);
|
||||
const nav = screen.getByRole("navigation", { name: /Global/i });
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders desktop search form", () => {
|
||||
render(<Navbar />);
|
||||
const searchForms = screen.getAllByRole("search");
|
||||
expect(searchForms.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders mobile menu toggle button", () => {
|
||||
render(<Navbar />);
|
||||
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens mobile menu on button click", () => {
|
||||
render(<Navbar />);
|
||||
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
|
||||
fireEvent.click(menuButton);
|
||||
const mobileDialog = screen.getByRole("dialog", { name: /Mobile navigation/i });
|
||||
expect(mobileDialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders mobile search form when menu is open", () => {
|
||||
render(<Navbar />);
|
||||
const menuButton = screen.getByRole("button", { name: /Open navigation menu/i });
|
||||
fireEvent.click(menuButton);
|
||||
const searchForms = screen.getAllByRole("search");
|
||||
expect(searchForms.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders plant emoji logo", () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.getByText("🌱")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
225
src/components/Navbar.tsx
Normal file
225
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, startTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { APP_NAME, NAV_LINKS } from "@/lib/constants";
|
||||
import SearchSuggestions from "@/components/SearchSuggestions";
|
||||
|
||||
/**
|
||||
* Responsive global navigation bar.
|
||||
* - Sticky top bar with app name and nav links
|
||||
* - Mobile hamburger menu with slide-out drawer
|
||||
* - Search input that navigates to /browse?search=... on submit
|
||||
* - Active link highlighting based on current route
|
||||
*/
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setMobileOpen(false);
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMobileOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Trap focus inside mobile drawer when open
|
||||
useEffect(() => {
|
||||
if (mobileOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const isActive = useCallback(
|
||||
(href: string) => {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(href);
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const navLinkClass = (href: string) =>
|
||||
`text-sm font-medium transition-colors px-3 py-2 rounded-lg ${
|
||||
isActive(href)
|
||||
? "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300"
|
||||
: "text-zinc-600 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-400 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20"
|
||||
}`;
|
||||
|
||||
const mobileNavLinkClass = (href: string) =>
|
||||
`block text-base font-medium transition-colors px-4 py-3 rounded-lg ${
|
||||
isActive(href)
|
||||
? "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300"
|
||||
: "text-zinc-700 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-300 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-zinc-200 dark:border-zinc-800 bg-white/95 dark:bg-zinc-950/95 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:supports-[backdrop-filter]:bg-zinc-950/80">
|
||||
<nav
|
||||
className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8"
|
||||
aria-label="Global"
|
||||
>
|
||||
{/* Logo / App Name */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-lg font-bold tracking-tight text-leaf-green-700 dark:text-leaf-green-400 shrink-0"
|
||||
>
|
||||
<span aria-hidden="true" className="text-2xl">
|
||||
🌱
|
||||
</span>
|
||||
<span className="hidden sm:inline">{APP_NAME}</span>
|
||||
<span className="sm:hidden">{APP_NAME}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav links */}
|
||||
<div className="hidden md:flex md:items-center md:gap-1">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} className={navLinkClass(link.href)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop search */}
|
||||
<SearchSuggestions
|
||||
placeholder="Search plants..."
|
||||
inputClassName="w-56 lg:w-64 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all"
|
||||
wrapperClassName="hidden md:block"
|
||||
/>
|
||||
|
||||
{/* Mobile hamburger button */}
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
className="md:hidden inline-flex items-center justify-center rounded-lg p-2 text-zinc-600 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-400 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20 transition-colors"
|
||||
onClick={() => setMobileOpen((prev) => !prev)}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm md:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<div
|
||||
ref={drawerRef}
|
||||
id="mobile-menu"
|
||||
className={`fixed top-0 right-0 z-50 h-full w-72 max-w-[85vw] bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-zinc-800 shadow-2xl transform transition-transform duration-300 ease-in-out md:hidden ${
|
||||
mobileOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<span className="text-lg font-bold text-leaf-green-700 dark:text-leaf-green-400">
|
||||
🌱 {APP_NAME}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-zinc-600 hover:text-leaf-green-700 hover:bg-leaf-green-50 dark:text-zinc-400 dark:hover:text-leaf-green-300 dark:hover:bg-leaf-green-900/20 transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-6 space-y-1">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={mobileNavLinkClass(link.href)}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile search */}
|
||||
<div className="px-4 mt-6 pt-6 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<SearchSuggestions
|
||||
placeholder="Search plants..."
|
||||
inputClassName="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 px-3.5 py-2.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all"
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
56
src/components/PlantCard.test.tsx
Normal file
56
src/components/PlantCard.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import PlantCard from "@/components/PlantCard";
|
||||
import type { PlantCardData } from "@/components/PlantCard";
|
||||
|
||||
describe("PlantCard", () => {
|
||||
const mockPlant: PlantCardData = {
|
||||
id: "tomato",
|
||||
commonName: "Tomato",
|
||||
scientificName: "Solanum lycopersicum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetable",
|
||||
imageUrl: "https://example.com/tomato.jpg",
|
||||
diseaseCount: 2,
|
||||
};
|
||||
|
||||
it("renders plant name", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant image", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
const img = screen.getByRole("img") as HTMLImageElement;
|
||||
expect(img).toHaveAttribute("src", expect.stringContaining("tomato.jpg"));
|
||||
expect(img).toHaveAttribute("alt", "Tomato");
|
||||
});
|
||||
|
||||
it("renders fallback SVG when no image URL", () => {
|
||||
const noImagePlant = { ...mockPlant, imageUrl: "" };
|
||||
render(<PlantCard plant={noImagePlant} />);
|
||||
// Should render SVG fallback instead of image
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plant family", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText("Solanaceae")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disease count", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
expect(screen.getByText(/2 diseases tracked/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders link to plant detail page", () => {
|
||||
render(<PlantCard plant={mockPlant} />);
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/browse/tomato");
|
||||
});
|
||||
|
||||
it("hides disease count when showDiseaseCount is false", () => {
|
||||
render(<PlantCard plant={mockPlant} showDiseaseCount={false} />);
|
||||
expect(screen.queryByText(/diseases tracked/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
97
src/components/PlantCard.tsx
Normal file
97
src/components/PlantCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface PlantCardData {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName: string;
|
||||
family: string;
|
||||
category: string;
|
||||
imageUrl: string;
|
||||
diseaseCount: number;
|
||||
updatedAt?: string;
|
||||
viewCount?: number;
|
||||
}
|
||||
|
||||
interface PlantCardProps {
|
||||
plant: PlantCardData;
|
||||
showDiseaseCount?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant card showing image, name, family, and optional disease count.
|
||||
* Used on the homepage featured section and browse grid.
|
||||
*/
|
||||
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
|
||||
const handleFlagClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/browse/${plant.id}`}
|
||||
className="group block rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-all duration-200 hover:-translate-y-1 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
|
||||
>
|
||||
{/* Plant image */}
|
||||
<div className="relative h-40 bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900 overflow-hidden">
|
||||
{plant.imageUrl ? (
|
||||
<Image
|
||||
src={plant.imageUrl}
|
||||
alt={plant.commonName}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<svg
|
||||
className="w-16 h-16 text-leaf-green-300 dark:text-leaf-green-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* Flag button overlay at bottom-right of image */}
|
||||
<div className="absolute bottom-1.5 right-1.5 z-10" onClick={handleFlagClick}>
|
||||
<FlagButton
|
||||
contentType="plant_image"
|
||||
contentId={plant.id}
|
||||
fieldName="image"
|
||||
label="plant image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-leaf-green-700 dark:group-hover:text-leaf-green-400 transition-colors">
|
||||
{plant.commonName}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 italic mt-0.5">{plant.family}</p>
|
||||
{showDiseaseCount && (
|
||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{plant.diseaseCount === 0
|
||||
? "No known diseases in database"
|
||||
: `${plant.diseaseCount} ${plant.diseaseCount === 1 ? "disease" : "diseases"} tracked`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
26
src/components/PlantViewTracker.tsx
Normal file
26
src/components/PlantViewTracker.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Tracks a plant page view by POSTing to the view-count API.
|
||||
* Renders nothing — purely a side-effect component.
|
||||
*/
|
||||
export default function PlantViewTracker({ plantId }: { plantId: string }) {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(`/api/plants/${encodeURIComponent(plantId)}/view`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
// Keepalive so the request completes even if the user navigates away quickly
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Silently ignore tracking failures
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [plantId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
345
src/components/ResultsDashboard.test.tsx
Normal file
345
src/components/ResultsDashboard.test.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ResultsDashboard from "@/components/ResultsDashboard";
|
||||
import type { IdentifyResponse, PredictionResult } from "@/lib/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/components/DiseaseCard", () => ({
|
||||
default: ({ rank, isPrimary, onDismiss, prediction }: any) => (
|
||||
<div data-testid={`disease-card-${rank}`} data-primary={isPrimary}>
|
||||
<span>{prediction.disease.name}</span>
|
||||
<span data-testid={`confidence-${rank}`}>{prediction.confidence.adjusted.toFixed(2)}</span>
|
||||
<button onClick={onDismiss}>Dismiss</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/LoadingSkeleton", () => ({
|
||||
default: () => <div data-testid="loading-skeleton">Loading...</div>,
|
||||
ResultsSkeleton: () => <div data-testid="results-skeleton">Results Loading...</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/EmptyState", () => ({
|
||||
default: ({ title, description, actionLabel, actionHref }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<span data-testid="empty-title">{title}</span>
|
||||
<span data-testid="empty-desc">{description}</span>
|
||||
{actionLabel && <a href={actionHref}>{actionLabel}</a>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/diseases", () => ({
|
||||
getPlantById: vi.fn(() => ({ id: "tomato", commonName: "Tomato" })),
|
||||
getDiseaseById: vi.fn(),
|
||||
getLookalikeDiseases: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe("ResultsDashboard", () => {
|
||||
const mockPrediction: PredictionResult = {
|
||||
diseaseId: "early-blight",
|
||||
disease: {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: ["Dark spots"],
|
||||
treatment: ["Remove leaves"],
|
||||
lookalikeDiseaseIds: [],
|
||||
plantId: "tomato",
|
||||
},
|
||||
confidence: { raw: 0.85, adjusted: 0.82 },
|
||||
lookalikes: [],
|
||||
};
|
||||
|
||||
const mockResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: {
|
||||
model: "mock-model",
|
||||
inferenceTimeMs: 150,
|
||||
imageId: "test-image-123",
|
||||
},
|
||||
};
|
||||
|
||||
it("renders loading skeleton when loading is true", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={null}
|
||||
loading={true}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("results-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state with error when error is provided", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={null}
|
||||
loading={false}
|
||||
error="Something went wrong"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("empty-title")).toHaveTextContent("Identification Failed");
|
||||
expect(screen.getByTestId("empty-desc")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no response and no predictions", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={null}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("empty-title")).toHaveTextContent("No Results Found");
|
||||
});
|
||||
|
||||
it("renders results header with inference time and model", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Identification Results")).toBeInTheDocument();
|
||||
expect(screen.getByText(/150ms/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/mock-model/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders demo mode badge when demo_mode is true", () => {
|
||||
const demoResponse: IdentifyResponse = {
|
||||
...mockResponse,
|
||||
demo_mode: true,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={demoResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Demo mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image preview", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
const img = screen.getByAltText("Uploaded plant image");
|
||||
expect(img).toHaveAttribute("src", "/test.jpg");
|
||||
});
|
||||
|
||||
it("renders image metadata section", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-image-123"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Image ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Predictions")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 shown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disease prediction cards", () => {
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={mockResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("disease-card-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Early Blight")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts predictions by confidence by default", () => {
|
||||
const multiResponse: IdentifyResponse = {
|
||||
predictions: [
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-a",
|
||||
disease: { ...mockPrediction.disease, name: "Disease A", plantId: "tomato" },
|
||||
confidence: { raw: 0.6, adjusted: 0.58 },
|
||||
},
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-b",
|
||||
disease: { ...mockPrediction.disease, name: "Disease B", plantId: "tomato" },
|
||||
confidence: { raw: 0.9, adjusted: 0.88 },
|
||||
},
|
||||
],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={multiResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
// Disease B (higher confidence) should appear first
|
||||
const cards = screen.getAllByRole("button", { name: /Dismiss/i });
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it("sorts predictions by name when sort changed", () => {
|
||||
const multiResponse: IdentifyResponse = {
|
||||
predictions: [
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-b",
|
||||
disease: { ...mockPrediction.disease, name: "Disease B", plantId: "tomato" },
|
||||
confidence: { raw: 0.9, adjusted: 0.88 },
|
||||
},
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "disease-a",
|
||||
disease: { ...mockPrediction.disease, name: "Disease A", plantId: "tomato" },
|
||||
confidence: { raw: 0.6, adjusted: 0.58 },
|
||||
},
|
||||
],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={multiResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Change sort to name
|
||||
const select = screen.getByLabelText(/Sort by/i);
|
||||
fireEvent.change(select, { target: { value: "name" } });
|
||||
|
||||
// Both cards should still be present
|
||||
expect(screen.getByText("Disease A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disease B")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismisses a prediction when dismiss button is clicked", () => {
|
||||
const multiResponse: IdentifyResponse = {
|
||||
predictions: [
|
||||
mockPrediction,
|
||||
{
|
||||
...mockPrediction,
|
||||
diseaseId: "late-blight",
|
||||
disease: { ...mockPrediction.disease, name: "Late Blight", plantId: "tomato" },
|
||||
confidence: { raw: 0.7, adjusted: 0.68 },
|
||||
},
|
||||
],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={multiResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 2 predictions shown
|
||||
expect(screen.getByText("2 shown")).toBeInTheDocument();
|
||||
|
||||
// Dismiss first prediction
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /Dismiss/i })[0]);
|
||||
|
||||
// Should show 1 prediction
|
||||
expect(screen.getByText("1 shown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows restore results link after dismissing all predictions", () => {
|
||||
const singleResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={singleResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
|
||||
|
||||
expect(screen.getByText(/Restore results/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'All results dismissed' when all predictions dismissed", () => {
|
||||
const singleResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={singleResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
|
||||
|
||||
expect(screen.getByText("All results dismissed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows restore option when all predictions dismissed", () => {
|
||||
const singleResponse: IdentifyResponse = {
|
||||
predictions: [mockPrediction],
|
||||
metadata: mockResponse.metadata,
|
||||
};
|
||||
render(
|
||||
<ResultsDashboard
|
||||
imageId="test-id"
|
||||
imageUrl="/test.jpg"
|
||||
response={singleResponse}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Dismiss
|
||||
fireEvent.click(screen.getByRole("button", { name: /Dismiss/i }));
|
||||
|
||||
// Verify dismissed state with restore option
|
||||
expect(screen.getByText("All results dismissed")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Restore results/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
214
src/components/ResultsDashboard.tsx
Normal file
214
src/components/ResultsDashboard.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import type { IdentifyResponse, PredictionResult } from "@/lib/types";
|
||||
import DiseaseCard from "@/components/DiseaseCard";
|
||||
import LoadingSkeleton, { ResultsSkeleton } from "@/components/LoadingSkeleton";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
|
||||
/**
|
||||
* Top-level results layout: uploaded image preview + ranked prediction cards.
|
||||
*
|
||||
* Side-by-side on desktop (image left, results right), stacked on mobile.
|
||||
* Loading skeleton state while results are computed.
|
||||
* Error state if identification fails.
|
||||
* Empty/unexpected state.
|
||||
*/
|
||||
export default function ResultsDashboard({
|
||||
imageId,
|
||||
imageUrl,
|
||||
response,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
imageId: string;
|
||||
imageUrl: string;
|
||||
response: IdentifyResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const [sortBy, setSortBy] = useState<"confidence" | "name">("confidence");
|
||||
|
||||
// Filter and sort predictions
|
||||
const predictions = useMemo(() => {
|
||||
if (!response?.predictions) return [];
|
||||
|
||||
let filtered = response.predictions.filter(
|
||||
(p: PredictionResult) => !dismissedIds.has(p.diseaseId),
|
||||
);
|
||||
|
||||
if (sortBy === "name") {
|
||||
filtered = [...filtered].sort((a, b) => a.disease.name.localeCompare(b.disease.name));
|
||||
} else {
|
||||
// Default: sort by confidence descending
|
||||
filtered = [...filtered].sort((a, b) => b.confidence.adjusted - a.confidence.adjusted);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [response, dismissedIds, sortBy]);
|
||||
|
||||
const dismissDisease = useCallback((diseaseId: string) => {
|
||||
setDismissedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(diseaseId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearDismissed = useCallback(() => {
|
||||
setDismissedIds(new Set());
|
||||
}, []);
|
||||
|
||||
// ─── Loading state ────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<ResultsSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
illustration="🍂"
|
||||
title="Identification Failed"
|
||||
description={error}
|
||||
actionLabel="Try again"
|
||||
actionHref="/"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty / no predictions ───────────────────────────────────────────────
|
||||
|
||||
if (!response || predictions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
illustration="🔍"
|
||||
title={
|
||||
predictions.length === 0 && dismissedIds.size > 0
|
||||
? "All results dismissed"
|
||||
: "No Results Found"
|
||||
}
|
||||
description={
|
||||
predictions.length === 0 && dismissedIds.size > 0
|
||||
? "You've dismissed all predictions. Click below to restore them."
|
||||
: "We couldn't identify any diseases in this image. Try uploading a clearer photo of the affected area."
|
||||
}
|
||||
actionLabel={
|
||||
predictions.length === 0 && dismissedIds.size > 0
|
||||
? "Restore results"
|
||||
: "Upload another photo"
|
||||
}
|
||||
actionHref={predictions.length === 0 && dismissedIds.size > 0 ? "#" : "/"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────────
|
||||
|
||||
const primaryPrediction = predictions[0];
|
||||
const primaryDisease = primaryPrediction?.disease;
|
||||
const plant = primaryPrediction?.plant ?? null;
|
||||
const demoMode = response?.demo_mode ?? false;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Identification Results
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Analyzed {response?.metadata?.inferenceTimeMs ?? 0}ms · Model:{" "}
|
||||
{response?.metadata?.model ?? "unknown"}
|
||||
{demoMode && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-warning-amber-100 dark:bg-warning-amber-900/50 px-2 py-0.5 text-xs font-medium text-warning-amber-700 dark:text-warning-amber-300">
|
||||
Demo mode
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main layout: image + results */}
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Left: Image preview */}
|
||||
<div className="w-full lg:w-80 lg:shrink-0">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<div className="rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-700 shadow-sm">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Uploaded plant image"
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image metadata */}
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-3 space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Image ID</span>
|
||||
<span className="font-mono text-zinc-700 dark:text-zinc-300 truncate ml-2 max-w-[120px]">
|
||||
{imageId.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
{plant && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Likely plant</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-300">{plant.commonName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Predictions</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-300">{predictions.length} shown</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="sort-select" className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Sort by:
|
||||
</label>
|
||||
<select
|
||||
id="sort-select"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as "confidence" | "name")}
|
||||
className="rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-2 py-1 text-xs text-zinc-700 dark:text-zinc-300 focus:ring-2 focus:ring-leaf-green-500"
|
||||
>
|
||||
<option value="confidence">Confidence</option>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
</select>
|
||||
|
||||
{dismissedIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDismissed}
|
||||
className="ml-auto text-xs text-leaf-green-600 dark:text-leaf-green-400 hover:underline"
|
||||
>
|
||||
Restore all ({dismissedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Ranked prediction cards */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{predictions.map((prediction, index) => (
|
||||
<DiseaseCard
|
||||
key={prediction.diseaseId}
|
||||
prediction={prediction}
|
||||
rank={index + 1}
|
||||
isPrimary={index === 0}
|
||||
onDismiss={() => dismissDisease(prediction.diseaseId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
350
src/components/SearchSuggestions.tsx
Normal file
350
src/components/SearchSuggestions.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef, useId } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Suggestion {
|
||||
type: "plant" | "disease";
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
emoji: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SearchSuggestionsProps {
|
||||
/** Placeholder text for the search input */
|
||||
placeholder?: string;
|
||||
/** Additional CSS classes for the search <input> element */
|
||||
inputClassName?: string;
|
||||
/** Additional CSS classes for the outer wrapper div */
|
||||
wrapperClassName?: string;
|
||||
/** Additional CSS classes for the <form> element */
|
||||
formClassName?: string;
|
||||
/** Called after a suggestion is clicked or the search is submitted (e.g., to close a mobile drawer) */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
// ─── Highlight helper ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits `text` on case-insensitive occurrences of `query` and wraps each match
|
||||
* in a <mark> element so the user can see what part of the suggestion matched
|
||||
* their typed input.
|
||||
*/
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escaped})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.toLowerCase() === lowerQuery) {
|
||||
return (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-leaf-green-200 dark:bg-leaf-green-700 text-leaf-green-900 dark:text-leaf-green-100 rounded px-0.5"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search-as-you-type input with a debounced suggestions dropdown.
|
||||
*
|
||||
* - Fetches suggestions from `/api/plants/suggestions?q=...` as the user types
|
||||
* - Displays results in a dropdown with keyboard navigation (↑↓ Enter Escape)
|
||||
* - Clicking a suggestion navigates directly to the plant or disease page
|
||||
* - Pressing Enter (when no suggestion is highlighted) navigates to the browse
|
||||
* page with the query as a search parameter
|
||||
*/
|
||||
export default function SearchSuggestions({
|
||||
placeholder = "Search plants...",
|
||||
inputClassName = "",
|
||||
wrapperClassName = "",
|
||||
formClassName = "",
|
||||
onNavigate,
|
||||
}: SearchSuggestionsProps) {
|
||||
const router = useRouter();
|
||||
const inputId = useId();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ─── Fetch suggestions with debounce ─────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
|
||||
// Empty query: don't fetch (the empty-input reset is handled in onChange).
|
||||
if (trimmed.length < 1) return;
|
||||
|
||||
// Cancel any pending debounced fetch so we only fire the latest one.
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
// Track whether this particular effect cycle is still active, so stale
|
||||
// async responses don't overwrite later (or cleared) state.
|
||||
let cancelled = false;
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/plants/suggestions?q=${encodeURIComponent(trimmed)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const items: Suggestion[] = data.suggestions ?? [];
|
||||
if (!cancelled) {
|
||||
setSuggestions(items);
|
||||
setShowDropdown(items.length > 0);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSuggestions([]);
|
||||
setShowDropdown(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// ─── Close dropdown on outside click ─────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
inputRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// ─── Navigation helpers ──────────────────────────────────────────────────
|
||||
|
||||
const navigate = useCallback(
|
||||
(href: string) => {
|
||||
setShowDropdown(false);
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
setActiveIndex(-1);
|
||||
router.push(href);
|
||||
onNavigate?.();
|
||||
},
|
||||
[router, onNavigate],
|
||||
);
|
||||
|
||||
const submitQuery = useCallback(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
navigate(`/browse?search=${encodeURIComponent(trimmed)}`);
|
||||
} else {
|
||||
navigate("/browse");
|
||||
}
|
||||
}, [query, navigate]);
|
||||
|
||||
// ─── Keyboard navigation ─────────────────────────────────────────────────
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showDropdown || suggestions.length === 0) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitQuery();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||||
navigate(suggestions[activeIndex].href);
|
||||
} else {
|
||||
submitQuery();
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setShowDropdown(false);
|
||||
setActiveIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[showDropdown, suggestions, activeIndex, submitQuery, navigate],
|
||||
);
|
||||
|
||||
// ─── Suggestion click (uses mousedown so it fires before blur) ───────────
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(href: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// ─── Input change handler: syncs query state AND resets suggestions
|
||||
// when the user clears the input (avoids doing setState in the effect).
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
|
||||
// When the input is cleared, immediately reset the suggestion state
|
||||
// instead of doing it inside the effect (which would trigger a
|
||||
// cascading-render warning).
|
||||
if (!value.trim()) {
|
||||
setSuggestions([]);
|
||||
setShowDropdown(false);
|
||||
setActiveIndex(-1);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className={`relative ${wrapperClassName}`}>
|
||||
<form
|
||||
className={formClassName}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitQuery();
|
||||
}}
|
||||
role="search"
|
||||
>
|
||||
<div className="relative">
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
{placeholder}
|
||||
</label>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setShowDropdown(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClassName}
|
||||
autoComplete="off"
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={showDropdown ? `${inputId}-listbox` : undefined}
|
||||
aria-activedescendant={
|
||||
activeIndex >= 0 ? `${inputId}-option-${activeIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Loading spinner */}
|
||||
{isLoading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2" aria-hidden="true">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-300 border-t-leaf-green-600 dark:border-zinc-600 dark:border-t-leaf-green-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id={`${inputId}-listbox`}
|
||||
role="listbox"
|
||||
aria-label="Search suggestions"
|
||||
className="absolute z-50 mt-1 w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-lg overflow-hidden"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.type}-${suggestion.id}`}
|
||||
id={`${inputId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onMouseDown={handleSuggestionClick(suggestion.href)}
|
||||
className={`w-full flex items-center gap-3 px-3.5 py-2.5 text-left transition-colors ${
|
||||
index === activeIndex
|
||||
? "bg-leaf-green-50 dark:bg-leaf-green-900/30"
|
||||
: "hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{/* Emoji */}
|
||||
<span className="text-xl shrink-0" aria-hidden="true">
|
||||
{suggestion.emoji}
|
||||
</span>
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
|
||||
{highlightMatch(suggestion.label, query)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate mt-0.5">
|
||||
{highlightMatch(suggestion.subtitle, query)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<span className="text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-500 shrink-0 ml-1">
|
||||
{suggestion.type === "plant" ? "Plant" : "Disease"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/components/SymptomChecker.test.tsx
Normal file
159
src/components/SymptomChecker.test.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import SymptomChecker from "@/components/SymptomChecker";
|
||||
|
||||
describe("SymptomChecker", () => {
|
||||
const symptoms = [
|
||||
"Dark brown spots with concentric rings on lower leaves",
|
||||
"Yellowing of leaves surrounding infected spots",
|
||||
"Premature defoliation starting from bottom of plant",
|
||||
"Dark sunken lesions on stems and fruit",
|
||||
"Wilting of severely affected branches",
|
||||
];
|
||||
|
||||
function renderChecker(customSymptoms?: string[]) {
|
||||
return render(<SymptomChecker symptoms={customSymptoms ?? symptoms} />);
|
||||
}
|
||||
|
||||
describe("initial state", () => {
|
||||
it("shows all symptoms as unchecked", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes).toHaveLength(5);
|
||||
checkboxes.forEach((cb) => expect(cb).not.toBeChecked());
|
||||
});
|
||||
|
||||
it("shows match counter as 0 of N", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText(/0 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Weak match' label when no symptoms checked", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText(/Weak match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the symptom check title", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText("Symptom Check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows guidance text when no symptoms are checked", () => {
|
||||
renderChecker();
|
||||
expect(screen.getByText(/Check symptoms you see on your plant/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("counter updates when toggling checkboxes", () => {
|
||||
it("increments counter when checking a symptom", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(screen.getByText(/1 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("decrements counter when unchecking a symptom", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(screen.getByText(/0 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates correctly when checking multiple symptoms", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]);
|
||||
fireEvent.click(checkboxes[2]);
|
||||
|
||||
expect(screen.getByText(/3 of 5 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Partial match' at 50% threshold", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]); // 2 of 5 = 40% — still weak
|
||||
fireEvent.click(checkboxes[2]); // 3 of 5 = 60% — partial
|
||||
|
||||
expect(screen.getByText(/Partial match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Strong match' at 80% threshold", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
// Check 4 of 5 = 80%
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]);
|
||||
fireEvent.click(checkboxes[2]);
|
||||
fireEvent.click(checkboxes[3]);
|
||||
|
||||
expect(screen.getByText(/Strong match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Strong match' at 100%", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
checkboxes.forEach((cb) => fireEvent.click(cb));
|
||||
|
||||
expect(screen.getByText(/5 of 5 symptoms match/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Strong match/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("visual feedback", () => {
|
||||
it("highlights checked symptom rows with green background", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
const checkedLabel = checkboxes[0].closest("label");
|
||||
expect(checkedLabel).toHaveClass("bg-leaf-green-50");
|
||||
});
|
||||
|
||||
it("shows guidance text only when no symptoms are checked", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(screen.queryByText(/Check symptoms you see on your plant/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty symptoms array", () => {
|
||||
renderChecker([]);
|
||||
expect(screen.getByText(/0 of 0 symptoms match/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles single symptom", () => {
|
||||
renderChecker(["Single symptom"]);
|
||||
expect(screen.getByText(/0 of 1 symptoms match/)).toBeInTheDocument();
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByText(/1 of 1 symptoms match/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress bar", () => {
|
||||
it("shows 0% width initially", () => {
|
||||
renderChecker();
|
||||
const progressBar = document.querySelector('[style*="width"]') as HTMLElement;
|
||||
expect(progressBar).toHaveStyle({ width: "0%" });
|
||||
});
|
||||
|
||||
it("updates width when symptoms are checked", () => {
|
||||
renderChecker();
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[0]); // 1 of 5 = 20%
|
||||
|
||||
const progressBar = document.querySelector('[style*="width"]') as HTMLElement;
|
||||
expect(progressBar).toHaveStyle({ width: "20%" });
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/components/SymptomChecker.tsx
Normal file
124
src/components/SymptomChecker.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Visual symptom checklist with severity indicators.
|
||||
*
|
||||
* For the predicted disease, shows a list of common symptoms with checkboxes.
|
||||
* User can check which symptoms they observe on their plant.
|
||||
* A match counter shows "3 of 5 symptoms match".
|
||||
* Helps user confirm/reject the diagnosis.
|
||||
*/
|
||||
export default function SymptomChecker({
|
||||
symptoms,
|
||||
}: {
|
||||
symptoms: string[];
|
||||
}) {
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggle = useCallback((index: number) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const matched = checked.size;
|
||||
const total = symptoms.length;
|
||||
const matchRatio = total > 0 ? matched / total : 0;
|
||||
|
||||
// Severity indicator color based on match ratio
|
||||
const severityColor =
|
||||
matchRatio >= 0.8
|
||||
? "text-leaf-green-700 dark:text-leaf-green-400"
|
||||
: matchRatio >= 0.5
|
||||
? "text-warning-amber-700 dark:text-warning-amber-400"
|
||||
: "text-zinc-500 dark:text-zinc-400";
|
||||
|
||||
const severityLabel =
|
||||
matchRatio >= 0.8
|
||||
? "Strong match"
|
||||
: matchRatio >= 0.5
|
||||
? "Partial match"
|
||||
: "Weak match";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Match counter header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Symptom Check
|
||||
</h4>
|
||||
<span className={`text-sm font-medium ${severityColor}`}>
|
||||
{matched} of {total} symptoms match · {severityLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Match progress bar */}
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
||||
matchRatio >= 0.8
|
||||
? "bg-leaf-green-500"
|
||||
: matchRatio >= 0.5
|
||||
? "bg-warning-amber-500"
|
||||
: "bg-zinc-400 dark:bg-zinc-500"
|
||||
}`}
|
||||
style={{ width: `${matchRatio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Symptom list */}
|
||||
<ul className="space-y-2" role="list">
|
||||
{symptoms.map((symptom, index) => {
|
||||
const isChecked = checked.has(index);
|
||||
return (
|
||||
<li key={index}>
|
||||
<label
|
||||
className={`
|
||||
flex items-start gap-3 rounded-lg px-3 py-2.5 cursor-pointer
|
||||
transition-colors duration-150
|
||||
${
|
||||
isChecked
|
||||
? "bg-leaf-green-50 dark:bg-leaf-green-950/30 border border-leaf-green-200 dark:border-leaf-green-800"
|
||||
: "bg-zinc-50 dark:bg-zinc-800/50 border border-transparent hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggle(index)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-zinc-300 dark:border-zinc-600 text-leaf-green-600 focus:ring-leaf-green-500 bg-white dark:bg-zinc-800"
|
||||
/>
|
||||
<span
|
||||
className={`text-sm leading-relaxed ${
|
||||
isChecked
|
||||
? "text-zinc-900 dark:text-zinc-100"
|
||||
: "text-zinc-600 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{symptom}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Guidance text */}
|
||||
{matched === 0 && (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 italic">
|
||||
Check symptoms you see on your plant to verify the diagnosis.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/components/TreatmentTimeline.test.tsx
Normal file
88
src/components/TreatmentTimeline.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import TreatmentTimeline, {
|
||||
treatmentStepsWithUrgency,
|
||||
type TreatmentStep,
|
||||
type UrgencyLevel,
|
||||
} from "@/components/TreatmentTimeline";
|
||||
|
||||
describe("TreatmentTimeline", () => {
|
||||
const mockSteps: TreatmentStep[] = [
|
||||
{ action: "Remove affected leaves immediately", urgency: "immediate" },
|
||||
{ action: "Apply copper fungicide within a week", urgency: "within-week" },
|
||||
{ action: "Monitor plant health regularly", urgency: "ongoing" },
|
||||
];
|
||||
|
||||
it("renders all treatment steps", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText("Remove affected leaves immediately")).toBeInTheDocument();
|
||||
expect(screen.getByText("Apply copper fungicide within a week")).toBeInTheDocument();
|
||||
expect(screen.getByText("Monitor plant health regularly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders numbered step indicators", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders urgency badges", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText("Immediate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Within a week")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ongoing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disclaimer at bottom", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
expect(screen.getByText(/Treatments may vary/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/consult a certified plant pathologist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no steps provided", () => {
|
||||
render(<TreatmentTimeline steps={[]} />);
|
||||
expect(screen.getByText(/No treatment steps available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders single step without connector line", () => {
|
||||
render(<TreatmentTimeline steps={[mockSteps[0]]} />);
|
||||
expect(screen.getByText("Remove affected leaves immediately")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses list role for timeline", () => {
|
||||
render(<TreatmentTimeline steps={mockSteps} />);
|
||||
const list = screen.getByRole("list");
|
||||
expect(list).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("treatmentStepsWithUrgency", () => {
|
||||
it("maps first step to immediate urgency", () => {
|
||||
const steps = treatmentStepsWithUrgency(["First action"]);
|
||||
expect(steps[0].urgency).toBe("immediate");
|
||||
expect(steps[0].action).toBe("First action");
|
||||
});
|
||||
|
||||
it("maps second step to within-week urgency", () => {
|
||||
const steps = treatmentStepsWithUrgency(["First", "Second"]);
|
||||
expect(steps[1].urgency).toBe("within-week");
|
||||
});
|
||||
|
||||
it("maps remaining steps to ongoing urgency", () => {
|
||||
const steps = treatmentStepsWithUrgency(["First", "Second", "Third", "Fourth"]);
|
||||
expect(steps[2].urgency).toBe("ongoing");
|
||||
expect(steps[3].urgency).toBe("ongoing");
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
const steps = treatmentStepsWithUrgency([]);
|
||||
expect(steps).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves action text", () => {
|
||||
const actions = ["Action A", "Action B", "Action C"];
|
||||
const steps = treatmentStepsWithUrgency(actions);
|
||||
expect(steps.map((s) => s.action)).toEqual(actions);
|
||||
});
|
||||
});
|
||||
155
src/components/TreatmentTimeline.tsx
Normal file
155
src/components/TreatmentTimeline.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Severity } from "@/lib/types";
|
||||
|
||||
/** Urgency level for treatment steps */
|
||||
export type UrgencyLevel = "immediate" | "within-week" | "ongoing";
|
||||
|
||||
/** A single treatment step with urgency metadata */
|
||||
export interface TreatmentStep {
|
||||
action: string;
|
||||
urgency: UrgencyLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered treatment steps displayed as a timeline.
|
||||
*
|
||||
* Each step has:
|
||||
* - Action text
|
||||
* - Urgency badge (immediate / within week / ongoing)
|
||||
* - Timeline connector
|
||||
*
|
||||
* Shows "Treatments may vary" disclaimer at bottom.
|
||||
*/
|
||||
export default function TreatmentTimeline({
|
||||
steps,
|
||||
severity,
|
||||
}: {
|
||||
steps: TreatmentStep[];
|
||||
severity?: Severity;
|
||||
}) {
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic">
|
||||
No treatment steps available.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{/* Timeline container */}
|
||||
<ol className="relative space-y-0" role="list">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1;
|
||||
const urgencyConfig = getUrgencyConfig(step.urgency);
|
||||
|
||||
return (
|
||||
<li key={index} className="relative flex gap-4">
|
||||
{/* Timeline line and node */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Node circle */}
|
||||
<div
|
||||
className={`
|
||||
flex h-8 w-8 shrink-0 items-center justify-center rounded-full
|
||||
${urgencyConfig.bg} ${urgencyConfig.ring} ring-2
|
||||
`}
|
||||
>
|
||||
<span className={`text-sm font-bold ${urgencyConfig.text}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector line (hidden for last item) */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 grow bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className={`pb-6 ${isLast ? "pb-0" : ""}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${urgencyConfig.badge}`}>
|
||||
{urgencyConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
|
||||
{step.action}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2.5">
|
||||
<svg className="mt-0.5 h-4 w-4 shrink-0 text-zinc-400 dark:text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
Treatments may vary depending on plant species, severity, and local conditions.
|
||||
Always consult a certified plant pathologist or extension service for critical decisions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat treatment array into TreatmentStep objects with urgency levels.
|
||||
*
|
||||
* Maps: first step → immediate, second step → within-week, rest → ongoing.
|
||||
*/
|
||||
export function treatmentStepsWithUrgency(treatment: string[]): TreatmentStep[] {
|
||||
return treatment.map((action, index) => ({
|
||||
action,
|
||||
urgency: getUrgencyForIndex(index),
|
||||
}));
|
||||
}
|
||||
|
||||
function getUrgencyForIndex(index: number): UrgencyLevel {
|
||||
if (index === 0) return "immediate";
|
||||
if (index === 1) return "within-week";
|
||||
return "ongoing";
|
||||
}
|
||||
|
||||
// ─── Urgency styling ─────────────────────────────────────────────────────────
|
||||
|
||||
interface UrgencyConfig {
|
||||
label: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
badge: string;
|
||||
}
|
||||
|
||||
function getUrgencyConfig(urgency: UrgencyLevel): UrgencyConfig {
|
||||
switch (urgency) {
|
||||
case "immediate":
|
||||
return {
|
||||
label: "Immediate",
|
||||
bg: "bg-red-100 dark:bg-red-900/50",
|
||||
text: "text-red-700 dark:text-red-300",
|
||||
ring: "ring-red-300 dark:ring-red-700",
|
||||
badge: "bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300",
|
||||
};
|
||||
case "within-week":
|
||||
return {
|
||||
label: "Within a week",
|
||||
bg: "bg-warning-amber-100 dark:bg-warning-amber-900/50",
|
||||
text: "text-warning-amber-700 dark:text-warning-amber-300",
|
||||
ring: "ring-warning-amber-300 dark:ring-warning-amber-700",
|
||||
badge: "bg-warning-amber-100 dark:bg-warning-amber-900/50 text-warning-amber-700 dark:text-warning-amber-300",
|
||||
};
|
||||
case "ongoing":
|
||||
return {
|
||||
label: "Ongoing",
|
||||
bg: "bg-leaf-green-100 dark:bg-leaf-green-900/50",
|
||||
text: "text-leaf-green-700 dark:text-leaf-green-300",
|
||||
ring: "ring-leaf-green-300 dark:ring-leaf-green-700",
|
||||
badge: "bg-leaf-green-100 dark:bg-leaf-green-900/50 text-leaf-green-700 dark:text-leaf-green-300",
|
||||
};
|
||||
}
|
||||
}
|
||||
0
src/data/.gitkeep
Normal file
0
src/data/.gitkeep
Normal file
3803
src/data/diseases.json
Normal file
3803
src/data/diseases.json
Normal file
File diff suppressed because it is too large
Load Diff
263
src/data/plants.json
Normal file
263
src/data/plants.json
Normal file
@@ -0,0 +1,263 @@
|
||||
[
|
||||
{
|
||||
"id": "tomato",
|
||||
"commonName": "Tomato",
|
||||
"scientificName": "Solanum lycopersicum",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained soil pH 6.0-6.8, regular feeding with balanced fertilizer, support with stakes or cages.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Bright_red_tomato_and_cross_section02.jpg/330px-Bright_red_tomato_and_cross_section02.jpg"
|
||||
},
|
||||
{
|
||||
"id": "basil",
|
||||
"commonName": "Basil",
|
||||
"scientificName": "Ocimum basilicum",
|
||||
"family": "Lamiaceae",
|
||||
"category": "herb",
|
||||
"careSummary": "Full sun (6-8h), moderate watering (keep soil moist but not soggy), warm temperatures (70-90\u00b0F), pinching flowers encourages bushier growth.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Pot_of_basil_sprouts_%28Ocimum_basilicum%29_-_20050422.jpg/330px-Pot_of_basil_sprouts_%28Ocimum_basilicum%29_-_20050422.jpg"
|
||||
},
|
||||
{
|
||||
"id": "rose",
|
||||
"commonName": "Rose",
|
||||
"scientificName": "Rosa spp.",
|
||||
"family": "Rosaceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun (6h+), deep watering 2-3 times weekly, well-drained slightly acidic soil, regular deadheading, annual pruning in late winter.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Rosa_rubiginosa_002.JPG/330px-Rosa_rubiginosa_002.JPG"
|
||||
},
|
||||
{
|
||||
"id": "monstera",
|
||||
"commonName": "Monstera",
|
||||
"scientificName": "Monstera deliciosa",
|
||||
"family": "Araceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light, water when top 2-3 inches of soil are dry, humidity 60-80%, temperatures 65-85\u00b0F, well-draining aroid mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Monstera_deliciosa_-_Wilhelma_01.jpg/330px-Monstera_deliciosa_-_Wilhelma_01.jpg"
|
||||
},
|
||||
{
|
||||
"id": "pothos",
|
||||
"commonName": "Pothos",
|
||||
"scientificName": "Epipremnum aureum",
|
||||
"family": "Araceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Low to bright indirect light, water when top inch of soil is dry, tolerates low humidity, temperatures 60-85\u00b0F, very forgiving and low-maintenance.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Epipremnum_aureum_2.jpg/330px-Epipremnum_aureum_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "snake-plant",
|
||||
"commonName": "Snake Plant",
|
||||
"scientificName": "Dracaena trifasciata",
|
||||
"family": "Asparagaceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Tolerates low to bright indirect light, water sparingly every 2-3 weeks, drought tolerant, temperatures 55-85\u00b0F, well-draining cactus mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/20210623_Hortus_botanicus_Leiden_-_Sansevieria_trifasciata_v2.jpg/330px-20210623_Hortus_botanicus_Leiden_-_Sansevieria_trifasciata_v2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "peace-lily",
|
||||
"commonName": "Peace Lily",
|
||||
"scientificName": "Spathiphyllum wallisii",
|
||||
"family": "Araceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Low to medium indirect light, keep soil consistently moist but not waterlogged, high humidity preferred, temperatures 65-80\u00b0F, sensitive to fluoride in water.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Peace_lily_-_1_-_cropped.jpg/330px-Peace_lily_-_1_-_cropped.jpg"
|
||||
},
|
||||
{
|
||||
"id": "orchid",
|
||||
"commonName": "Phalaenopsis Orchid",
|
||||
"scientificName": "Phalaenopsis amabilis",
|
||||
"family": "Orchidaceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light, water weekly by soaking roots for 15 minutes then draining completely, humidity 50-70%, temperatures 65-80\u00b0F, bark-based orchid mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Ban_Ki-Moon_Orchid_Flower_Singapore_Feb23_D72_25479.jpg/330px-Ban_Ki-Moon_Orchid_Flower_Singapore_Feb23_D72_25479.jpg"
|
||||
},
|
||||
{
|
||||
"id": "succulent",
|
||||
"commonName": "Succulent (Echeveria)",
|
||||
"scientificName": "Echeveria elegans",
|
||||
"family": "Crassulaceae",
|
||||
"category": "succulent",
|
||||
"careSummary": "Bright direct light (6h+), water only when soil is completely dry (soak and dry method), excellent drainage essential, temperatures 60-80\u00b0F, sandy well-draining mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Echeveria_A7CR_02866-90_zsp.jpg/330px-Echeveria_A7CR_02866-90_zsp.jpg"
|
||||
},
|
||||
{
|
||||
"id": "pepper",
|
||||
"commonName": "Bell Pepper",
|
||||
"scientificName": "Capsicum annuum",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent watering, warm soil (70-80\u00b0F), well-drained fertile soil pH 6.0-6.8, regular feeding with high-potassium fertilizer during fruiting.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fd/2_x_Flat_red_bell_pepper_2017_A.jpg/330px-2_x_Flat_red_bell_pepper_2017_A.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cucumber",
|
||||
"commonName": "Cucumber",
|
||||
"scientificName": "Cucumis sativus",
|
||||
"family": "Cucurbitaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent deep watering (1-2 inches/week), warm temperatures (70-95\u00b0F), trellis support recommended, mulch to retain moisture.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Cucumber_on_tomato_-_20180903_130208.jpg/330px-Cucumber_on_tomato_-_20180903_130208.jpg"
|
||||
},
|
||||
{
|
||||
"id": "squash",
|
||||
"commonName": "Summer Squash",
|
||||
"scientificName": "Cucurbita pepo",
|
||||
"family": "Cucurbitaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), deep watering (1-2 inches/week), warm temperatures (65-80\u00b0F), well-drained fertile soil, space plants 2-3 feet apart.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/CSA-Yellow-Squash.jpg/330px-CSA-Yellow-Squash.jpg"
|
||||
},
|
||||
{
|
||||
"id": "bean",
|
||||
"commonName": "Green Bean",
|
||||
"scientificName": "Phaseolus vulgaris",
|
||||
"family": "Fabaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), moderate watering (keep soil evenly moist), warm temperatures (65-80\u00b0F), trellis for pole varieties, benefits from nitrogen-fixing roots.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/-2020-08-02_Bobby_bean_harvest_%28Phaseolus_vulgaris%29%2C_Trimingham%2C_Norfolk.JPG/330px--2020-08-02_Bobby_bean_harvest_%28Phaseolus_vulgaris%29%2C_Trimingham%2C_Norfolk.JPG"
|
||||
},
|
||||
{
|
||||
"id": "strawberry",
|
||||
"commonName": "Strawberry",
|
||||
"scientificName": "Fragaria \u00d7 ananassa",
|
||||
"family": "Rosaceae",
|
||||
"category": "fruit",
|
||||
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained slightly acidic soil pH 5.5-6.5, mulch with straw to protect fruit, remove runners for larger berries.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Erdbeere_Closeup_%28126599651%29.jpeg/330px-Erdbeere_Closeup_%28126599651%29.jpeg"
|
||||
},
|
||||
{
|
||||
"id": "mint",
|
||||
"commonName": "Mint",
|
||||
"scientificName": "Mentha spp.",
|
||||
"family": "Lamiaceae",
|
||||
"category": "herb",
|
||||
"careSummary": "Partial shade to full sun, keep soil consistently moist, cool to warm temperatures (60-70\u00b0F), container growing recommended to prevent spreading, regular harvesting encourages growth.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/A_mint_leaves_in_Yuen_Long.jpg/330px-A_mint_leaves_in_Yuen_Long.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lavender",
|
||||
"commonName": "Lavender",
|
||||
"scientificName": "Lavandula angustifolia",
|
||||
"family": "Lamiaceae",
|
||||
"category": "herb",
|
||||
"careSummary": "Full sun (6-8h+), drought tolerant once established, well-drained alkaline soil pH 6.5-7.5, prune after flowering, temperatures 50-75\u00b0F.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Bee_on_Lavender_Blossom_2.jpg/330px-Bee_on_Lavender_Blossom_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lettuce",
|
||||
"commonName": "Lettuce",
|
||||
"scientificName": "Lactuca sativa",
|
||||
"family": "Asteraceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Partial shade to full sun, consistent moisture (shallow watering), cool temperatures (55-75\u00b0F), well-drained fertile soil, succession planting every 2 weeks.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Butterhead_lettuce.jpg/330px-Butterhead_lettuce.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cabbage",
|
||||
"commonName": "Cabbage",
|
||||
"scientificName": "Brassica oleracea var. capitata",
|
||||
"family": "Brassicaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent deep watering, cool to moderate temperatures (50-85\u00b0F), rich well-drained soil, side-dress with nitrogen mid-season.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Brassica_oleracea_var._capitata_%284170722993%29.jpg/330px-Brassica_oleracea_var._capitata_%284170722993%29.jpg"
|
||||
},
|
||||
{
|
||||
"id": "sunflower",
|
||||
"commonName": "Sunflower",
|
||||
"scientificName": "Helianthus annuus",
|
||||
"family": "Asteraceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun (6-8h+), moderate watering (deep but infrequent), warm temperatures (70-78\u00b0F), well-drained soil, tall varieties need staking in wind.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Close_up_sunflower_in_bloom_Mongolia.jpg/330px-Close_up_sunflower_in_bloom_Mongolia.jpg"
|
||||
},
|
||||
{
|
||||
"id": "fiddle-leaf-fig",
|
||||
"commonName": "Fiddle Leaf Fig",
|
||||
"scientificName": "Ficus lyrata",
|
||||
"family": "Moraceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light (no direct harsh sun), water when top 1-2 inches of soil are dry, humidity 40-60%, temperatures 60-75\u00b0F, avoid moving once placed.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c2/Ficus_lyrata_8zz.jpg/330px-Ficus_lyrata_8zz.jpg"
|
||||
},
|
||||
{
|
||||
"id": "aloe-vera",
|
||||
"commonName": "Aloe Vera",
|
||||
"scientificName": "Aloe barbadensis miller",
|
||||
"family": "Asphodelaceae",
|
||||
"category": "succulent",
|
||||
"careSummary": "Bright indirect to direct light, water deeply every 2-3 weeks, allow soil to dry completely between waterings, temperatures 55-80\u00b0F, well-draining cactus mix.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Aloe_Vera.jpg/330px-Aloe_Vera.jpg"
|
||||
},
|
||||
{
|
||||
"id": "jasmine",
|
||||
"commonName": "Jasmine",
|
||||
"scientificName": "Jasminum officinale",
|
||||
"family": "Oleaceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun to partial shade (6h+), regular watering (keep soil moist), warm temperatures (60-75\u00b0F), trellis support for climbing varieties, prune after flowering.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Flowers_White_Jasmine.jpg/330px-Flowers_White_Jasmine.jpg"
|
||||
},
|
||||
{
|
||||
"id": "chili",
|
||||
"commonName": "Chili Pepper",
|
||||
"scientificName": "Capsicum chinense",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (8h+), consistent watering (not waterlogged), warm temperatures (70-85\u00b0F), well-drained fertile soil, high-potassium fertilizer during fruiting.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/77/A_Fat_Red_Cayenne_Pepper.jpg/330px-A_Fat_Red_Cayenne_Pepper.jpg"
|
||||
},
|
||||
{
|
||||
"id": "eggplant",
|
||||
"commonName": "Eggplant",
|
||||
"scientificName": "Solanum melongena",
|
||||
"family": "Solanaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), consistent deep watering, warm temperatures (70-85\u00b0F), well-drained fertile soil, mulch to retain moisture, stake or cage for support.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/Eggplant_01.jpg/330px-Eggplant_01.jpg"
|
||||
},
|
||||
{
|
||||
"id": "spinach",
|
||||
"commonName": "Spinach",
|
||||
"scientificName": "Spinacia oleracea",
|
||||
"family": "Amaranthaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Partial shade to full sun, consistent moisture, cool temperatures (50-70\u00b0F), well-drained fertile soil, bolt quickly in heat \u2014 plant in spring or fall.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Spinach_leaves.jpg/330px-Spinach_leaves.jpg"
|
||||
},
|
||||
{
|
||||
"id": "fern",
|
||||
"commonName": "Boston Fern",
|
||||
"scientificName": "Nephrolepis exaltata",
|
||||
"family": "Nephrolepidaceae",
|
||||
"category": "houseplant",
|
||||
"careSummary": "Bright indirect light, keep soil consistently moist (never dry out), high humidity 50-80%, temperatures 60-75\u00b0F, regular misting or humidity tray recommended.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5f/Boston_Fern_%28Nephrolepis_exaltata%29.jpg/330px-Boston_Fern_%28Nephrolepis_exaltata%29.jpg"
|
||||
},
|
||||
{
|
||||
"id": "daisy",
|
||||
"commonName": "Shasta Daisy",
|
||||
"scientificName": "Leucanthemum \u00d7 superbum",
|
||||
"family": "Asteraceae",
|
||||
"category": "flower",
|
||||
"careSummary": "Full sun (6h+), moderate watering, cool to moderate temperatures (60-75\u00b0F), well-drained soil, deadhead spent blooms, divide clumps every 3-4 years.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/Daisy_flower_clicked_by_somya.jpg/330px-Daisy_flower_clicked_by_somya.jpg"
|
||||
},
|
||||
{
|
||||
"id": "zucchini",
|
||||
"commonName": "Zucchini",
|
||||
"scientificName": "Cucurbita pepo var. cylindrica",
|
||||
"family": "Cucurbitaceae",
|
||||
"category": "vegetable",
|
||||
"careSummary": "Full sun (6-8h), deep consistent watering (1-2 inches/week), warm temperatures (65-80\u00b0F), well-drained fertile soil, harvest when 6-8 inches for best flavor.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Courgette_Cucurbita_pepo_2.jpg/330px-Courgette_Cucurbita_pepo_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cactus",
|
||||
"commonName": "Cactus (Prickly Pear)",
|
||||
"scientificName": "Opuntia ficus-indica",
|
||||
"family": "Cactaceae",
|
||||
"category": "succulent",
|
||||
"careSummary": "Full sun (8h+), water sparingly (every 2-4 weeks in growing season, almost none in winter), extremely well-draining soil, temperatures 55-100\u00b0F, excellent heat/drought tolerance.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Cactus_%28Opuntia_phaeacantha%29_flower.JPG/330px-Cactus_%28Opuntia_phaeacantha%29_flower.JPG"
|
||||
}
|
||||
]
|
||||
169
src/data/plants.test.ts
Normal file
169
src/data/plants.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
plants,
|
||||
getPlantById,
|
||||
getPlantsByCategory,
|
||||
getFeaturedPlants,
|
||||
getAllDiseaseTypes,
|
||||
searchPlants,
|
||||
} from "./plants";
|
||||
|
||||
describe("plants data", () => {
|
||||
it("exports a non-empty array of plants", () => {
|
||||
expect(Array.isArray(plants)).toBe(true);
|
||||
expect(plants.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each plant has required fields", () => {
|
||||
for (const plant of plants) {
|
||||
expect(plant).toHaveProperty("id");
|
||||
expect(plant).toHaveProperty("commonName");
|
||||
expect(plant).toHaveProperty("scientificName");
|
||||
expect(plant).toHaveProperty("family");
|
||||
expect(plant).toHaveProperty("category");
|
||||
expect(plant).toHaveProperty("description");
|
||||
expect(plant).toHaveProperty("careSummary");
|
||||
expect(plant).toHaveProperty("imageEmoji");
|
||||
expect(plant).toHaveProperty("diseases");
|
||||
expect(Array.isArray(plant.diseases)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("each plant has at least one disease", () => {
|
||||
for (const plant of plants) {
|
||||
expect(plant.diseases.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("each disease has required fields", () => {
|
||||
for (const plant of plants) {
|
||||
for (const disease of plant.diseases) {
|
||||
expect(disease).toHaveProperty("id");
|
||||
expect(disease).toHaveProperty("name");
|
||||
expect(disease).toHaveProperty("type");
|
||||
expect(disease).toHaveProperty("description");
|
||||
expect(disease).toHaveProperty("symptoms");
|
||||
expect(disease).toHaveProperty("causes");
|
||||
expect(disease).toHaveProperty("treatmentSteps");
|
||||
expect(disease).toHaveProperty("preventionTips");
|
||||
expect(disease).toHaveProperty("severity");
|
||||
expect(Array.isArray(disease.symptoms)).toBe(true);
|
||||
expect(Array.isArray(disease.treatmentSteps)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantById", () => {
|
||||
it("returns plant when id exists", () => {
|
||||
const plant = getPlantById("tomato");
|
||||
expect(plant).toBeDefined();
|
||||
expect(plant!.commonName).toBe("Tomato");
|
||||
});
|
||||
|
||||
it("returns undefined when id does not exist", () => {
|
||||
const plant = getPlantById("nonexistent");
|
||||
expect(plant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is case sensitive", () => {
|
||||
const plant = getPlantById("Tomato");
|
||||
expect(plant).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantsByCategory", () => {
|
||||
it("returns plants in the vegetables category", () => {
|
||||
const veggies = getPlantsByCategory("vegetables");
|
||||
expect(veggies.length).toBeGreaterThan(0);
|
||||
expect(veggies.every((p) => p.category === "vegetables")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plants in the herbs category", () => {
|
||||
const herbs = getPlantsByCategory("herbs");
|
||||
expect(herbs.length).toBeGreaterThan(0);
|
||||
expect(herbs.every((p) => p.category === "herbs")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plants in the flowers category", () => {
|
||||
const flowers = getPlantsByCategory("flowers");
|
||||
expect(flowers.length).toBeGreaterThan(0);
|
||||
expect(flowers.every((p) => p.category === "flowers")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plants in the houseplants category", () => {
|
||||
const houseplants = getPlantsByCategory("houseplants");
|
||||
expect(houseplants.length).toBeGreaterThan(0);
|
||||
expect(houseplants.every((p) => p.category === "houseplants")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeaturedPlants", () => {
|
||||
it("returns a subset of plants", () => {
|
||||
const featured = getFeaturedPlants();
|
||||
expect(featured.length).toBeGreaterThan(0);
|
||||
expect(featured.length).toBeLessThanOrEqual(plants.length);
|
||||
});
|
||||
|
||||
it("returns expected featured plants", () => {
|
||||
const featured = getFeaturedPlants();
|
||||
const ids = featured.map((p) => p.id);
|
||||
expect(ids).toContain("tomato");
|
||||
expect(ids).toContain("basil");
|
||||
expect(ids).toContain("rose");
|
||||
expect(ids).toContain("monstera");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllDiseaseTypes", () => {
|
||||
it("returns unique disease types", () => {
|
||||
const types = getAllDiseaseTypes();
|
||||
expect(types.length).toBe(new Set(types).size);
|
||||
});
|
||||
|
||||
it("includes expected disease types", () => {
|
||||
const types = getAllDiseaseTypes();
|
||||
expect(types).toContain("fungal");
|
||||
expect(types).toContain("bacterial");
|
||||
expect(types).toContain("physiological");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchPlants", () => {
|
||||
it("returns all plants for empty query", () => {
|
||||
const results = searchPlants("");
|
||||
expect(results).toEqual(plants);
|
||||
});
|
||||
|
||||
it("returns all plants for whitespace query", () => {
|
||||
const results = searchPlants(" ");
|
||||
expect(results).toEqual(plants);
|
||||
});
|
||||
|
||||
it("finds plants by common name", () => {
|
||||
const results = searchPlants("tomato");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].commonName).toBe("Tomato");
|
||||
});
|
||||
|
||||
it("finds plants by scientific name", () => {
|
||||
const results = searchPlants("solanum");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds plants by disease name", () => {
|
||||
const results = searchPlants("root rot");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
const lower = searchPlants("tomato");
|
||||
const upper = searchPlants("TOMATO");
|
||||
expect(lower.length).toBe(upper.length);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const results = searchPlants("xyznonexistent123");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
727
src/data/plants.ts
Normal file
727
src/data/plants.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* Static plant and disease knowledge base.
|
||||
*
|
||||
* In production this data could be served from an API, database, or CMS.
|
||||
* For now it provides the content powering the browse, detail, and homepage views.
|
||||
*/
|
||||
|
||||
export interface Disease {
|
||||
id: string;
|
||||
name: string;
|
||||
scientificName?: string;
|
||||
type: "fungal" | "bacterial" | "viral" | "pest" | "physiological";
|
||||
description: string;
|
||||
symptoms: string[];
|
||||
causes: string[];
|
||||
treatmentSteps: string[];
|
||||
preventionTips: string[];
|
||||
severity: "low" | "moderate" | "high" | "critical";
|
||||
commonName?: string;
|
||||
}
|
||||
|
||||
export interface Plant {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName: string;
|
||||
family: string;
|
||||
category: "vegetables" | "herbs" | "houseplants" | "flowers";
|
||||
description: string;
|
||||
careSummary: string;
|
||||
imageEmoji: string;
|
||||
diseases: Disease[];
|
||||
}
|
||||
|
||||
export const plants: Plant[] = [
|
||||
{
|
||||
id: "tomato",
|
||||
commonName: "Tomato",
|
||||
scientificName: "Solanum lycopersicum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetables",
|
||||
description:
|
||||
"One of the most popular garden vegetables, tomatoes are grown worldwide. They are susceptible to several diseases that affect leaves, stems, and fruit.",
|
||||
careSummary: "Full sun, well-drained soil, regular watering at base. Stake or cage for support.",
|
||||
imageEmoji: "🍅",
|
||||
diseases: [
|
||||
{
|
||||
id: "tomato-late-blight",
|
||||
name: "Late Blight",
|
||||
scientificName: "Phytophthora infestans",
|
||||
type: "fungal",
|
||||
commonName: "Late blight",
|
||||
description:
|
||||
"A devastating water mold disease that caused the Irish Potato Famine. It spreads rapidly in cool, wet weather and can destroy a crop within days.",
|
||||
symptoms: [
|
||||
"Water-soaked, dark green to brown lesions on leaves",
|
||||
"White fuzzy mold on leaf undersides in humid conditions",
|
||||
"Dark, firm, greasy-looking spots on stems",
|
||||
"Brown, firm, irregular spots on green fruit that enlarge rapidly",
|
||||
],
|
||||
causes: [
|
||||
"Phytophthora infestans spores spread by wind and rain",
|
||||
"Cool temperatures (60-70°F / 15-21°C) with high humidity",
|
||||
"Overhead watering that keeps foliage wet for extended periods",
|
||||
"Infected volunteer plants or potato cull piles nearby",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected plant material immediately (do not compost)",
|
||||
"Apply copper-based fungicide or chlorothalonil at first sign",
|
||||
"Improve airflow by spacing plants and pruning lower branches",
|
||||
"Switch to drip irrigation to keep foliage dry",
|
||||
"Apply organic biofungicides containing Bacillus subtilis as preventive measure",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant resistant varieties (e.g., 'Mountain Magic', 'Defiant')",
|
||||
"Use mulch to prevent soil splash onto lower leaves",
|
||||
"Space plants adequately for air circulation",
|
||||
"Avoid overhead watering; water at soil level in the morning",
|
||||
"Rotate crops — avoid planting tomatoes in same spot for 3-4 years",
|
||||
],
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "tomato-early-blight",
|
||||
name: "Early Blight",
|
||||
scientificName: "Alternaria solani",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A common fungal disease that appears as distinctive target-like spots on older leaves first, gradually moving upward.",
|
||||
symptoms: [
|
||||
"Dark brown spots with concentric rings (target-like pattern) on lower leaves",
|
||||
"Yellowing of tissue surrounding leaf spots",
|
||||
"Leaf drop starting from the bottom of the plant",
|
||||
"Dark, sunken lesions on stems and fruit near the stem end",
|
||||
],
|
||||
causes: [
|
||||
"Alternaria solani fungus survives in soil and plant debris",
|
||||
"Warm, humid weather (75-85°F / 24-29°C) with frequent rain or dew",
|
||||
"Poor air circulation and dense foliage",
|
||||
"Overhead irrigation that wets leaves",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Prune lower branches and any infected leaves promptly",
|
||||
"Apply copper fungicide or sulfur-based fungicide every 7-14 days",
|
||||
"Remove and dispose of fallen infected leaves",
|
||||
"Apply a layer of organic mulch to prevent soil splash",
|
||||
"Use calcium spray to strengthen plant cell walls",
|
||||
],
|
||||
preventionTips: [
|
||||
"Choose early-blight-resistant varieties when available",
|
||||
"Rotate crops with non-solanaceous plants (beans, corn, lettuce)",
|
||||
"Space plants for good airflow (24-36 inches apart)",
|
||||
"Water at soil level in the morning using drip irrigation",
|
||||
"Remove plant debris at end of season",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
{
|
||||
id: "tomato-blossom-end-rot",
|
||||
name: "Blossom End Rot",
|
||||
type: "physiological",
|
||||
description:
|
||||
"A common physiological disorder caused by calcium deficiency in the fruit, often due to inconsistent watering — not a disease or pest.",
|
||||
symptoms: [
|
||||
"Water-soaked spot at the blossom end (bottom) of the fruit",
|
||||
"Spot enlarges and turns dark brown to black, leathery",
|
||||
"Fruit ripens prematurely on the affected area",
|
||||
"Affected area may become sunken and flat",
|
||||
],
|
||||
causes: [
|
||||
"Calcium deficiency in developing fruit due to inconsistent watering",
|
||||
"Extreme fluctuations in soil moisture (drought followed by heavy water)",
|
||||
"Over-fertilization with nitrogen, which interferes with calcium uptake",
|
||||
"Root damage or compacted soil limiting nutrient absorption",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Maintain consistent soil moisture — water regularly and deeply",
|
||||
"Remove affected fruit so the plant directs energy to healthy fruit",
|
||||
"Apply calcium spray (calcium chloride or calcium nitrate) to foliage",
|
||||
"Mulch around plants to retain soil moisture",
|
||||
"Test soil pH — aim for 6.5-7.0 for optimal calcium availability",
|
||||
],
|
||||
preventionTips: [
|
||||
"Water consistently — 1-2 inches per week, never let soil dry out completely",
|
||||
"Avoid excessive nitrogen fertilizers (use balanced 5-10-10 or similar)",
|
||||
"Incorporate lime or gypsum into soil before planting if calcium is low",
|
||||
"Plant in well-drained soil with good organic matter content",
|
||||
"Use drip irrigation with timer for consistent moisture",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basil",
|
||||
commonName: "Basil",
|
||||
scientificName: "Ocimum basilicum",
|
||||
family: "Lamiaceae",
|
||||
category: "herbs",
|
||||
description:
|
||||
"A fragrant culinary herb beloved in kitchens worldwide. Basil is relatively easy to grow but can be affected by several fungal diseases in humid conditions.",
|
||||
careSummary: "Full sun (6+ hours), well-drained soil, regular pruning to encourage bushy growth. Protect from frost.",
|
||||
imageEmoji: "🌿",
|
||||
diseases: [
|
||||
{
|
||||
id: "basil-downy-mildew",
|
||||
name: "Downy Mildew",
|
||||
scientificName: "Peronospora belbahrii",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A devastating disease that has become the most serious threat to basil production worldwide. It spreads quickly and can wipe out a crop within days.",
|
||||
symptoms: [
|
||||
"Pale green to yellow angular patches between leaf veins",
|
||||
"Fuzzy gray-purple sporulation on leaf undersides",
|
||||
"Leaves curl and distort as disease progresses",
|
||||
"Leaves eventually turn brown and drop off",
|
||||
"Disease progresses from lower leaves upward",
|
||||
],
|
||||
causes: [
|
||||
"Peronospora belbahrii spores carried by wind and splashing water",
|
||||
"High humidity (>85%) and moderate temperatures (60-80°F)",
|
||||
"Overhead watering that keeps foliage wet overnight",
|
||||
"Overcrowding reducing air circulation around plants",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected plants immediately (do not compost)",
|
||||
"Apply copper-based fungicide as a protective measure",
|
||||
"Improve air circulation by increasing plant spacing",
|
||||
"Switch to drip irrigation to avoid wetting leaves",
|
||||
"Apply potassium phosphite fungicide if available",
|
||||
],
|
||||
preventionTips: [
|
||||
"Choose resistant varieties (e.g., 'Prospera', 'Rutgers Obsession')",
|
||||
"Space plants 8-12 inches apart for good airflow",
|
||||
"Water at soil level in the morning",
|
||||
"Avoid high-nitrogen fertilizers that promote lush, susceptible growth",
|
||||
"Use preventative fungicide applications during high-risk periods",
|
||||
],
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "basil-fusarium-wilt",
|
||||
name: "Fusarium Wilt",
|
||||
scientificName: "Fusarium oxysporum f. sp. basilici",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A soil-borne fungal disease that causes sudden wilting and death of basil plants. The fungus enters through the roots and blocks water-conducting tissue.",
|
||||
symptoms: [
|
||||
"Sudden wilting of leaves and shoots, often on one side first",
|
||||
"Stunted growth and yellowing of lower leaves",
|
||||
"Brown vascular discoloration visible when cutting open the stem",
|
||||
"Dark brown streaks on stems",
|
||||
"Plant eventually collapses and dies",
|
||||
],
|
||||
causes: [
|
||||
"Fusarium fungus survives in soil for years",
|
||||
"Contaminated seeds or transplants",
|
||||
"Warm soil temperatures (75-85°F / 24-29°C)",
|
||||
"Overwatering and poor soil drainage",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected plants and surrounding soil",
|
||||
"Do not plant basil in same location for 3-5 years",
|
||||
"Solarize soil (cover with clear plastic for 4-6 weeks in hot weather)",
|
||||
"Use raised beds with fresh potting mix for new plantings",
|
||||
"Apply beneficial Trichoderma fungi to soil as biological control",
|
||||
],
|
||||
preventionTips: [
|
||||
"Use disease-free seeds and transplants from reputable sources",
|
||||
"Plant resistant varieties (some sweet basil cultivars have tolerance)",
|
||||
"Rotate crops — avoid planting basil in same spot for 3+ years",
|
||||
"Improve drainage with raised beds or organic matter",
|
||||
"Sterilize pots and tools with 10% bleach solution",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rose",
|
||||
commonName: "Rose",
|
||||
scientificName: "Rosa spp.",
|
||||
family: "Rosaceae",
|
||||
category: "flowers",
|
||||
description:
|
||||
"The classic garden flower prized for its beauty and fragrance. Roses are heavy feeders and can be susceptible to several common diseases, especially in humid climates.",
|
||||
careSummary: "Full sun (6+ hours), rich well-drained soil, regular fertilizing, prune in early spring.",
|
||||
imageEmoji: "🌹",
|
||||
diseases: [
|
||||
{
|
||||
id: "rose-black-spot",
|
||||
name: "Black Spot",
|
||||
scientificName: "Diplocarpon rosae",
|
||||
type: "fungal",
|
||||
description:
|
||||
"The most common and damaging disease of roses worldwide. It causes unsightly black spots on leaves and can lead to complete defoliation.",
|
||||
symptoms: [
|
||||
"Circular black spots with fringed or feathery margins on leaves",
|
||||
"Yellowing of leaf tissue surrounding spots",
|
||||
"Premature leaf drop — plant may become nearly leafless",
|
||||
"Small red or purple spots on young canes",
|
||||
],
|
||||
causes: [
|
||||
"Fungus survives on infected fallen leaves and canes",
|
||||
"Warm, wet weather (70-80°F) with extended leaf wetness (7+ hours)",
|
||||
"Overhead watering that keeps foliage wet",
|
||||
"Overcrowded planting with poor air circulation",
|
||||
"Susceptible rose varieties",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy all infected leaves (both on plant and fallen)",
|
||||
"Apply sulfur or neem oil fungicide every 7-14 days during growing season",
|
||||
"Prune to improve air circulation — remove crossing branches",
|
||||
"Mulch around base to prevent soil splash",
|
||||
"Apply fungicide containing chlorothalonil or myclobutanil for severe cases",
|
||||
],
|
||||
preventionTips: [
|
||||
"Choose disease-resistant varieties (e.g., 'Knock Out', 'Drift', 'Easy Elegance')",
|
||||
"Water at soil level in the morning",
|
||||
"Space roses adequately (2-3 feet apart for most varieties)",
|
||||
"Remove and dispose of fallen leaves in fall",
|
||||
"Apply dormant spray (lime sulfur) in late winter",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
id: "rose-powdery-mildew",
|
||||
name: "Powdery Mildew",
|
||||
scientificName: "Podosphaera pannosa",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A common fungal disease that appears as a white powdery coating on leaves, stems, and buds. Unlike most fungal diseases, it thrives in warm, dry conditions with high humidity at night.",
|
||||
symptoms: [
|
||||
"White to gray powdery coating on leaves, stems, and buds",
|
||||
"Leaves may curl, twist, or become distorted",
|
||||
"New growth appears stunted and dwarfed",
|
||||
"Buds may fail to open or produce distorted flowers",
|
||||
"Purple-red discoloration on leaves in some varieties",
|
||||
],
|
||||
causes: [
|
||||
"Fungus thrives in warm days (70-85°F) and cool, humid nights",
|
||||
"Poor air circulation around plants",
|
||||
"New, succulent growth is most susceptible",
|
||||
"Shaded conditions reduce air movement and drying",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Prune out infected shoots and buds promptly",
|
||||
"Apply sulfur-based fungicide or neem oil every 7-14 days",
|
||||
"Use potassium bicarbonate spray to disrupt fungal growth",
|
||||
"Improve air circulation by thinning crowded growth",
|
||||
"Apply horticultural oil (dormant or summer weight) as directed",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant in full sun with good airflow",
|
||||
"Avoid high-nitrogen fertilizers that promote soft, susceptible growth",
|
||||
"Water at soil level, not overhead",
|
||||
"Prune annually to keep center of plant open",
|
||||
"Choose resistant cultivars when available",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "monstera",
|
||||
commonName: "Monstera",
|
||||
scientificName: "Monstera deliciosa",
|
||||
family: "Araceae",
|
||||
category: "houseplants",
|
||||
description:
|
||||
"The iconic Swiss cheese plant with its distinctive split leaves. A popular low-maintenance houseplant native to tropical Central America.",
|
||||
careSummary: "Bright indirect light, moderate watering (let top inch dry), well-draining soil, high humidity preferred.",
|
||||
imageEmoji: "🪴",
|
||||
diseases: [
|
||||
{
|
||||
id: "monstera-root-rot",
|
||||
name: "Root Rot",
|
||||
scientificName: "Various (Pythium, Phytophthora, Fusarium spp.)",
|
||||
type: "fungal",
|
||||
description:
|
||||
"The most common killer of monsteras and other houseplants. Root rot is caused by overwatering and fungal pathogens thriving in waterlogged soil.",
|
||||
symptoms: [
|
||||
"Yellowing leaves starting from the bottom of the plant",
|
||||
"Wilting despite moist soil (roots can't uptake water)",
|
||||
"Soft, mushy stems near the soil line",
|
||||
"Brown, mushy, or black roots (healthy roots are firm and white/tan)",
|
||||
"Foul, musty smell coming from the soil",
|
||||
"Stunted growth and dropping leaves",
|
||||
],
|
||||
causes: [
|
||||
"Overwatering — potting soil stays soggy for extended periods",
|
||||
"Poor drainage — pot lacks drainage holes or soil is too dense",
|
||||
"Pots that are too large relative to the root system",
|
||||
"Low light reducing water uptake by the plant",
|
||||
"Prolonged exposure to cold, damp conditions",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove plant from pot and cut away ALL mushy, brown roots",
|
||||
"Trim affected leaves and stems — reduce the plant's transpiration demand",
|
||||
"Wash remaining healthy roots with diluted hydrogen peroxide (1:3 with water)",
|
||||
"Repot in fresh, well-draining soil in a clean pot with drainage holes",
|
||||
"Do not water for 5-7 days after repotting, then water sparingly",
|
||||
"Treat with fungicide (copper-based or biological) if severe",
|
||||
],
|
||||
preventionTips: [
|
||||
"Water only when the top 1-2 inches of soil is dry",
|
||||
"Use pots with drainage holes and well-draining aroid mix",
|
||||
"Choose a pot only 1-2 inches larger than the root ball",
|
||||
"Provide bright, indirect light for proper water uptake",
|
||||
"Reduce watering in winter when growth slows",
|
||||
],
|
||||
severity: "critical",
|
||||
},
|
||||
{
|
||||
id: "monstera-leaf-spot",
|
||||
name: "Bacterial Leaf Spot",
|
||||
scientificName: "Pseudomonas cichorii",
|
||||
type: "bacterial",
|
||||
description:
|
||||
"A common bacterial infection that causes unsightly spots on monstera leaves. It thrives in warm, humid conditions with poor air circulation.",
|
||||
symptoms: [
|
||||
"Dark brown to black spots with yellow halos on leaves",
|
||||
"Spots may be water-soaked initially, turning dry and crispy",
|
||||
"Irregularly shaped spots, often along leaf edges or veins",
|
||||
"Spots may merge to form large dead areas on leaves",
|
||||
"Leaves may yellow and drop prematurely",
|
||||
],
|
||||
causes: [
|
||||
"Bacteria enters through wounds or natural openings (stomata)",
|
||||
"Overhead watering splashing bacteria onto leaves",
|
||||
"High humidity combined with stagnant air",
|
||||
"Using contaminated pruning tools",
|
||||
"Overcrowding with poor air circulation",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Isolate affected plant from other houseplants",
|
||||
"Cut off severely affected leaves with sterilized scissors",
|
||||
"Apply copper-based bactericide to remaining leaves",
|
||||
"Improve air circulation with a fan on low setting",
|
||||
"Allow soil to dry slightly between waterings",
|
||||
],
|
||||
preventionTips: [
|
||||
"Avoid overhead watering — water at soil level",
|
||||
"Maintain good air circulation around plants",
|
||||
"Sterilize pruning tools between plants",
|
||||
"Quarantine new plants for 2-3 weeks before introducing",
|
||||
"Keep leaves dry, especially in humid conditions",
|
||||
],
|
||||
severity: "moderate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "snake-plant",
|
||||
commonName: "Snake Plant",
|
||||
scientificName: "Dracaena trifasciata (formerly Sansevieria)",
|
||||
family: "Asparagaceae",
|
||||
category: "houseplants",
|
||||
description:
|
||||
"One of the hardiest houseplants, snake plants are almost impossible to kill. They're known for their upright, sword-like leaves and air-purifying ability.",
|
||||
careSummary: "Low to bright indirect light, infrequent watering (let soil dry completely), drought-tolerant.",
|
||||
imageEmoji: "🌵",
|
||||
diseases: [
|
||||
{
|
||||
id: "snake-plant-overwatering",
|
||||
name: "Overwatering / Root Rot",
|
||||
type: "physiological",
|
||||
description:
|
||||
"Snake plants are succulents and the #1 cause of death is overwatering. They store water in their leaves and need the soil to dry completely between waterings.",
|
||||
symptoms: [
|
||||
"Leaves turning yellow, soft, or mushy at the base",
|
||||
"Leaves falling over or flopping",
|
||||
"Brown, mushy roots when checked",
|
||||
"Foul smell from the soil indicating rot",
|
||||
"Leaf tips turning brown while base is yellow",
|
||||
],
|
||||
causes: [
|
||||
"Watering too frequently (snake plants need infrequent watering)",
|
||||
"Soil that doesn't drain well or is too compacted",
|
||||
"Pot without drainage holes trapping water at bottom",
|
||||
"Cold temperatures combined with wet soil",
|
||||
"Pot too large causing soil to stay wet too long",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Stop watering immediately and let soil dry completely",
|
||||
"Remove plant, cut away all rotted roots and affected leaves",
|
||||
"Repot in fresh succulent/cactus mix in a pot with drainage holes",
|
||||
"Wait 2-3 weeks before first light watering",
|
||||
"Place in bright indirect light to encourage recovery",
|
||||
],
|
||||
preventionTips: [
|
||||
"Water only when soil is completely dry (every 2-6 weeks depending on conditions)",
|
||||
"Use well-draining succulent or cactus potting mix",
|
||||
"Choose a pot only slightly larger than the root ball with drainage holes",
|
||||
"Water even less in winter (monthly or less)",
|
||||
"When in doubt, don't water — snake plants tolerate drought far better than overwatering",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pepper",
|
||||
commonName: "Bell Pepper",
|
||||
scientificName: "Capsicum annuum",
|
||||
family: "Solanaceae",
|
||||
category: "vegetables",
|
||||
description:
|
||||
"Colorful sweet peppers are a garden favorite. Like tomatoes, they're in the nightshade family and share some similar disease vulnerabilities.",
|
||||
careSummary: "Full sun, warm soil (65°F+), consistent moisture, stake for support, heavy feeder.",
|
||||
imageEmoji: "🫑",
|
||||
diseases: [
|
||||
{
|
||||
id: "pepper-bacterial-spot",
|
||||
name: "Bacterial Leaf Spot",
|
||||
scientificName: "Xanthomonas campestris pv. vesicatoria",
|
||||
type: "bacterial",
|
||||
description:
|
||||
"A serious bacterial disease affecting peppers in warm, humid weather. It can cause significant defoliation and fruit blemishing, reducing yield and marketability.",
|
||||
symptoms: [
|
||||
"Small, water-soaked spots on leaves that turn brown to black",
|
||||
"Spots have a distinctive yellow halo",
|
||||
"Leaf spots may fall out, creating a 'shot-hole' appearance",
|
||||
"Raised, scabby spots on fruit with green to brown discoloration",
|
||||
"Severe defoliation starting from the bottom of the plant",
|
||||
],
|
||||
causes: [
|
||||
"Bacteria spreads through splashing water and rain",
|
||||
"Warm temperatures (75-95°F / 24-35°C) with high humidity",
|
||||
"Overhead irrigation that keeps foliage wet",
|
||||
"Working with wet plants spreads bacteria",
|
||||
"Contaminated seeds or transplants",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy severely infected leaves and plants",
|
||||
"Apply copper-based bactericide (copper hydroxide or copper sulfate)",
|
||||
"Use streptomycin sulfate bactericide if available (check local regulations)",
|
||||
"Switch to drip irrigation immediately",
|
||||
"Avoid working with plants when they are wet",
|
||||
],
|
||||
preventionTips: [
|
||||
"Use disease-free seeds (hot water treatment at 125°F/52°C for 30 minutes)",
|
||||
"Choose resistant varieties (e.g., 'Boynton Bell', 'Summer Sweet')",
|
||||
"Rotate crops on a 3-year cycle",
|
||||
"Use plastic mulch to prevent soil splash",
|
||||
"Water in the morning with drip irrigation only",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
id: "pepper-sunscald",
|
||||
name: "Sunscald",
|
||||
type: "physiological",
|
||||
description:
|
||||
"Not a disease but a physiological disorder caused by excessive sun exposure on pepper fruit, usually after defoliation from other issues.",
|
||||
symptoms: [
|
||||
"Soft, pale white or yellow patches on the sun-exposed side of fruit",
|
||||
"Affected area becomes papery and sunken over time",
|
||||
"Secondary fungal infection may develop on sunscalded areas (gray or black mold)",
|
||||
"Occurs most often on fruit exposed after leaf loss",
|
||||
],
|
||||
causes: [
|
||||
"Intense direct sunlight on fruit surfaces",
|
||||
"Sudden loss of leaf cover (from pruning, disease, or pest damage)",
|
||||
"Fruit oriented toward the south or west side of the plant",
|
||||
"Extremely hot weather combined with low humidity",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Provide shade cloth (30-40%) during extreme heat events",
|
||||
"Leave adequate foliage cover — avoid over-pruning",
|
||||
"Harvest affected fruit promptly (they won't recover)",
|
||||
"Treat secondary fungal infections if they appear",
|
||||
"Use kaolin clay spray (Surround WP) to reflect excess light",
|
||||
],
|
||||
preventionTips: [
|
||||
"Maintain healthy foliage cover — don't over-prune",
|
||||
"Use shade cloth during heat waves (90°F+/32°C+)",
|
||||
"Plant in location with some afternoon shade in hot climates",
|
||||
"Space plants so they provide mutual leaf cover",
|
||||
"Control defoliating diseases and pests promptly",
|
||||
],
|
||||
severity: "low",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lavender",
|
||||
commonName: "Lavender",
|
||||
scientificName: "Lavandula angustifolia",
|
||||
family: "Lamiaceae",
|
||||
category: "herbs",
|
||||
description:
|
||||
"A beloved aromatic herb from the Mediterranean. Lavender is drought-tolerant and thrives in poor, well-drained soil. Most problems come from too much water or humidity.",
|
||||
careSummary: "Full sun, very well-drained alkaline soil, minimal water once established, prune after flowering.",
|
||||
imageEmoji: "💜",
|
||||
diseases: [
|
||||
{
|
||||
id: "lavender-root-rot",
|
||||
name: "Root Rot / Shab",
|
||||
scientificName: "Phytophthora spp. / Various fungi",
|
||||
type: "fungal",
|
||||
description:
|
||||
"The most common cause of lavender death. Lavender evolved in dry, rocky Mediterranean slopes and cannot tolerate wet roots. Root rot can kill a plant within days.",
|
||||
symptoms: [
|
||||
"Leaves turning gray or yellow and wilting",
|
||||
"Plant looks like it needs water but soil is moist",
|
||||
"Stems near ground become brown and woody",
|
||||
"Base of plant turns black and mushy",
|
||||
"Sudden collapse of entire plant in severe cases",
|
||||
"Foul smell from soil",
|
||||
],
|
||||
causes: [
|
||||
"Overwatering or planting in poorly drained soil",
|
||||
"Heavy clay soil retaining too much moisture",
|
||||
"High humidity combined with poor air circulation",
|
||||
"Wet winter soil is especially deadly for lavender",
|
||||
"Mulch piled against the crown causing rot",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy severely affected plants — rarely recoverable",
|
||||
"For mild cases: stop watering, dig up, cut away rotted roots",
|
||||
"Repot in extremely well-draining mix (cactus/succulent + perlite)",
|
||||
"Add gravel or grit to planting hole for drainage",
|
||||
"Do not water for 1-2 weeks after repotting",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant in raised beds or slopes with excellent drainage",
|
||||
"Add gravel, sand or grit to heavy soil before planting",
|
||||
"Water deeply but infrequently — let soil dry between waterings",
|
||||
"Never mulch around lavender crown — keep base exposed",
|
||||
"Space plants 18-24 inches apart for airflow",
|
||||
"In humid climates, choose cultivars like 'Phenomenal' or 'Grosso'",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sunflower",
|
||||
commonName: "Sunflower",
|
||||
scientificName: "Helianthus annuus",
|
||||
family: "Asteraceae",
|
||||
category: "flowers",
|
||||
description:
|
||||
"Cheerful, fast-growing annuals that are surprisingly resilient. Sunflowers are generally low-maintenance but can be affected by a few diseases, especially in crowded plantings.",
|
||||
careSummary: "Full sun (6-8+ hours), moderately fertile well-drained soil, support tall varieties, protect from strong winds.",
|
||||
imageEmoji: "🌻",
|
||||
diseases: [
|
||||
{
|
||||
id: "sunflower-downy-mildew",
|
||||
name: "Downy Mildew",
|
||||
scientificName: "Plasmopara halstedii",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A serious disease affecting sunflower seedlings. It stunts growth significantly and can reduce yields in agricultural settings.",
|
||||
symptoms: [
|
||||
"Stunted, thickened, pale green to yellow leaves",
|
||||
"White fuzzy growth on leaf undersides",
|
||||
"Plants are severely dwarfed with shortened internodes",
|
||||
"Leaves curl downward and become brittle",
|
||||
"Infected plants rarely produce flowers or produce tiny heads",
|
||||
],
|
||||
causes: [
|
||||
"Soil-borne oospores infect young seedlings in cool, wet soil",
|
||||
"Cool soil temperatures (50-65°F / 10-18°C) at planting time",
|
||||
"Prolonged wet weather after planting",
|
||||
"Poorly drained soil",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy infected seedlings immediately",
|
||||
"Improve drainage if possible",
|
||||
"Apply metalaxyl or mefenoxam fungicide as soil treatment (for high-value plantings)",
|
||||
"Do not plant sunflowers in same spot for 5-7 years",
|
||||
],
|
||||
preventionTips: [
|
||||
"Plant resistant varieties (most modern hybrids have resistance)",
|
||||
"Wait until soil is warm (60°F+) before planting",
|
||||
"Plant in well-drained soil — avoid low, wet areas",
|
||||
"Use fungicide seed treatments for high-risk areas",
|
||||
"Rotate sunflowers with non-host crops (corn, soybeans, wheat)",
|
||||
],
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
id: "sunflower-rust",
|
||||
name: "Rust",
|
||||
scientificName: "Puccinia helianthi",
|
||||
type: "fungal",
|
||||
description:
|
||||
"A common fungal disease that produces rusty orange pustules on leaves. While usually not fatal, severe infections can reduce vigor and flower quality.",
|
||||
symptoms: [
|
||||
"Small, rusty orange to brown pustules (raised bumps) on leaves",
|
||||
"Pustules appear mainly on leaf undersides, with yellow spots on top",
|
||||
"Leaves turn yellow, then brown, and may die prematurely",
|
||||
"In severe cases, pustules appear on stems and flower bracts",
|
||||
"Defoliation from bottom upward as disease progresses",
|
||||
],
|
||||
causes: [
|
||||
"Rust fungus spores overwinter on infected plant debris",
|
||||
"Spores spread by wind and splashing water",
|
||||
"Warm, humid weather (65-85°F / 18-29°C) with frequent dew or rain",
|
||||
"Overcrowding reducing air circulation",
|
||||
],
|
||||
treatmentSteps: [
|
||||
"Remove and destroy heavily infected leaves",
|
||||
"Apply sulfur-based fungicide or neem oil at first sign",
|
||||
"Improve spacing and air circulation",
|
||||
"Water at soil level, not overhead",
|
||||
"Apply fungicide containing myclobutanil or chlorothalonil for severe cases",
|
||||
],
|
||||
preventionTips: [
|
||||
"Space plants 12-24 inches apart for good airflow",
|
||||
"Plant in full sun to ensure leaves dry quickly",
|
||||
"Water in the morning at the base of plants",
|
||||
"Clean up all plant debris at end of season",
|
||||
"Rotate planting location each year",
|
||||
],
|
||||
severity: "low",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a plant by its ID.
|
||||
*/
|
||||
export function getPlantById(id: string): Plant | undefined {
|
||||
return plants.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plants by category.
|
||||
*/
|
||||
export function getPlantsByCategory(category: Plant["category"]): Plant[] {
|
||||
return plants.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured plants (subset for homepage).
|
||||
*/
|
||||
export function getFeaturedPlants(): Plant[] {
|
||||
return plants.filter((p) =>
|
||||
["tomato", "basil", "rose", "monstera", "snake-plant", "pepper"].includes(p.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique disease types used across the dataset.
|
||||
*/
|
||||
export function getAllDiseaseTypes(): Disease["type"][] {
|
||||
const types = new Set<Disease["type"]>();
|
||||
plants.forEach((p) => p.diseases.forEach((d) => types.add(d.type)));
|
||||
return Array.from(types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search plants by name (case-insensitive).
|
||||
*/
|
||||
export function searchPlants(query: string): Plant[] {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return plants;
|
||||
return plants.filter(
|
||||
(p) =>
|
||||
p.commonName.toLowerCase().includes(q) ||
|
||||
p.scientificName.toLowerCase().includes(q) ||
|
||||
p.diseases.some((d) => d.name.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
0
src/lib/.gitkeep
Normal file
0
src/lib/.gitkeep
Normal file
0
src/lib/api/.gitkeep
Normal file
0
src/lib/api/.gitkeep
Normal file
135
src/lib/api/browse.ts
Normal file
135
src/lib/api/browse.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Browse API — fetches plants with disease counts from the Turso DB
|
||||
* for the browse page. Runs server-side only.
|
||||
*/
|
||||
|
||||
import { sql, eq, inArray, notInArray } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { plants, diseases, plantViews } from "@/lib/db/schema";
|
||||
import type { PlantCardData } from "@/components/PlantCard";
|
||||
|
||||
export type { PlantCardData };
|
||||
|
||||
/**
|
||||
* Get all plants with their disease counts for the browse page.
|
||||
*
|
||||
* Uses scalar subqueries for COUNT to avoid expensive LEFT JOIN + GROUP BY
|
||||
* on the large diseases table (11,498 rows).
|
||||
*/
|
||||
export async function getBrowsePlants(): Promise<PlantCardData[]> {
|
||||
const db = getDb();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: plants.id,
|
||||
commonName: plants.commonName,
|
||||
scientificName: plants.scientificName,
|
||||
family: plants.family,
|
||||
category: plants.category,
|
||||
imageUrl: plants.imageUrl,
|
||||
updatedAt: plants.updatedAt,
|
||||
viewCount: sql<number>`COALESCE(${plantViews.viewCount}, 0)`,
|
||||
diseaseCount: sql<number>`(SELECT COUNT(*) FROM ${diseases} WHERE ${diseases.plantId} = ${plants.id})`,
|
||||
})
|
||||
.from(plants)
|
||||
.leftJoin(plantViews, eq(plantViews.plantId, plants.id))
|
||||
.orderBy(plants.commonName);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
commonName: r.commonName,
|
||||
scientificName: r.scientificName,
|
||||
family: r.family,
|
||||
category: r.category,
|
||||
imageUrl: r.imageUrl,
|
||||
updatedAt: r.updatedAt,
|
||||
viewCount: r.viewCount,
|
||||
diseaseCount: r.diseaseCount,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single plant with disease count (for detail page lookups).
|
||||
*/
|
||||
export async function getBrowsePlant(id: string): Promise<PlantCardData | null> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: plants.id,
|
||||
commonName: plants.commonName,
|
||||
scientificName: plants.scientificName,
|
||||
family: plants.family,
|
||||
category: plants.category,
|
||||
imageUrl: plants.imageUrl,
|
||||
diseaseCount: sql<number>`(SELECT COUNT(*) FROM ${diseases} WHERE ${diseases.plantId} = ${plants.id})`,
|
||||
})
|
||||
.from(plants)
|
||||
.where(eq(plants.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured plants for the homepage (subset).
|
||||
*/
|
||||
const FEATURED_IDS = [
|
||||
"tomato",
|
||||
"basil",
|
||||
"rose",
|
||||
"monstera",
|
||||
"snake-plant",
|
||||
"pepper",
|
||||
"apple",
|
||||
"corn",
|
||||
"wheat",
|
||||
"strawberry",
|
||||
"blueberry",
|
||||
"lettuce",
|
||||
];
|
||||
|
||||
export async function getFeaturedPlants(): Promise<PlantCardData[]> {
|
||||
const db = getDb();
|
||||
|
||||
const selectFeatured = db
|
||||
.select({
|
||||
id: plants.id,
|
||||
commonName: plants.commonName,
|
||||
scientificName: plants.scientificName,
|
||||
family: plants.family,
|
||||
category: plants.category,
|
||||
imageUrl: plants.imageUrl,
|
||||
updatedAt: plants.updatedAt,
|
||||
viewCount: sql<number>`COALESCE(${plantViews.viewCount}, 0)`,
|
||||
diseaseCount: sql<number>`(SELECT COUNT(*) FROM ${diseases} WHERE ${diseases.plantId} = ${plants.id})`,
|
||||
})
|
||||
.from(plants)
|
||||
.leftJoin(plantViews, eq(plantViews.plantId, plants.id));
|
||||
|
||||
const rows = await selectFeatured
|
||||
.where(inArray(plants.id, FEATURED_IDS))
|
||||
.orderBy(plants.commonName);
|
||||
|
||||
if (rows.length < 6) {
|
||||
const padRows = await selectFeatured
|
||||
.where(notInArray(plants.id, FEATURED_IDS))
|
||||
.orderBy(plants.commonName)
|
||||
.limit(12 - rows.length);
|
||||
return [...rows, ...padRows].map(mapRow);
|
||||
}
|
||||
return rows.slice(0, 12).map(mapRow);
|
||||
}
|
||||
|
||||
function mapRow(r: Record<string, unknown>): PlantCardData {
|
||||
return {
|
||||
id: r.id as string,
|
||||
commonName: r.commonName as string,
|
||||
scientificName: r.scientificName as string,
|
||||
family: r.family as string,
|
||||
category: r.category as string,
|
||||
imageUrl: r.imageUrl as string,
|
||||
updatedAt: r.updatedAt as string | undefined,
|
||||
viewCount: r.viewCount as number,
|
||||
diseaseCount: r.diseaseCount as number,
|
||||
};
|
||||
}
|
||||
382
src/lib/api/diseases-db.ts
Normal file
382
src/lib/api/diseases-db.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Typed helpers to query the Plant Disease Knowledge Base from Turso DB.
|
||||
*
|
||||
* All functions are async and use Drizzle ORM against the Turso/libSQL database.
|
||||
*
|
||||
* For client components that need sync access, import from
|
||||
* @/lib/api/diseases-sync.ts (backed by JSON seed data)
|
||||
*/
|
||||
|
||||
import { eq, like, or, and, sql, type SQL } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { plants, diseases } from "@/lib/db/schema";
|
||||
import type {
|
||||
CausalAgentType,
|
||||
Disease,
|
||||
DiseaseListParams,
|
||||
DiseaseWithPlant,
|
||||
Plant,
|
||||
PlantListParams,
|
||||
PlantWithDiseases,
|
||||
Prevalence,
|
||||
Severity,
|
||||
PlantCategory,
|
||||
} from "@/lib/types";
|
||||
|
||||
// ─── Row → Type mappers ──────────────────────────────────────────────────────
|
||||
|
||||
function toPlant(row: typeof plants.$inferSelect): Plant {
|
||||
return {
|
||||
id: row.id,
|
||||
commonName: row.commonName,
|
||||
scientificName: row.scientificName,
|
||||
family: row.family,
|
||||
category: row.category as PlantCategory,
|
||||
careSummary: row.careSummary,
|
||||
imageUrl: row.imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function toDisease(row: typeof diseases.$inferSelect): Disease {
|
||||
return {
|
||||
id: row.id,
|
||||
plantId: row.plantId,
|
||||
name: row.name,
|
||||
scientificName: row.scientificName,
|
||||
causalAgentType: row.causalAgentType as CausalAgentType,
|
||||
description: row.description,
|
||||
symptoms: row.symptoms as string[],
|
||||
causes: row.causes as string[],
|
||||
treatment: row.treatment as string[],
|
||||
prevention: row.prevention as string[],
|
||||
lookalikeDiseaseIds: (row.lookalikeIds as string[]) ?? [],
|
||||
severity: row.severity as Severity,
|
||||
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
|
||||
imageUrl: (row.imageUrl as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a plant by its ID.
|
||||
*/
|
||||
export async function getPlantById(id: string): Promise<Plant | undefined> {
|
||||
const db = getDb();
|
||||
const row = await db.select().from(plants).where(eq(plants.id, id.toLowerCase())).limit(1);
|
||||
return row[0] ? toPlant(row[0]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a disease by its ID.
|
||||
*/
|
||||
export async function getDiseaseById(id: string): Promise<Disease | undefined> {
|
||||
const db = getDb();
|
||||
const row = await db.select().from(diseases).where(eq(diseases.id, id.toLowerCase())).limit(1);
|
||||
return row[0] ? toDisease(row[0]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all diseases for a specific plant.
|
||||
*/
|
||||
export async function getDiseasesByPlantId(plantId: string): Promise<Disease[]> {
|
||||
const db = getDb();
|
||||
const rows = await db.select().from(diseases).where(eq(diseases.plantId, plantId.toLowerCase()));
|
||||
return rows.map(toDisease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a plant with all its associated diseases.
|
||||
*/
|
||||
export async function getPlantWithDiseases(
|
||||
plantId: string,
|
||||
): Promise<PlantWithDiseases | undefined> {
|
||||
const plant = await getPlantById(plantId);
|
||||
if (!plant) return undefined;
|
||||
const diseaseRows = await getDiseasesByPlantId(plantId);
|
||||
return { plant, diseases: diseaseRows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a disease with its associated plant.
|
||||
*/
|
||||
export async function getDiseaseWithPlant(
|
||||
diseaseId: string,
|
||||
): Promise<DiseaseWithPlant | undefined> {
|
||||
const disease = await getDiseaseById(diseaseId);
|
||||
if (!disease) return undefined;
|
||||
const plant = await getPlantById(disease.plantId);
|
||||
if (!plant) return undefined;
|
||||
return { disease, plant };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve lookalike disease IDs to full disease objects.
|
||||
*/
|
||||
export async function getLookalikeDiseases(diseaseId: string): Promise<Disease[]> {
|
||||
const disease = await getDiseaseById(diseaseId);
|
||||
if (!disease || !disease.lookalikeDiseaseIds.length) return [];
|
||||
const db = getDb();
|
||||
const ids = disease.lookalikeDiseaseIds;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(diseases)
|
||||
.where(sql`${diseases.id} IN ${ids}`);
|
||||
return rows.map(toDisease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search plants by term (matches common name, scientific name, family, category).
|
||||
*/
|
||||
export async function searchPlants(term: string): Promise<Plant[]> {
|
||||
const lower = term.toLowerCase().trim();
|
||||
if (!lower) return listPlants();
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(plants)
|
||||
.where(
|
||||
or(
|
||||
like(plants.commonName, `%${lower}%`),
|
||||
like(plants.scientificName, `%${lower}%`),
|
||||
like(plants.family, `%${lower}%`),
|
||||
like(plants.category, `%${lower}%`),
|
||||
),
|
||||
);
|
||||
return rows.map(toPlant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search diseases by term (matches name, scientific name, description, symptoms via LIKE).
|
||||
*/
|
||||
export async function searchDiseases(term: string): Promise<Disease[]> {
|
||||
const lower = term.toLowerCase().trim();
|
||||
if (!lower) return listDiseases();
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(diseases)
|
||||
.where(
|
||||
or(
|
||||
like(diseases.name, `%${lower}%`),
|
||||
like(diseases.scientificName, `%${lower}%`),
|
||||
like(diseases.description, `%${lower}%`),
|
||||
),
|
||||
);
|
||||
return rows.map(toDisease);
|
||||
}
|
||||
|
||||
/**
|
||||
* List plants with optional search and category filters.
|
||||
*/
|
||||
export async function listPlants(params: PlantListParams = {}): Promise<Plant[]> {
|
||||
const db = getDb();
|
||||
const plantConditions: SQL[] = [];
|
||||
|
||||
if (params.category) {
|
||||
plantConditions.push(eq(plants.category, params.category));
|
||||
}
|
||||
if (params.search) {
|
||||
const lower = params.search.toLowerCase().trim();
|
||||
const searchCond = or(
|
||||
like(plants.commonName, `%${lower}%`),
|
||||
like(plants.scientificName, `%${lower}%`),
|
||||
like(plants.family, `%${lower}%`),
|
||||
like(plants.category, `%${lower}%`),
|
||||
);
|
||||
if (searchCond) plantConditions.push(searchCond);
|
||||
}
|
||||
|
||||
const query =
|
||||
plantConditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(plants)
|
||||
.where(and(...plantConditions))
|
||||
: db.select().from(plants);
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(toPlant);
|
||||
}
|
||||
|
||||
/**
|
||||
* List diseases with optional filters.
|
||||
*/
|
||||
export async function listDiseases(params: DiseaseListParams = {}): Promise<Disease[]> {
|
||||
const db = getDb();
|
||||
const diseaseConditions: SQL[] = [];
|
||||
|
||||
if (params.plantId) {
|
||||
diseaseConditions.push(eq(diseases.plantId, params.plantId.toLowerCase()));
|
||||
}
|
||||
if (params.causalAgentType) {
|
||||
diseaseConditions.push(eq(diseases.causalAgentType, params.causalAgentType));
|
||||
}
|
||||
if (params.severity) {
|
||||
diseaseConditions.push(eq(diseases.severity, params.severity));
|
||||
}
|
||||
if (params.search) {
|
||||
const lower = params.search.toLowerCase().trim();
|
||||
const searchCond = or(
|
||||
like(diseases.name, `%${lower}%`),
|
||||
like(diseases.scientificName, `%${lower}%`),
|
||||
like(diseases.description, `%${lower}%`),
|
||||
);
|
||||
if (searchCond) diseaseConditions.push(searchCond);
|
||||
}
|
||||
|
||||
const query =
|
||||
diseaseConditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(diseases)
|
||||
.where(and(...diseaseConditions))
|
||||
: db.select().from(diseases);
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(toDisease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique plant IDs that have diseases.
|
||||
*/
|
||||
export async function getPlantIdsWithDiseases(): Promise<string[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ plantId: diseases.plantId })
|
||||
.from(diseases)
|
||||
.groupBy(diseases.plantId);
|
||||
return rows.map((r) => r.plantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique disease IDs referenced as lookalikes.
|
||||
*/
|
||||
export async function getReferencedLookalikeIds(): Promise<Set<string>> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ id: diseases.id, lookalikeIds: diseases.lookalikeIds })
|
||||
.from(diseases);
|
||||
const ids = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const lookalikes = row.lookalikeIds as string[];
|
||||
for (const id of lookalikes) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate knowledge base data integrity.
|
||||
* Returns array of validation errors (empty = valid).
|
||||
*/
|
||||
export async function validateKnowledgeBase(): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const validCausalAgentTypes: CausalAgentType[] = [
|
||||
"fungal",
|
||||
"bacterial",
|
||||
"viral",
|
||||
"environmental",
|
||||
];
|
||||
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
||||
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare", "very_rare"];
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get all plants and diseases
|
||||
const allPlants = await db.select({ id: plants.id }).from(plants);
|
||||
const allDiseases = await db.select().from(diseases);
|
||||
|
||||
const plantIds = new Set(allPlants.map((p) => p.id));
|
||||
const diseaseIds = new Set<string>();
|
||||
const diseaseMap = new Map<string, (typeof allDiseases)[0]>();
|
||||
const diseaseErrors: Array<{
|
||||
id: string;
|
||||
plantId: string;
|
||||
name: string;
|
||||
lookalikeDiseaseIds: string[];
|
||||
}> = [];
|
||||
|
||||
for (const d of allDiseases) {
|
||||
if (diseaseIds.has(d.id)) {
|
||||
errors.push(`Duplicate disease ID: ${d.id}`);
|
||||
}
|
||||
diseaseIds.add(d.id);
|
||||
diseaseMap.set(d.id, d);
|
||||
diseaseErrors.push({
|
||||
id: d.id,
|
||||
plantId: d.plantId,
|
||||
name: d.name,
|
||||
lookalikeDiseaseIds: (d.lookalikeIds as string[]) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
// Check disease references
|
||||
for (const d of diseaseErrors) {
|
||||
// Valid plant reference
|
||||
if (!plantIds.has(d.plantId)) {
|
||||
errors.push(`Disease "${d.id}" references unknown plant ID: ${d.plantId}`);
|
||||
}
|
||||
|
||||
const full = diseaseMap.get(d.id)!;
|
||||
|
||||
// Valid causal agent type
|
||||
if (!validCausalAgentTypes.includes(full.causalAgentType as CausalAgentType)) {
|
||||
errors.push(`Disease "${d.id}" has invalid causalAgentType: ${full.causalAgentType}`);
|
||||
}
|
||||
|
||||
// Valid severity
|
||||
if (!validSeverities.includes(full.severity as Severity)) {
|
||||
errors.push(`Disease "${d.id}" has invalid severity: ${full.severity}`);
|
||||
}
|
||||
|
||||
// Valid prevalence
|
||||
if (full.prevalence && !validPrevalences.includes(full.prevalence as Prevalence)) {
|
||||
errors.push(`Disease "${d.id}" has invalid prevalence: ${full.prevalence}`);
|
||||
}
|
||||
|
||||
// Minimum counts
|
||||
const symptoms = full.symptoms as string[];
|
||||
const causes = full.causes as string[];
|
||||
const treatment = full.treatment as string[];
|
||||
const prevention = full.prevention as string[];
|
||||
|
||||
if (symptoms.length < 3) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 3 symptoms (${symptoms.length})`);
|
||||
}
|
||||
if (causes.length < 2) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 2 causes (${causes.length})`);
|
||||
}
|
||||
if (treatment.length < 3) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 3 treatment steps (${treatment.length})`);
|
||||
}
|
||||
if (prevention.length < 2) {
|
||||
errors.push(`Disease "${d.id}" has fewer than 2 prevention tips (${prevention.length})`);
|
||||
}
|
||||
|
||||
// Valid lookalike references
|
||||
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||
if (!diseaseIds.has(lookalikeId)) {
|
||||
errors.push(`Disease "${d.id}" references unknown lookalike: ${lookalikeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check lookalike bidirectionality
|
||||
for (const d of diseaseErrors) {
|
||||
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||
const lookalike = diseaseMap.get(lookalikeId);
|
||||
if (lookalike) {
|
||||
const otherLookalikes = (lookalike.lookalikeIds as string[]) ?? [];
|
||||
if (!otherLookalikes.includes(d.id)) {
|
||||
errors.push(
|
||||
`Lookalike reference not bidirectional: "${d.id}" references "${lookalikeId}" but not vice versa`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
44
src/lib/api/home.ts
Normal file
44
src/lib/api/home.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Homepage data — fetches featured plants from the Turso DB.
|
||||
* Uses React's cache() to ensure one fetch per render pass.
|
||||
* Backed by the async fetch for SSR but stays sync in exported interface
|
||||
* via a module-level cache pattern.
|
||||
*/
|
||||
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
// Re-export the type for convenience
|
||||
export type { PlantCardData } from "@/components/PlantCard";
|
||||
|
||||
/**
|
||||
* Get featured plants for the homepage.
|
||||
* Cached via next/cache to avoid repeated DB calls.
|
||||
*/
|
||||
export const getFeaturedPlants = unstable_cache(
|
||||
async () => {
|
||||
const { getBrowsePlants } = await import("./browse");
|
||||
const all = await getBrowsePlants();
|
||||
const FEATURED_IDS = [
|
||||
"tomato",
|
||||
"basil",
|
||||
"rose",
|
||||
"monstera",
|
||||
"snake-plant",
|
||||
"pepper",
|
||||
"apple",
|
||||
"corn",
|
||||
"wheat",
|
||||
"strawberry",
|
||||
"blueberry",
|
||||
"lettuce",
|
||||
];
|
||||
const featured = all.filter((p) => FEATURED_IDS.includes(p.id));
|
||||
if (featured.length < 6) {
|
||||
const rest = all.filter((p) => !FEATURED_IDS.includes(p.id));
|
||||
return [...featured, ...rest].slice(0, 12);
|
||||
}
|
||||
return featured.slice(0, 12);
|
||||
},
|
||||
["featured-plants"],
|
||||
{ revalidate: 3600 },
|
||||
);
|
||||
107
src/lib/api/identify-client.test.ts
Normal file
107
src/lib/api/identify-client.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { identifyPlant } from "./identify";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("identifyPlant", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("identifies plant and returns predictions", async () => {
|
||||
const mockResponse = {
|
||||
predictions: [
|
||||
{
|
||||
diseaseId: "early-blight",
|
||||
disease: {
|
||||
id: "early-blight",
|
||||
name: "Early Blight",
|
||||
causalAgent: "Alternaria solani",
|
||||
causalAgentType: "fungal",
|
||||
severity: "moderate",
|
||||
symptoms: ["Dark spots"],
|
||||
treatment: ["Remove leaves"],
|
||||
lookalikeDiseaseIds: [],
|
||||
plantId: "tomato",
|
||||
},
|
||||
confidence: { raw: 0.85, adjusted: 0.82 },
|
||||
lookalikes: [],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
model: "mock-model",
|
||||
inferenceTimeMs: 150,
|
||||
imageId: "test-image-123",
|
||||
},
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await identifyPlant("test-image-123");
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("calls fetch with correct URL and method", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ predictions: [], metadata: {} }),
|
||||
});
|
||||
|
||||
await identifyPlant("test-id");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/identify"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends imageId in request body", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ predictions: [], metadata: {} }),
|
||||
});
|
||||
|
||||
await identifyPlant("test-id");
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0][1];
|
||||
const body = JSON.parse(callArgs.body);
|
||||
expect(body.imageId).toBe("test-id");
|
||||
});
|
||||
|
||||
it("throws error when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
await expect(identifyPlant("test-id")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws error when fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(identifyPlant("test-id")).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("handles demo mode response", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
predictions: [],
|
||||
metadata: {},
|
||||
demo_mode: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await identifyPlant("test-id");
|
||||
expect(result.demo_mode).toBe(true);
|
||||
});
|
||||
});
|
||||
49
src/lib/api/identify.ts
Normal file
49
src/lib/api/identify.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Client-side API helper for plant disease identification.
|
||||
*
|
||||
* POSTs an imageId to the /api/identify endpoint and returns
|
||||
* ranked predictions with confidence scores, enriched with
|
||||
* knowledge base data (name, symptoms, treatment, prevention).
|
||||
*/
|
||||
|
||||
import type { IdentifyRequest, IdentifyResponse } from "@/lib/types";
|
||||
|
||||
export interface IdentifyError {
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify plant diseases from an uploaded image.
|
||||
*
|
||||
* @param imageId - Image ID from a previous /api/upload call
|
||||
* @returns IdentifyResponse with ranked predictions and metadata
|
||||
* @throws IdentifyError on failure
|
||||
*/
|
||||
export async function identifyPlant(
|
||||
imageId: string,
|
||||
): Promise<IdentifyResponse> {
|
||||
const request: IdentifyRequest = { imageId };
|
||||
|
||||
const response = await fetch("/api/identify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(30_000), // 30s timeout for inference
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
error: data.error || "Identification failed",
|
||||
message: data.message || `Server returned ${response.status}`,
|
||||
status: response.status,
|
||||
} as IdentifyError;
|
||||
}
|
||||
|
||||
return data as IdentifyResponse;
|
||||
}
|
||||
98
src/lib/api/upload-client.test.ts
Normal file
98
src/lib/api/upload-client.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { uploadImage } from "./upload";
|
||||
import * as imageProcessing from "@/lib/image-processing";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/image-processing", () => ({
|
||||
validateImageFile: vi.fn(() => ({ ok: true })),
|
||||
validateImageDimensions: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("uploadImage", () => {
|
||||
const mockFile = new File(["dummy"], "test.png", { type: "image/png" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mocks to default pass values
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({ ok: true });
|
||||
(imageProcessing.validateImageDimensions as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("uploads image and returns response", async () => {
|
||||
const mockResponse = {
|
||||
imageId: "test-id-123",
|
||||
tensorShape: [3, 224, 224],
|
||||
previewUrl: "/uploads/test-id-123.png",
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await uploadImage(mockFile);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("calls fetch with correct URL", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ imageId: "test", tensorShape: [3, 224, 224], previewUrl: "/test.png" }),
|
||||
});
|
||||
|
||||
await uploadImage(mockFile);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/upload",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends FormData with image field", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ imageId: "test", tensorShape: [3, 224, 224], previewUrl: "/test.png" }),
|
||||
});
|
||||
|
||||
await uploadImage(mockFile);
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0][1];
|
||||
expect(callArgs.body).toBeInstanceOf(FormData);
|
||||
});
|
||||
|
||||
it("throws error when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
json: async () => ({ error: "File too large", message: "File exceeds 10MB limit" }),
|
||||
});
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws error when fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("throws error when file validation fails", async () => {
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({ ok: false, error: "Invalid file type" });
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow("Validation: Invalid file type");
|
||||
});
|
||||
|
||||
it("throws error when dimension validation fails", async () => {
|
||||
// Reset validateImageFile to pass so we can test dimension validation
|
||||
(imageProcessing.validateImageFile as ReturnType<typeof vi.fn>).mockReturnValue({ ok: true });
|
||||
(imageProcessing.validateImageDimensions as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false, error: "Image too small" });
|
||||
|
||||
await expect(uploadImage(mockFile)).rejects.toThrow("Validation: Image too small");
|
||||
});
|
||||
});
|
||||
89
src/lib/api/upload.ts
Normal file
89
src/lib/api/upload.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Client-side API helper for image upload.
|
||||
*
|
||||
* POSTs an image file to the server upload endpoint and returns
|
||||
* the image metadata (imageId, tensorShape, previewUrl).
|
||||
*/
|
||||
|
||||
import { validateImageFile, validateImageDimensions } from "@/lib/image-processing";
|
||||
|
||||
export interface UploadResponse {
|
||||
imageId: string;
|
||||
tensorShape: [number, number, number, number];
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
export interface UploadError {
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image file to the server.
|
||||
*
|
||||
* Validates the file client-side before sending. Returns the server
|
||||
* response with imageId, tensorShape, and previewUrl.
|
||||
*
|
||||
* @param file - The image file to upload
|
||||
* @param onProgress - Optional callback for upload progress (0–100)
|
||||
* @returns UploadResponse on success
|
||||
* @throws UploadError on failure
|
||||
*/
|
||||
export async function uploadImage(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<UploadResponse> {
|
||||
// Client-side validation
|
||||
const fileValidation = validateImageFile(file);
|
||||
if (!fileValidation.ok) {
|
||||
throw new Error(`Validation: ${fileValidation.error}`);
|
||||
}
|
||||
|
||||
const dimValidation = await validateImageDimensions(file);
|
||||
if (!dimValidation.ok) {
|
||||
throw new Error(`Validation: ${dimValidation.error}`);
|
||||
}
|
||||
|
||||
// Build form data
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
// POST with progress tracking
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30_000), // 30s timeout
|
||||
});
|
||||
|
||||
// Parse response
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
error: data.error || "Upload failed",
|
||||
message: data.message || `Server returned ${response.status}`,
|
||||
status: response.status,
|
||||
} as UploadError;
|
||||
}
|
||||
|
||||
return data as UploadResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image and also get the preprocessed tensor for client-side inference.
|
||||
* Useful when you want to run inference on the client while also uploading.
|
||||
*
|
||||
* @param file - The image file to upload
|
||||
* @returns { uploadResponse, tensor }
|
||||
*/
|
||||
export async function uploadWithTensor(
|
||||
file: File,
|
||||
): Promise<{ uploadResponse: UploadResponse; tensor: Float32Array }> {
|
||||
const { resizeImage, imageToTensor } = await import("@/lib/image-processing");
|
||||
|
||||
const tensor = imageToTensor(await resizeImage(file));
|
||||
const uploadResponse = await uploadImage(file);
|
||||
|
||||
return { uploadResponse, tensor };
|
||||
}
|
||||
173
src/lib/constants.test.ts
Normal file
173
src/lib/constants.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
APP_NAME,
|
||||
NAV_LINKS,
|
||||
PLANT_CATEGORIES,
|
||||
TRUST_SIGNALS,
|
||||
HOW_IT_WORKS,
|
||||
BETA_DISCLAIMER,
|
||||
SOCIAL_LINKS,
|
||||
FEATURED_PLANT_IDS,
|
||||
APP_TAGLINE,
|
||||
APP_DESCRIPTION,
|
||||
} from "./constants";
|
||||
|
||||
describe("constants", () => {
|
||||
describe("APP_NAME", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof APP_NAME).toBe("string");
|
||||
expect(APP_NAME.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("equals expected name", () => {
|
||||
expect(APP_NAME).toBe("Plant Health ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("APP_TAGLINE", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof APP_TAGLINE).toBe("string");
|
||||
expect(APP_TAGLINE.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("APP_DESCRIPTION", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof APP_DESCRIPTION).toBe("string");
|
||||
expect(APP_DESCRIPTION.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOCIAL_LINKS", () => {
|
||||
it("has github link", () => {
|
||||
expect(SOCIAL_LINKS.github).toMatch(/github/);
|
||||
});
|
||||
|
||||
it("has twitter link", () => {
|
||||
expect(SOCIAL_LINKS.twitter).toMatch(/twitter/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NAV_LINKS", () => {
|
||||
it("is an array of navigation links", () => {
|
||||
expect(Array.isArray(NAV_LINKS)).toBe(true);
|
||||
expect(NAV_LINKS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each link has label and href", () => {
|
||||
for (const link of NAV_LINKS) {
|
||||
expect(link).toHaveProperty("label");
|
||||
expect(link).toHaveProperty("href");
|
||||
expect(typeof link.label).toBe("string");
|
||||
expect(typeof link.href).toBe("string");
|
||||
expect(link.href.startsWith("/")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes expected routes", () => {
|
||||
const hrefs = NAV_LINKS.map((l) => l.href);
|
||||
expect(hrefs).toContain("/");
|
||||
expect(hrefs).toContain("/browse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLANT_CATEGORIES", () => {
|
||||
it("is an array of categories", () => {
|
||||
expect(Array.isArray(PLANT_CATEGORIES)).toBe(true);
|
||||
expect(PLANT_CATEGORIES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each category has label and value", () => {
|
||||
for (const cat of PLANT_CATEGORIES) {
|
||||
expect(cat).toHaveProperty("label");
|
||||
expect(cat).toHaveProperty("value");
|
||||
expect(typeof cat.label).toBe("string");
|
||||
expect(typeof cat.value).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes all option", () => {
|
||||
const values = PLANT_CATEGORIES.map((c) => c.value);
|
||||
expect(values).toContain("all");
|
||||
});
|
||||
|
||||
it("includes common plant categories", () => {
|
||||
const values = PLANT_CATEGORIES.map((c) => c.value);
|
||||
expect(values).toContain("vegetables");
|
||||
expect(values).toContain("flowers");
|
||||
expect(values).toContain("herbs");
|
||||
expect(values).toContain("houseplants");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FEATURED_PLANT_IDS", () => {
|
||||
it("is an array of plant IDs", () => {
|
||||
expect(Array.isArray(FEATURED_PLANT_IDS)).toBe(true);
|
||||
expect(FEATURED_PLANT_IDS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes expected featured plants", () => {
|
||||
expect(FEATURED_PLANT_IDS).toContain("tomato");
|
||||
expect(FEATURED_PLANT_IDS).toContain("basil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TRUST_SIGNALS", () => {
|
||||
it("is an array of trust signals", () => {
|
||||
expect(Array.isArray(TRUST_SIGNALS)).toBe(true);
|
||||
expect(TRUST_SIGNALS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each signal has icon and label", () => {
|
||||
for (const signal of TRUST_SIGNALS) {
|
||||
expect(signal).toHaveProperty("icon");
|
||||
expect(signal).toHaveProperty("label");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOW_IT_WORKS", () => {
|
||||
it("is an array of steps", () => {
|
||||
expect(Array.isArray(HOW_IT_WORKS)).toBe(true);
|
||||
expect(HOW_IT_WORKS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each step has step number, emoji, title, and description", () => {
|
||||
for (const step of HOW_IT_WORKS) {
|
||||
expect(step).toHaveProperty("step");
|
||||
expect(step).toHaveProperty("emoji");
|
||||
expect(step).toHaveProperty("title");
|
||||
expect(step).toHaveProperty("description");
|
||||
}
|
||||
});
|
||||
|
||||
it("has exactly 3 steps", () => {
|
||||
expect(HOW_IT_WORKS.length).toBe(3);
|
||||
});
|
||||
|
||||
it("steps are numbered sequentially", () => {
|
||||
expect(HOW_IT_WORKS[0].step).toBe(1);
|
||||
expect(HOW_IT_WORKS[1].step).toBe(2);
|
||||
expect(HOW_IT_WORKS[2].step).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BETA_DISCLAIMER", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof BETA_DISCLAIMER).toBe("string");
|
||||
expect(BETA_DISCLAIMER.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mentions AI-assisted tool", () => {
|
||||
expect(BETA_DISCLAIMER.toLowerCase()).toContain("ai-assisted");
|
||||
});
|
||||
|
||||
it("mentions professional advice disclaimer", () => {
|
||||
expect(BETA_DISCLAIMER.toLowerCase()).toMatch(/not a substitute|professional/i);
|
||||
});
|
||||
|
||||
it("mentions plant pathologist", () => {
|
||||
expect(BETA_DISCLAIMER.toLowerCase()).toContain("plant pathologist");
|
||||
});
|
||||
});
|
||||
});
|
||||
77
src/lib/constants.ts
Normal file
77
src/lib/constants.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Site-wide constants for the Plant Disease Identifier application.
|
||||
*/
|
||||
|
||||
export const APP_NAME = "Plant Health ID";
|
||||
export const APP_TAGLINE = "Snap. Identify. Treat.";
|
||||
export const APP_DESCRIPTION =
|
||||
"Upload a plant photo for hyper-specific disease diagnosis with confidence scores, symptoms, causes, treatment steps, and prevention tips.";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
github: "https://github.com/plant-health-id",
|
||||
twitter: "https://twitter.com/planthealthid",
|
||||
} as const;
|
||||
|
||||
export const NAV_LINKS = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/upload", label: "Identify" },
|
||||
{ href: "/browse", label: "Browse Plants" },
|
||||
{ href: "/about", label: "About" },
|
||||
] as const;
|
||||
|
||||
export const PLANT_CATEGORIES = [
|
||||
{ value: "all", label: "All" },
|
||||
{ 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 = [
|
||||
"tomato",
|
||||
"basil",
|
||||
"rose",
|
||||
"monstera",
|
||||
"snake-plant",
|
||||
"pepper",
|
||||
"apple",
|
||||
"corn",
|
||||
"wheat",
|
||||
"strawberry",
|
||||
"blueberry",
|
||||
"lettuce",
|
||||
] as const;
|
||||
|
||||
export const TRUST_SIGNALS = [
|
||||
{ icon: "📸", label: "Trained on 500K+ images" },
|
||||
{ icon: "🌿", label: "Covers 300+ plants with 10K+ diseases" },
|
||||
{ icon: "🔓", label: "Open source" },
|
||||
] as const;
|
||||
|
||||
export const HOW_IT_WORKS = [
|
||||
{
|
||||
step: 1,
|
||||
emoji: "📸",
|
||||
title: "Upload a Photo",
|
||||
description: "Snap a picture of the affected plant leaf or fruit with your phone or camera.",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
emoji: "🧠",
|
||||
title: "AI Analysis",
|
||||
description:
|
||||
"Our model analyzes the image against 500K+ labeled plant disease images in seconds.",
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
emoji: "🌱",
|
||||
title: "Get Treatment Plan",
|
||||
description:
|
||||
"Receive a detailed diagnosis with confidence score, symptoms, causes, and step-by-step treatment.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const BETA_DISCLAIMER =
|
||||
"Plant Health ID is an AI-assisted tool for informational purposes only. It is not a substitute for professional agricultural or horticultural advice. Always consult a certified plant pathologist or extension service for critical plant health decisions.";
|
||||
372
src/lib/db.ts
Normal file
372
src/lib/db.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Turso/libSQL Database Client
|
||||
*
|
||||
* Provides the database client and schema management for the plant disease
|
||||
* knowledge base. Connect to Turso/libSQL using environment variables.
|
||||
*
|
||||
* Required env vars:
|
||||
* DATABASE_URL — Turso database URL (e.g., libsql://my-db.turso.io)
|
||||
* DATABASE_TOKEN — Turso authentication token
|
||||
*/
|
||||
|
||||
import { createClient, type InValue } from "@libsql/client";
|
||||
import type { Plant, Disease, CausalAgentType, Prevalence, Severity } from "./types";
|
||||
|
||||
// ─── Client ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let client: ReturnType<typeof createClient> | null = null;
|
||||
let connected = false;
|
||||
|
||||
/** Get or create a singleton database client */
|
||||
export function getDb() {
|
||||
if (client) return client;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const token = process.env.DATABASE_TOKEN;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"DATABASE_TOKEN is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
|
||||
client = createClient({ url, authToken: token });
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Check database connectivity */
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.execute("SELECT 1 AS ok");
|
||||
connected = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
connected = false;
|
||||
console.error("[DB] Connection failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** SQL to create the plants table */
|
||||
const PLANTS_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS plants (
|
||||
id TEXT PRIMARY KEY,
|
||||
common_name TEXT NOT NULL,
|
||||
scientific_name TEXT NOT NULL,
|
||||
family TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
care_summary TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plants_category ON plants(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_plants_common_name ON plants(common_name);
|
||||
`;
|
||||
|
||||
/** SQL to create the diseases table */
|
||||
const DISEASES_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS diseases (
|
||||
id TEXT PRIMARY KEY,
|
||||
plant_id TEXT NOT NULL REFERENCES plants(id),
|
||||
name TEXT NOT NULL,
|
||||
scientific_name TEXT NOT NULL DEFAULT '',
|
||||
causal_agent_type TEXT NOT NULL CHECK (causal_agent_type IN ('fungal','bacterial','viral','environmental')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
symptoms TEXT NOT NULL DEFAULT '[]',
|
||||
causes TEXT NOT NULL DEFAULT '[]',
|
||||
treatment TEXT NOT NULL DEFAULT '[]',
|
||||
prevention TEXT NOT NULL DEFAULT '[]',
|
||||
lookalike_ids TEXT NOT NULL DEFAULT '[]',
|
||||
severity TEXT NOT NULL CHECK (severity IN ('low','moderate','high','critical')),
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_diseases_plant_id ON diseases(plant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_diseases_causal_agent ON diseases(causal_agent_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_diseases_severity ON diseases(severity);
|
||||
|
||||
-- Full-text search virtual table for diseases
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS diseases_fts USING fts5(
|
||||
name,
|
||||
scientific_name,
|
||||
description,
|
||||
symptoms_text,
|
||||
content='diseases',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
`;
|
||||
|
||||
/** SQL to create the scrape_log table for tracking source freshness */
|
||||
const SCRAPE_LOG_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS scrape_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('wikipedia','university_extension','cabi','other')),
|
||||
source_url TEXT NOT NULL,
|
||||
last_scraped_at TEXT,
|
||||
entries_count INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','success','error')),
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
/** Run all schema migrations */
|
||||
export async function runSchema() {
|
||||
const db = getDb();
|
||||
console.log("[DB] Running schema migrations...");
|
||||
|
||||
await db.execute(PLANTS_TABLE_SQL);
|
||||
console.log("[DB] ✓ plants table");
|
||||
|
||||
await db.execute(DISEASES_TABLE_SQL);
|
||||
console.log("[DB] ✓ diseases table");
|
||||
|
||||
await db.execute(SCRAPE_LOG_SQL);
|
||||
console.log("[DB] ✓ scrape_sources table");
|
||||
|
||||
console.log("[DB] Schema up to date.");
|
||||
}
|
||||
|
||||
// ─── Row ↔ Type mappers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Convert a database row to a Plant object */
|
||||
export function rowToPlant(row: Record<string, unknown>): Plant {
|
||||
return {
|
||||
id: row.id as string,
|
||||
commonName: row.common_name as string,
|
||||
scientificName: row.scientific_name as string,
|
||||
family: row.family as string,
|
||||
category: row.category as Plant["category"],
|
||||
careSummary: row.care_summary as string,
|
||||
imageUrl: row.image_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a database row to a Disease object */
|
||||
export function rowToDisease(row: Record<string, unknown>): Disease {
|
||||
return {
|
||||
id: row.id as string,
|
||||
plantId: row.plant_id as string,
|
||||
name: row.name as string,
|
||||
scientificName: row.scientific_name as string,
|
||||
causalAgentType: row.causal_agent_type as CausalAgentType,
|
||||
description: row.description as string,
|
||||
symptoms: JSON.parse(row.symptoms as string) as string[],
|
||||
causes: JSON.parse(row.causes as string) as string[],
|
||||
treatment: JSON.parse(row.treatment as string) as string[],
|
||||
prevention: JSON.parse(row.prevention as string) as string[],
|
||||
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
|
||||
severity: row.severity as Severity,
|
||||
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Plant object to database column values */
|
||||
export function plantToRow(plant: Plant): Record<string, InValue> {
|
||||
return {
|
||||
id: plant.id,
|
||||
common_name: plant.commonName,
|
||||
scientific_name: plant.scientificName,
|
||||
family: plant.family,
|
||||
category: plant.category,
|
||||
care_summary: plant.careSummary,
|
||||
image_url: plant.imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Disease object to database column values */
|
||||
export function diseaseToRow(disease: Disease & { sourceUrl?: string }): Record<string, InValue> {
|
||||
return {
|
||||
id: disease.id,
|
||||
plant_id: disease.plantId,
|
||||
name: disease.name,
|
||||
scientific_name: disease.scientificName,
|
||||
causal_agent_type: disease.causalAgentType,
|
||||
description: disease.description,
|
||||
symptoms: JSON.stringify(disease.symptoms),
|
||||
causes: JSON.stringify(disease.causes),
|
||||
treatment: JSON.stringify(disease.treatment),
|
||||
prevention: JSON.stringify(disease.prevention),
|
||||
lookalike_ids: JSON.stringify(disease.lookalikeDiseaseIds),
|
||||
severity: disease.severity,
|
||||
source_url: disease.sourceUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Query helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Insert or replace a plant */
|
||||
export async function upsertPlant(plant: Plant) {
|
||||
const db = getDb();
|
||||
const row = plantToRow(plant);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
|
||||
await db.execute(`INSERT OR REPLACE INTO plants (${columns}) VALUES (${placeholders})`, values);
|
||||
}
|
||||
|
||||
/** Insert or replace a disease */
|
||||
export async function upsertDisease(disease: Disease & { sourceUrl?: string }) {
|
||||
const db = getDb();
|
||||
const row = diseaseToRow(disease);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
|
||||
await db.execute(`INSERT OR REPLACE INTO diseases (${columns}) VALUES (${placeholders})`, values);
|
||||
}
|
||||
|
||||
/** Bulk insert plants in a transaction */
|
||||
export async function bulkUpsertPlants(plants: Plant[]) {
|
||||
const db = getDb();
|
||||
const tx = await db.transaction("write");
|
||||
try {
|
||||
for (const plant of plants) {
|
||||
const row = plantToRow(plant);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
await tx.execute({
|
||||
sql: `INSERT OR REPLACE INTO plants (${columns}) VALUES (${placeholders})`,
|
||||
args: values,
|
||||
});
|
||||
}
|
||||
await tx.commit();
|
||||
console.log(`[DB] Inserted/replaced ${plants.length} plants`);
|
||||
} catch (err) {
|
||||
await tx.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk insert diseases in a transaction */
|
||||
export async function bulkUpsertDiseases(diseases: Array<Disease & { sourceUrl?: string }>) {
|
||||
const db = getDb();
|
||||
const tx = await db.transaction("write");
|
||||
try {
|
||||
for (const disease of diseases) {
|
||||
const row = diseaseToRow(disease);
|
||||
const keys = Object.keys(row);
|
||||
const columns = keys.join(", ");
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const values = keys.map((k) => row[k]);
|
||||
await tx.execute({
|
||||
sql: `INSERT OR REPLACE INTO diseases (${columns}) VALUES (${placeholders})`,
|
||||
args: values,
|
||||
});
|
||||
}
|
||||
await tx.commit();
|
||||
console.log(`[DB] Inserted/replaced ${diseases.length} diseases`);
|
||||
} catch (err) {
|
||||
await tx.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all plants */
|
||||
export async function getAllPlants(): Promise<Plant[]> {
|
||||
const db = getDb();
|
||||
const result = await db.execute("SELECT * FROM plants ORDER BY common_name");
|
||||
return result.rows.map((r) => rowToPlant(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Get all diseases (optionally filtered by plant_id) */
|
||||
export async function getDiseases(plantId?: string): Promise<Disease[]> {
|
||||
const db = getDb();
|
||||
let sql = "SELECT * FROM diseases";
|
||||
const params: InValue[] = [];
|
||||
if (plantId) {
|
||||
sql += " WHERE plant_id = ?";
|
||||
params.push(plantId);
|
||||
}
|
||||
sql += " ORDER BY name";
|
||||
const result = await db.execute(sql, params);
|
||||
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Get a single plant by ID */
|
||||
export async function getPlantById(plantId: string): Promise<Plant | null> {
|
||||
const db = getDb();
|
||||
const result = await db.execute("SELECT * FROM plants WHERE id = ?", [plantId]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToPlant(result.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** Get a single disease by ID */
|
||||
export async function getDiseaseById(diseaseId: string): Promise<Disease | null> {
|
||||
const db = getDb();
|
||||
const result = await db.execute("SELECT * FROM diseases WHERE id = ?", [diseaseId]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToDisease(result.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** Search diseases via FTS */
|
||||
export async function searchDiseasesFts(searchTerm: string): Promise<Disease[]> {
|
||||
const db = getDb();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
`SELECT d.* FROM diseases d
|
||||
JOIN diseases_fts fts ON d.rowid = fts.rowid
|
||||
WHERE diseases_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 50`,
|
||||
[searchTerm],
|
||||
);
|
||||
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
|
||||
} catch {
|
||||
// FTS might not be populated yet; fall back to LIKE search
|
||||
const likeTerm = `%${searchTerm}%`;
|
||||
const result = await db.execute(
|
||||
`SELECT * FROM diseases
|
||||
WHERE name LIKE ? OR description LIKE ? OR scientific_name LIKE ?
|
||||
LIMIT 50`,
|
||||
[likeTerm, likeTerm, likeTerm],
|
||||
);
|
||||
return result.rows.map((r) => rowToDisease(r as Record<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
/** Get database stats */
|
||||
export async function getDbStats(): Promise<{
|
||||
plants: number;
|
||||
diseases: number;
|
||||
byType: Record<string, number>;
|
||||
bySeverity: Record<string, number>;
|
||||
}> {
|
||||
const db = getDb();
|
||||
const plantCount = await db.execute("SELECT COUNT(*) as cnt FROM plants");
|
||||
const diseaseCount = await db.execute("SELECT COUNT(*) as cnt FROM diseases");
|
||||
const byType = await db.execute(
|
||||
"SELECT causal_agent_type, COUNT(*) as cnt FROM diseases GROUP BY causal_agent_type",
|
||||
);
|
||||
const bySeverity = await db.execute(
|
||||
"SELECT severity, COUNT(*) as cnt FROM diseases GROUP BY severity",
|
||||
);
|
||||
|
||||
return {
|
||||
plants: plantCount.rows[0].cnt as number,
|
||||
diseases: diseaseCount.rows[0].cnt as number,
|
||||
byType: Object.fromEntries(byType.rows.map((r) => [r.causal_agent_type, r.cnt as number])),
|
||||
bySeverity: Object.fromEntries(bySeverity.rows.map((r) => [r.severity, r.cnt as number])),
|
||||
};
|
||||
}
|
||||
69
src/lib/db/index.ts
Normal file
69
src/lib/db/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Drizzle ORM Database Client for Turso/libSQL.
|
||||
*
|
||||
* Provides the configured drizzle instance and convenience helpers.
|
||||
* Reads DATABASE_URL and DATABASE_TOKEN from environment.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import { createClient } from "@libsql/client";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type {
|
||||
PlantRow,
|
||||
PlantInsert,
|
||||
DiseaseRow,
|
||||
DiseaseInsert,
|
||||
FlaggedContentRow,
|
||||
FlaggedContentInsert,
|
||||
} from "./schema";
|
||||
|
||||
export { schema };
|
||||
|
||||
let _db: LibSQLDatabase<typeof schema> | null = null;
|
||||
let _client: ReturnType<typeof createClient> | null = null;
|
||||
|
||||
/** Get or create the Drizzle database instance (singleton). */
|
||||
export function getDb(): LibSQLDatabase<typeof schema> {
|
||||
if (_db) return _db;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const token = process.env.DATABASE_TOKEN;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"DATABASE_TOKEN is not set. Check your .env.development or .env.production file.",
|
||||
);
|
||||
}
|
||||
|
||||
_client = createClient({ url, authToken: token });
|
||||
_db = drizzle(_client, { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
/** Check database connectivity. */
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = await db.run(sql`SELECT 1 AS ok`);
|
||||
return result.rowsAffected >= 0;
|
||||
} catch (err) {
|
||||
console.error("[DB] Connection failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the client connection. */
|
||||
export function closeDb() {
|
||||
if (_client) {
|
||||
_client.close();
|
||||
_client = null;
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
173
src/lib/db/schema.ts
Normal file
173
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Drizzle ORM Schema for the Plant Disease Knowledge Base.
|
||||
*
|
||||
* Uses Turso (libSQL) with SQLite dialect.
|
||||
* Arrays (symptoms, causes, treatment, prevention, lookalike_ids)
|
||||
* are stored as JSON text columns and typed via Drizzle's $type().
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// ─── Plants Table ────────────────────────────────────────────────────────────
|
||||
|
||||
export const plants = sqliteTable(
|
||||
"plants",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
commonName: text("common_name").notNull(),
|
||||
scientificName: text("scientific_name").notNull(),
|
||||
family: text("family").notNull(),
|
||||
category: text("category").notNull(),
|
||||
careSummary: text("care_summary").notNull().default(""),
|
||||
imageUrl: text("image_url").notNull().default(""),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
categoryIdx: index("idx_plants_category").on(table.category),
|
||||
commonNameIdx: index("idx_plants_common_name").on(table.commonName),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Diseases Table ──────────────────────────────────────────────────────────
|
||||
|
||||
export const diseases = sqliteTable(
|
||||
"diseases",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
plantId: text("plant_id")
|
||||
.notNull()
|
||||
.references(() => plants.id),
|
||||
name: text("name").notNull(),
|
||||
scientificName: text("scientific_name").notNull().default(""),
|
||||
causalAgentType: text("causal_agent_type", {
|
||||
enum: ["fungal", "bacterial", "viral", "environmental"],
|
||||
}).notNull(),
|
||||
description: text("description").notNull().default(""),
|
||||
symptoms: text("symptoms", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
causes: text("causes", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
prevention: text("prevention", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||
prevalence: text("prevalence", {
|
||||
enum: ["common", "uncommon", "rare", "very_rare"],
|
||||
})
|
||||
.notNull()
|
||||
.default("uncommon"),
|
||||
prevalenceScore: integer("prevalence_score").notNull().default(0),
|
||||
severity: text("severity", {
|
||||
enum: ["low", "moderate", "high", "critical"],
|
||||
}).notNull(),
|
||||
imageUrl: text("image_url").notNull().default(""),
|
||||
sourceUrl: text("source_url").notNull().default(""),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
|
||||
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
|
||||
severityIdx: index("idx_diseases_severity").on(table.severity),
|
||||
prevalenceIdx: index("idx_diseases_prevalence").on(table.prevalence),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Scrape Sources Table ────────────────────────────────────────────────────
|
||||
|
||||
export const scrapeSources = sqliteTable("scrape_sources", {
|
||||
id: text("id").primaryKey(),
|
||||
sourceType: text("source_type", {
|
||||
enum: ["wikipedia", "university_extension", "cabi", "other"],
|
||||
}).notNull(),
|
||||
sourceUrl: text("source_url").notNull(),
|
||||
lastScrapedAt: text("last_scraped_at"),
|
||||
entriesCount: integer("entries_count").default(0),
|
||||
status: text("status", { enum: ["pending", "success", "error"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
errorMessage: text("error_message"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
});
|
||||
|
||||
// ─── Plant Views Table ───────────────────────────────────────────────────────
|
||||
|
||||
export const plantViews = sqliteTable(
|
||||
"plant_views",
|
||||
{
|
||||
plantId: text("plant_id")
|
||||
.primaryKey()
|
||||
.references(() => plants.id),
|
||||
viewCount: integer("view_count").notNull().default(0),
|
||||
},
|
||||
(table) => ({
|
||||
viewCountIdx: index("idx_plant_views_count").on(table.viewCount),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Flagged Content Table ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stores user-flagged content for manual review.
|
||||
* content_type: what kind of content is flagged
|
||||
* content_id: the ID of the plant or disease
|
||||
* field_name: specific field being flagged (e.g., "image", "symptoms", "causes", "treatment", "prevention")
|
||||
* flag_count: number of times this item has been flagged
|
||||
*/
|
||||
export const flaggedContent = sqliteTable(
|
||||
"flagged_content",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
contentType: text("content_type", {
|
||||
enum: [
|
||||
"plant_image",
|
||||
"disease_image",
|
||||
"disease_description",
|
||||
"disease_symptoms",
|
||||
"disease_causes",
|
||||
"disease_treatment",
|
||||
"disease_prevention",
|
||||
],
|
||||
}).notNull(),
|
||||
contentId: text("content_id").notNull(),
|
||||
fieldName: text("field_name").notNull(),
|
||||
notes: text("notes").default(""),
|
||||
flagCount: integer("flag_count").notNull().default(1),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
},
|
||||
(table) => ({
|
||||
contentTypeIdx: index("idx_flagged_content_type").on(table.contentType),
|
||||
contentIdIdx: index("idx_flagged_content_id").on(table.contentId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Type helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export type FlaggedContentRow = typeof flaggedContent.$inferSelect;
|
||||
export type FlaggedContentInsert = typeof flaggedContent.$inferInsert;
|
||||
|
||||
// ─── Relation Inference ──────────────────────────────────────────────────────
|
||||
|
||||
export const plantsRelations = {};
|
||||
export const diseasesRelations = {};
|
||||
|
||||
// ─── Type helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlantRow = typeof plants.$inferSelect;
|
||||
export type PlantInsert = typeof plants.$inferInsert;
|
||||
export type DiseaseRow = typeof diseases.$inferSelect;
|
||||
export type DiseaseInsert = typeof diseases.$inferInsert;
|
||||
47
src/lib/display-helpers.ts
Normal file
47
src/lib/display-helpers.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Display helpers for the browse UI that bridge the DB types
|
||||
* to display-friendly values (emoji icons, descriptions).
|
||||
*/
|
||||
|
||||
const CATEGORY_EMOJIS: Record<string, string> = {
|
||||
vegetable: "🥬",
|
||||
fruit: "🍎",
|
||||
herb: "🌿",
|
||||
flower: "🌸",
|
||||
houseplant: "🪴",
|
||||
succulent: "🌵",
|
||||
tree: "🌳",
|
||||
};
|
||||
|
||||
const FALLBACK_EMOJI = "🌱";
|
||||
|
||||
export function getEmojiForCategory(category: string): string {
|
||||
return CATEGORY_EMOJIS[category] ?? FALLBACK_EMOJI;
|
||||
}
|
||||
|
||||
export function getPlantDescription(
|
||||
commonName: string,
|
||||
scientificName: string,
|
||||
category: string,
|
||||
family: string,
|
||||
): string {
|
||||
return `${commonName} (${scientificName}) is a ${category} in the ${family} family. Preventative care and early identification of diseases are key to keeping your ${commonName.toLowerCase()} healthy.`;
|
||||
}
|
||||
|
||||
export function getDescriptionForCategory(category: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
vegetable:
|
||||
"Vegetables are garden favorites grown for their edible parts. They can be affected by various fungal, bacterial, and viral diseases that impact yield and quality.",
|
||||
fruit:
|
||||
"Fruit plants produce delicious harvests but require attention to disease management for optimal production.",
|
||||
herb: "Herbs are aromatic plants used in cooking and medicine. Most are relatively disease-resistant but can be affected in humid conditions.",
|
||||
flower:
|
||||
"Ornamental flowers add beauty to gardens. They may be susceptible to various foliar and root diseases.",
|
||||
houseplant:
|
||||
"Houseplants bring nature indoors. The most common issues are overwatering, insufficient light, and fungal leaf spots.",
|
||||
succulent:
|
||||
"Succulents store water in their leaves and stems. Overwatering is the most common cause of problems.",
|
||||
tree: "Trees provide shade, fruit, and beauty. They can be affected by cankers, rots, wilts, and foliar diseases.",
|
||||
};
|
||||
return descriptions[category] ?? `This plant belongs to the ${category} category.`;
|
||||
}
|
||||
241
src/lib/image-processing.test.ts
Normal file
241
src/lib/image-processing.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Unit tests for lib/image-processing.ts
|
||||
*
|
||||
* Tests:
|
||||
* - resizeImage() produces 224×224 output for any input aspect ratio
|
||||
* - imageToTensor() output length equals 3 * 224 * 224
|
||||
* - Normalization produces values in expected range
|
||||
* - validateImageFile rejects invalid types and oversized files
|
||||
* - tensorToBase64 / base64ToTensor round-trip
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
resizeImage,
|
||||
imageToTensor,
|
||||
tensorToBase64,
|
||||
base64ToTensor,
|
||||
getTensorShape,
|
||||
validateImageFile,
|
||||
MAX_FILE_SIZE,
|
||||
MIN_DIMENSION,
|
||||
ALLOWED_MIME_TYPES,
|
||||
} from "@/lib/image-processing";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a mock ImageData at given dimensions */
|
||||
function createMockImageData(
|
||||
width: number,
|
||||
height: number,
|
||||
fillR = 128,
|
||||
fillG = 64,
|
||||
fillB = 32,
|
||||
): ImageData {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
data[i * 4] = fillR;
|
||||
data[i * 4 + 1] = fillG;
|
||||
data[i * 4 + 2] = fillB;
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
/** Create a mock File with given properties */
|
||||
function createMockFile({
|
||||
name = "test.jpg",
|
||||
type = "image/jpeg",
|
||||
size = 1024,
|
||||
}: Partial<File> & Pick<File, "name" | "type" | "size"> = {}): File {
|
||||
// Use ArrayBuffer to control actual file size for large/empty tests
|
||||
let content: BlobPart;
|
||||
if (size === 0) {
|
||||
content = "";
|
||||
} else if (size > 1024) {
|
||||
// For large files, create a buffer of the right size
|
||||
content = new Uint8Array(size);
|
||||
} else {
|
||||
content = "dummy";
|
||||
}
|
||||
return new File([content], name, { type });
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateImageFile", () => {
|
||||
it("accepts valid JPEG", () => {
|
||||
const file = createMockFile({ name: "photo.jpg", type: "image/jpeg", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid PNG", () => {
|
||||
const file = createMockFile({ name: "photo.png", type: "image/png", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid WebP", () => {
|
||||
const file = createMockFile({ name: "photo.webp", type: "image/webp", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported MIME type", () => {
|
||||
const file = createMockFile({ name: "document.txt", type: "text/plain", size: 1024 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Unsupported file type");
|
||||
});
|
||||
|
||||
it("rejects files larger than 10 MB", () => {
|
||||
const file = createMockFile({
|
||||
name: "huge.jpg",
|
||||
type: "image/jpeg",
|
||||
size: 11 * 1024 * 1024, // 11 MB
|
||||
});
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("too large");
|
||||
});
|
||||
|
||||
it("rejects empty files", () => {
|
||||
const file = createMockFile({ name: "empty.jpg", type: "image/jpeg", size: 0 });
|
||||
const result = validateImageFile(file);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageToTensor", () => {
|
||||
it("produces correct tensor length for 224×224", () => {
|
||||
const imageData = createMockImageData(224, 224);
|
||||
const tensor = imageToTensor(imageData);
|
||||
expect(tensor.length).toBe(3 * 224 * 224);
|
||||
});
|
||||
|
||||
it("produces correct tensor length for 299×299", () => {
|
||||
const imageData = createMockImageData(299, 299);
|
||||
const tensor = imageToTensor(imageData);
|
||||
expect(tensor.length).toBe(3 * 299 * 299);
|
||||
});
|
||||
|
||||
it("produces Float32Array", () => {
|
||||
const imageData = createMockImageData(224, 224);
|
||||
const tensor = imageToTensor(imageData);
|
||||
expect(tensor).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("normalizes pixel values with ImageNet mean/std", () => {
|
||||
// All pixels set to 128/255 = 0.502
|
||||
const imageData = createMockImageData(224, 224, 128, 128, 128);
|
||||
const tensor = imageToTensor(imageData);
|
||||
|
||||
// With ImageNet mean [0.485, 0.456, 0.406] and std [0.229, 0.224, 0.225]
|
||||
// R: (0.502 - 0.485) / 0.229 ≈ 0.074
|
||||
const expectedR = (128 / 255 - 0.485) / 0.229;
|
||||
expect(tensor[0]).toBeCloseTo(expectedR, 3);
|
||||
|
||||
// G: (0.502 - 0.456) / 0.224 ≈ 0.205
|
||||
const totalPixels = 224 * 224;
|
||||
const expectedG = (128 / 255 - 0.456) / 0.224;
|
||||
expect(tensor[totalPixels]).toBeCloseTo(expectedG, 3);
|
||||
|
||||
// B: (0.502 - 0.406) / 0.225 ≈ 0.427
|
||||
const expectedB = (128 / 255 - 0.406) / 0.225;
|
||||
expect(tensor[2 * totalPixels]).toBeCloseTo(expectedB, 3);
|
||||
});
|
||||
|
||||
it("preserves channel separation (R, G, B in separate channels)", () => {
|
||||
// R=255, G=0, B=0 (pure red)
|
||||
const imageData = createMockImageData(224, 224, 255, 0, 0);
|
||||
const tensor = imageToTensor(imageData);
|
||||
const totalPixels = 224 * 224;
|
||||
|
||||
// After ImageNet normalization:
|
||||
// R: (1.0 - 0.485) / 0.229 ≈ 2.25
|
||||
// G: (0.0 - 0.456) / 0.224 ≈ -2.04
|
||||
// B: (0.0 - 0.406) / 0.225 ≈ -1.80
|
||||
const rVal = tensor[0];
|
||||
const gVal = tensor[totalPixels];
|
||||
const bVal = tensor[2 * totalPixels];
|
||||
|
||||
// R is positive (high), G and B are negative (low)
|
||||
expect(rVal).toBeGreaterThan(2);
|
||||
expect(gVal).toBeLessThan(-1);
|
||||
expect(bVal).toBeLessThan(-1);
|
||||
// R is highest
|
||||
expect(rVal).toBeGreaterThan(bVal);
|
||||
expect(rVal).toBeGreaterThan(gVal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tensorToBase64 / base64ToTensor", () => {
|
||||
it("round-trips tensor data correctly", () => {
|
||||
const imageData = createMockImageData(160, 160, 100, 150, 200);
|
||||
const original = imageToTensor(imageData);
|
||||
|
||||
const base64 = tensorToBase64(original);
|
||||
const decoded = base64ToTensor(base64);
|
||||
|
||||
expect(decoded.tensor.length).toBe(original.length);
|
||||
expect(decoded.shape).toEqual([3, 160, 160]);
|
||||
|
||||
// Check a few values match
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(decoded.tensor[i]).toBeCloseTo(original[i], 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves custom shape", () => {
|
||||
const tensor = new Float32Array(3 * 299 * 299);
|
||||
const base64 = tensorToBase64(tensor, [3, 299, 299]);
|
||||
const decoded = base64ToTensor(base64);
|
||||
expect(decoded.shape).toEqual([3, 299, 299]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTensorShape", () => {
|
||||
it("returns [1, 3, 160, 160] by default", () => {
|
||||
const shape = getTensorShape();
|
||||
expect(shape).toEqual([1, 3, 160, 160]);
|
||||
});
|
||||
|
||||
it("returns NCHW layout", () => {
|
||||
const shape = getTensorShape();
|
||||
expect(shape.length).toBe(4);
|
||||
expect(shape[0]).toBe(1); // batch
|
||||
expect(shape[1]).toBe(3); // channels
|
||||
expect(shape[2]).toBe(160); // height (model input size)
|
||||
expect(shape[3]).toBe(160); // width (model input size)
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeImage", () => {
|
||||
it("is an async function", () => {
|
||||
expect(typeof resizeImage).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts a File and size parameter", () => {
|
||||
const file = createMockFile({ name: "test.jpg", type: "image/jpeg", size: 1024 });
|
||||
// In jsdom, this will use the mock canvas — we verify the function signature
|
||||
expect(resizeImage).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("MAX_FILE_SIZE is 10 MB", () => {
|
||||
expect(MAX_FILE_SIZE).toBe(10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("MIN_DIMENSION is 150", () => {
|
||||
expect(MIN_DIMENSION).toBe(150);
|
||||
});
|
||||
|
||||
it("ALLOWED_MIME_TYPES includes PNG, JPEG, and WebP", () => {
|
||||
expect(ALLOWED_MIME_TYPES).toContain("image/png");
|
||||
expect(ALLOWED_MIME_TYPES).toContain("image/jpeg");
|
||||
expect(ALLOWED_MIME_TYPES).toContain("image/webp");
|
||||
});
|
||||
});
|
||||
244
src/lib/image-processing.ts
Normal file
244
src/lib/image-processing.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Client-side image preprocessing pipeline.
|
||||
*
|
||||
* Resizes images to model-expected dimensions (160×160 by default),
|
||||
* converts RGBA → RGB, normalizes pixel values, and produces flat
|
||||
* Float32Array tensors ready for ML inference or base64 transmission.
|
||||
*
|
||||
* Tensor shape: [1, 3, 160, 160] — NCHW layout matching MobileNetV2.
|
||||
*
|
||||
* Configurable via env:
|
||||
* IMAGE_MODEL_SIZE — target dimension (default 160)
|
||||
* IMAGE_MEAN_R/G/B — per-channel mean for normalization (default 0.485, 0.456, 0.406 — ImageNet)
|
||||
* IMAGE_STD_R/G/B — per-channel std for normalization (default 0.229, 0.224, 0.225 — ImageNet)
|
||||
*/
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MODEL_SIZE = 160;
|
||||
const DEFAULT_MEAN = [0.485, 0.456, 0.406] as const; // ImageNet RGB means
|
||||
const DEFAULT_STD = [0.229, 0.224, 0.225] as const; // ImageNet RGB stds
|
||||
|
||||
function getConfig(): {
|
||||
size: number;
|
||||
mean: readonly [number, number, number];
|
||||
std: readonly [number, number, number];
|
||||
} {
|
||||
// These env vars are exposed via next.config.ts / .env.local
|
||||
const size = parseInt(
|
||||
typeof process !== "undefined" && process.env?.IMAGE_MODEL_SIZE
|
||||
? process.env.IMAGE_MODEL_SIZE
|
||||
: String(DEFAULT_MODEL_SIZE),
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
size: isNaN(size) ? DEFAULT_MODEL_SIZE : size,
|
||||
mean: DEFAULT_MEAN,
|
||||
std: DEFAULT_STD,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum file size accepted (10 MB) */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** Minimum image dimensions (150×150) */
|
||||
export const MIN_DIMENSION = 150;
|
||||
|
||||
/** Allowed MIME types */
|
||||
export const ALLOWED_MIME_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/webp"] as const;
|
||||
|
||||
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];
|
||||
|
||||
/** Maximum number of ephemeral uploads to keep */
|
||||
export const MAX_UPLOADS = 100;
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate that a file is an acceptable image for upload.
|
||||
* Returns `{ ok: true }` or `{ ok: false, error: string }`.
|
||||
*/
|
||||
export function validateImageFile(file: File): { ok: true } | { ok: false; error: string } {
|
||||
// MIME type check
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type as AllowedMimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported file type "${file.type}". Allowed: PNG, JPG, WebP.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Size check
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const mb = (file.size / (1024 * 1024)).toFixed(1);
|
||||
return { ok: false, error: `File too large (${mb} MB). Maximum is 10 MB.` };
|
||||
}
|
||||
|
||||
// Zero-size check
|
||||
if (file.size === 0) {
|
||||
return { ok: false, error: "File is empty." };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that an image file meets minimum dimension requirements.
|
||||
* Returns a promise resolving to `{ ok: true }` or `{ ok: false, error: string }`.
|
||||
*/
|
||||
export function validateImageDimensions(
|
||||
file: File,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.width < MIN_DIMENSION || img.height < MIN_DIMENSION) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `Image too small (${img.width}×${img.height}). Minimum is ${MIN_DIMENSION}×${MIN_DIMENSION}.`,
|
||||
});
|
||||
} else {
|
||||
resolve({ ok: true });
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ ok: false, error: "Failed to read image dimensions." });
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Resize ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resize an image file to the target model size using an offscreen canvas.
|
||||
* Uses bilinear interpolation via canvas drawing.
|
||||
*
|
||||
* @param file - Source image file
|
||||
* @param size - Target dimension (square). Defaults to IMAGE_MODEL_SIZE env or 224.
|
||||
* @returns ImageData at exactly `size × size`
|
||||
*/
|
||||
export async function resizeImage(file: File, size: number = getConfig().size): Promise<ImageData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas 2D context."));
|
||||
return;
|
||||
}
|
||||
// Bilinear resize via drawImage
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const imageData = ctx.getImageData(0, 0, size, size);
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(imageData);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
reject(new Error("Failed to load image for resizing."));
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tensor Conversion ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert ImageData (RGBA) to a flat Float32Array tensor in RGB layout.
|
||||
* Drops the alpha channel, normalizes pixel values to [0, 1].
|
||||
*
|
||||
* Output layout: flat array of length 3 × width × height.
|
||||
* Channel order: RRR...GGG...BBB... (channel-first, like PyTorch NCHW without batch dim).
|
||||
*
|
||||
* @param imageData - Source ImageData from resizeImage()
|
||||
* @returns Float32Array of length 3 × size × size with values in [0, 1]
|
||||
*/
|
||||
export function imageToTensor(imageData: ImageData): Float32Array {
|
||||
const { width, height, data } = imageData;
|
||||
const totalPixels = width * height;
|
||||
const config = getConfig();
|
||||
const { mean, std } = config;
|
||||
|
||||
// Allocate channel-first tensor: [3, H, W]
|
||||
const tensor = new Float32Array(3 * totalPixels);
|
||||
|
||||
// Extract R, G, B channels (skip alpha)
|
||||
const rChannel = tensor.subarray(0, totalPixels);
|
||||
const gChannel = tensor.subarray(totalPixels, 2 * totalPixels);
|
||||
const bChannel = tensor.subarray(2 * totalPixels, 3 * totalPixels);
|
||||
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const idx = i * 4; // RGBA stride
|
||||
rChannel[i] = data[idx] / 255;
|
||||
gChannel[i] = data[idx + 1] / 255;
|
||||
bChannel[i] = data[idx + 2] / 255;
|
||||
}
|
||||
|
||||
// Normalize with ImageNet mean/std
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const channel = c === 0 ? rChannel : c === 1 ? gChannel : bChannel;
|
||||
const m = mean[c];
|
||||
const s = std[c];
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
channel[i] = (channel[i] - m) / s;
|
||||
}
|
||||
}
|
||||
|
||||
return tensor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected tensor shape for the current model configuration.
|
||||
* Returns [batch, channels, height, width] = [1, 3, size, size].
|
||||
*/
|
||||
export function getTensorShape(): [number, number, number, number] {
|
||||
const size = getConfig().size;
|
||||
return [1, 3, size, size];
|
||||
}
|
||||
|
||||
// ─── Base64 Encoding ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode a Float32Array tensor to a base64 string for transmission.
|
||||
* Wraps the binary data in a simple JSON envelope with shape metadata.
|
||||
*
|
||||
* @param tensor - Flat Float32Array from imageToTensor()
|
||||
* @param shape - Tensor shape [C, H, W], defaults to [3, size, size]
|
||||
* @returns base64-encoded JSON string
|
||||
*/
|
||||
export function tensorToBase64(
|
||||
tensor: Float32Array,
|
||||
shape: [number, number, number] = [3, getConfig().size, getConfig().size],
|
||||
): string {
|
||||
const envelope = {
|
||||
shape,
|
||||
data: Array.from(tensor),
|
||||
};
|
||||
const json = JSON.stringify(envelope);
|
||||
return btoa(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 tensor string back to a Float32Array.
|
||||
*
|
||||
* @param base64 - Base64 string from tensorToBase64()
|
||||
* @returns { tensor, shape }
|
||||
*/
|
||||
export function base64ToTensor(base64: string): {
|
||||
tensor: Float32Array;
|
||||
shape: [number, number, number];
|
||||
} {
|
||||
const json = atob(base64);
|
||||
const envelope = JSON.parse(json);
|
||||
return {
|
||||
tensor: new Float32Array(envelope.data),
|
||||
shape: envelope.shape as [number, number, number],
|
||||
};
|
||||
}
|
||||
0
src/lib/ml/.gitkeep
Normal file
0
src/lib/ml/.gitkeep
Normal file
342
src/lib/ml/confidence.test.ts
Normal file
342
src/lib/ml/confidence.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Unit tests for lib/ml/confidence.ts
|
||||
*
|
||||
* Tests:
|
||||
* - softmax([1, 2, 3]) sums to ~1.0
|
||||
* - softmaxFloat32 produces same results as softmax
|
||||
* - calibrateConfidence(0.9) returns label "high"
|
||||
* - calibrateConfidence(0.6) returns label "medium"
|
||||
* - calibrateConfidence(0.3) returns label "low"
|
||||
* - getTopK returns exactly 5 entries sorted descending
|
||||
* - getTopKFloat32 returns exactly 5 entries sorted descending
|
||||
* - filterByConfidence removes predictions below threshold
|
||||
* - Numerically stable softmax handles large logits
|
||||
* - Degenerate softmax (all -Infinity) returns uniform distribution
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
softmax,
|
||||
softmaxFloat32,
|
||||
calibrateConfidence,
|
||||
getConfidenceLabel,
|
||||
getTopK,
|
||||
getTopKFloat32,
|
||||
filterByConfidence,
|
||||
DEFAULT_MIN_CONFIDENCE,
|
||||
} from "@/lib/ml/confidence";
|
||||
|
||||
describe("softmax", () => {
|
||||
it("softmax([1, 2, 3]) sums to ~1.0", () => {
|
||||
const result = softmax([1, 2, 3]);
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 6);
|
||||
});
|
||||
|
||||
it("produces correct probability distribution", () => {
|
||||
const result = softmax([1, 2, 3]);
|
||||
// Higher input → higher probability
|
||||
expect(result[2]).toBeGreaterThan(result[1]);
|
||||
expect(result[1]).toBeGreaterThan(result[0]);
|
||||
// All positive
|
||||
expect(result.every(v => v > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles equal logits uniformly", () => {
|
||||
const result = softmax([1, 1, 1]);
|
||||
expect(result[0]).toBeCloseTo(1 / 3, 6);
|
||||
expect(result[1]).toBeCloseTo(1 / 3, 6);
|
||||
expect(result[2]).toBeCloseTo(1 / 3, 6);
|
||||
});
|
||||
|
||||
it("handles single element", () => {
|
||||
const result = softmax([5]);
|
||||
expect(result).toEqual([1.0]);
|
||||
});
|
||||
|
||||
it("handles large logits without overflow", () => {
|
||||
const result = softmax([1000, 1001, 1002]);
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 6);
|
||||
// The largest should dominate
|
||||
expect(result[2]).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("handles negative logits", () => {
|
||||
const result = softmax([-3, -2, -1]);
|
||||
const sum = result.reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 6);
|
||||
expect(result.every(v => v > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softmaxFloat32", () => {
|
||||
it("produces Float32Array output", () => {
|
||||
const logits = new Float32Array([1, 2, 3]);
|
||||
const result = softmaxFloat32(logits);
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("sums to ~1.0", () => {
|
||||
const logits = new Float32Array([1, 2, 3]);
|
||||
const result = softmaxFloat32(logits);
|
||||
const sum = Array.from(result).reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it("matches softmax for same input", () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const arrayResult = softmax(input);
|
||||
const float32Result = softmaxFloat32(new Float32Array(input));
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
expect(float32Result[i]).toBeCloseTo(arrayResult[i], 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles large arrays (95 classes)", () => {
|
||||
const logits = new Float32Array(95);
|
||||
for (let i = 0; i < 95; i++) {
|
||||
logits[i] = i * 0.1 - 4.75; // centered around 0
|
||||
}
|
||||
const result = softmaxFloat32(logits);
|
||||
const sum = Array.from(result).reduce((a, b) => a + b, 0);
|
||||
expect(sum).toBeCloseTo(1.0, 5);
|
||||
expect(result.length).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calibrateConfidence", () => {
|
||||
it("calibrateConfidence(0.9) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(0.9);
|
||||
expect(result.label).toBe("high");
|
||||
expect(result.raw).toBe(0.9);
|
||||
expect(result.adjusted).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.95) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(0.95);
|
||||
expect(result.label).toBe("high");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.8) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(0.8);
|
||||
expect(result.label).toBe("high");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.6) returns label 'medium'", () => {
|
||||
const result = calibrateConfidence(0.6);
|
||||
expect(result.label).toBe("medium");
|
||||
expect(result.adjusted).toBeGreaterThanOrEqual(0.5);
|
||||
expect(result.adjusted).toBeLessThan(0.8);
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.55) returns label 'medium'", () => {
|
||||
const result = calibrateConfidence(0.55);
|
||||
expect(result.label).toBe("medium");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.3) returns label 'low'", () => {
|
||||
const result = calibrateConfidence(0.3);
|
||||
expect(result.label).toBe("low");
|
||||
expect(result.adjusted).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.1) returns label 'low'", () => {
|
||||
const result = calibrateConfidence(0.1);
|
||||
expect(result.label).toBe("low");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(0.0) returns label 'low'", () => {
|
||||
const result = calibrateConfidence(0.0);
|
||||
expect(result.label).toBe("low");
|
||||
});
|
||||
|
||||
it("calibrateConfidence(1.0) returns label 'high'", () => {
|
||||
const result = calibrateConfidence(1.0);
|
||||
expect(result.label).toBe("high");
|
||||
expect(result.adjusted).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("adjusted confidence is rounded to 4 decimal places", () => {
|
||||
const result = calibrateConfidence(0.73);
|
||||
const decimalPlaces = result.adjusted.toString().split(".")[1]?.length || 0;
|
||||
expect(decimalPlaces).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("raw confidence is rounded to 4 decimal places", () => {
|
||||
const result = calibrateConfidence(0.73456789);
|
||||
expect(result.raw).toBe(0.7346);
|
||||
});
|
||||
|
||||
it("adjusted confidence is monotonically increasing with raw", () => {
|
||||
const low = calibrateConfidence(0.3);
|
||||
const mid = calibrateConfidence(0.6);
|
||||
const high = calibrateConfidence(0.9);
|
||||
expect(high.adjusted).toBeGreaterThan(mid.adjusted);
|
||||
expect(mid.adjusted).toBeGreaterThan(low.adjusted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfidenceLabel", () => {
|
||||
it("returns 'high' for score >= 0.8", () => {
|
||||
expect(getConfidenceLabel(0.8)).toBe("high");
|
||||
expect(getConfidenceLabel(0.85)).toBe("high");
|
||||
expect(getConfidenceLabel(1.0)).toBe("high");
|
||||
});
|
||||
|
||||
it("returns 'medium' for score >= 0.5 and < 0.8", () => {
|
||||
expect(getConfidenceLabel(0.5)).toBe("medium");
|
||||
expect(getConfidenceLabel(0.65)).toBe("medium");
|
||||
expect(getConfidenceLabel(0.79)).toBe("medium");
|
||||
});
|
||||
|
||||
it("returns 'low' for score < 0.5", () => {
|
||||
expect(getConfidenceLabel(0.0)).toBe("low");
|
||||
expect(getConfidenceLabel(0.49)).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopK", () => {
|
||||
it("returns exactly 5 entries by default", () => {
|
||||
const probs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
const result = getTopK(probs);
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("returns entries sorted by probability descending", () => {
|
||||
const probs = [0.1, 0.5, 0.3, 0.9, 0.2, 0.7, 0.4];
|
||||
const result = getTopK(probs);
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].probability).toBeGreaterThanOrEqual(result[i + 1].probability);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns correct class indices", () => {
|
||||
const probs = [0.1, 0.5, 0.3, 0.9, 0.2];
|
||||
const result = getTopK(probs, 3);
|
||||
expect(result[0].classIndex).toBe(3); // 0.9
|
||||
expect(result[1].classIndex).toBe(1); // 0.5
|
||||
expect(result[2].classIndex).toBe(2); // 0.3
|
||||
});
|
||||
|
||||
it("respects custom k value", () => {
|
||||
const probs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
const result = getTopK(probs, 3);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns all entries when k > array length", () => {
|
||||
const probs = [0.1, 0.2, 0.3];
|
||||
const result = getTopK(probs, 10);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("handles equal probabilities", () => {
|
||||
const probs = [0.3, 0.3, 0.3, 0.1, 0.1];
|
||||
const result = getTopK(probs, 3);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.every(p => p.probability === 0.3)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopKFloat32", () => {
|
||||
it("returns exactly 5 entries by default", () => {
|
||||
const probs = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]);
|
||||
const result = getTopKFloat32(probs);
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("returns entries sorted by probability descending", () => {
|
||||
const probs = new Float32Array([0.1, 0.5, 0.3, 0.9, 0.2, 0.7, 0.4]);
|
||||
const result = getTopKFloat32(probs);
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].probability).toBeGreaterThanOrEqual(result[i + 1].probability);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns correct class indices", () => {
|
||||
const probs = new Float32Array([0.1, 0.5, 0.3, 0.9, 0.2]);
|
||||
const result = getTopKFloat32(probs, 3);
|
||||
expect(result[0].classIndex).toBe(3);
|
||||
expect(result[1].classIndex).toBe(1);
|
||||
expect(result[2].classIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("handles large arrays (95 classes)", () => {
|
||||
const probs = new Float32Array(95);
|
||||
// Set a few high values
|
||||
probs[0] = 0.4;
|
||||
probs[5] = 0.3;
|
||||
probs[10] = 0.2;
|
||||
probs[20] = 0.05;
|
||||
probs[30] = 0.03;
|
||||
// Rest are small
|
||||
for (let i = 0; i < 95; i++) {
|
||||
if (probs[i] === 0) probs[i] = 0.001;
|
||||
}
|
||||
const result = getTopKFloat32(probs, 5);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].classIndex).toBe(0);
|
||||
expect(result[0].probability).toBeCloseTo(0.4, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByConfidence", () => {
|
||||
it("removes predictions below default threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.5 },
|
||||
{ classIndex: 1, probability: 0.3 },
|
||||
{ classIndex: 2, probability: 0.1 },
|
||||
{ classIndex: 3, probability: 0.05 },
|
||||
];
|
||||
const result = filterByConfidence(predictions);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].classIndex).toBe(0);
|
||||
expect(result[1].classIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("uses custom threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.5 },
|
||||
{ classIndex: 1, probability: 0.3 },
|
||||
{ classIndex: 2, probability: 0.1 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.25);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when all below threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.1 },
|
||||
{ classIndex: 1, probability: 0.05 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.2);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all predictions when all above threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.5 },
|
||||
{ classIndex: 1, probability: 0.3 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.1);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps predictions at exactly the threshold", () => {
|
||||
const predictions = [
|
||||
{ classIndex: 0, probability: 0.15 },
|
||||
{ classIndex: 1, probability: 0.14 },
|
||||
];
|
||||
const result = filterByConfidence(predictions, 0.15);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].classIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MIN_CONFIDENCE", () => {
|
||||
it("is 0.15", () => {
|
||||
expect(DEFAULT_MIN_CONFIDENCE).toBe(0.15);
|
||||
});
|
||||
});
|
||||
204
src/lib/ml/confidence.ts
Normal file
204
src/lib/ml/confidence.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Confidence calibration and threshold logic for ML predictions.
|
||||
*
|
||||
* Provides softmax conversion, confidence calibration, and threshold-based
|
||||
* filtering of predictions.
|
||||
*/
|
||||
|
||||
import type { ConfidenceLabel, ConfidenceResult, RawPrediction } from "@/lib/types";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimum confidence threshold — predictions below this are filtered out */
|
||||
export const DEFAULT_MIN_CONFIDENCE = 0.15;
|
||||
|
||||
/** Confidence label thresholds */
|
||||
const CONFIDENCE_THRESHOLDS = {
|
||||
HIGH: 0.8,
|
||||
MEDIUM: 0.5,
|
||||
} as const;
|
||||
|
||||
// ─── Softmax ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply softmax to a vector of logits, converting them to probabilities.
|
||||
*
|
||||
* Uses numerically stable softmax: subtracts max before exp() to avoid overflow.
|
||||
*
|
||||
* @param logits - Array of raw model output values
|
||||
* @returns Array of probabilities that sum to ~1.0
|
||||
*/
|
||||
export function softmax(logits: number[]): number[] {
|
||||
const maxLogit = Math.max(...logits);
|
||||
const expValues = logits.map((l) => Math.exp(l - maxLogit));
|
||||
const sumExp = expValues.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (sumExp === 0) {
|
||||
// Degenerate case: all logits are -Infinity
|
||||
const uniform = 1 / logits.length;
|
||||
return logits.map(() => uniform);
|
||||
}
|
||||
|
||||
return expValues.map((e) => e / sumExp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply softmax to a Float32Array of logits.
|
||||
*
|
||||
* @param logits - Float32Array of raw model output values
|
||||
* @returns Float32Array of probabilities that sum to ~1.0
|
||||
*/
|
||||
export function softmaxFloat32(logits: Float32Array): Float32Array {
|
||||
const maxLogit = -Infinity;
|
||||
let actualMax = maxLogit;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
if (logits[i] > actualMax) actualMax = logits[i];
|
||||
}
|
||||
|
||||
const expValues = new Float32Array(logits.length);
|
||||
let sumExp = 0;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
expValues[i] = Math.exp(logits[i] - actualMax);
|
||||
sumExp += expValues[i];
|
||||
}
|
||||
|
||||
if (sumExp === 0) {
|
||||
const uniform = 1 / logits.length;
|
||||
return new Float32Array(logits.length).fill(uniform);
|
||||
}
|
||||
|
||||
for (let i = 0; i < expValues.length; i++) {
|
||||
expValues[i] /= sumExp;
|
||||
}
|
||||
|
||||
return expValues;
|
||||
}
|
||||
|
||||
// ─── Confidence Calibration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calibrate a raw probability into an adjusted confidence score with a label.
|
||||
*
|
||||
* Applies a mild calibration that slightly adjusts raw softmax probabilities
|
||||
* to account for model overconfidence. Uses a linear calibration:
|
||||
* adjusted = rawProb * calibrationFactor
|
||||
* where calibrationFactor ≈ 1.0 (default 1.02) to slightly boost
|
||||
* well-separated predictions while keeping the value in [0, 1].
|
||||
*
|
||||
* The calibrated value is clamped to [0, 1] and labeled using thresholds:
|
||||
* high ≥ 0.8
|
||||
* medium ≥ 0.5
|
||||
* low < 0.5
|
||||
*
|
||||
* @param rawProb - Raw softmax probability (0–1)
|
||||
* @param calibrationFactor - Linear calibration factor (default 1.02)
|
||||
* @returns { adjusted, label }
|
||||
*/
|
||||
export function calibrateConfidence(
|
||||
rawProb: number,
|
||||
calibrationFactor = 1.02,
|
||||
): ConfidenceResult {
|
||||
const adjusted = Math.min(1, Math.max(0, rawProb * calibrationFactor));
|
||||
const label = getConfidenceLabel(adjusted);
|
||||
|
||||
return {
|
||||
raw: roundToDecimals(rawProb, 4),
|
||||
adjusted: roundToDecimals(adjusted, 4),
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the confidence label for a given score.
|
||||
*
|
||||
* Thresholds:
|
||||
* high ≥ 0.8
|
||||
* medium ≥ 0.5
|
||||
* low < 0.5
|
||||
*
|
||||
* @param score - Confidence score (0–1)
|
||||
* @returns Confidence label
|
||||
*/
|
||||
export function getConfidenceLabel(score: number): ConfidenceLabel {
|
||||
if (score >= CONFIDENCE_THRESHOLDS.HIGH) return "high";
|
||||
if (score >= CONFIDENCE_THRESHOLDS.MEDIUM) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sigmoid function: 1 / (1 + exp(-x))
|
||||
*/
|
||||
function sigmoid(x: number): number {
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a number to a given number of decimal places.
|
||||
*/
|
||||
function roundToDecimals(value: number, decimals: number): number {
|
||||
const factor = Math.pow(10, decimals);
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
// ─── Top-K Extraction ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the top-K predictions from a probability array.
|
||||
*
|
||||
* @param probabilities - Array of probabilities (from softmax)
|
||||
* @param k - Number of top predictions to return (default 5)
|
||||
* @returns Array of { classIndex, probability } sorted by probability descending
|
||||
*/
|
||||
export function getTopK(
|
||||
probabilities: number[],
|
||||
k = 5,
|
||||
): RawPrediction[] {
|
||||
// Create indexed pairs
|
||||
const indexed = probabilities.map((prob, index) => ({
|
||||
classIndex: index,
|
||||
probability: prob,
|
||||
}));
|
||||
|
||||
// Sort by probability descending
|
||||
indexed.sort((a, b) => b.probability - a.probability);
|
||||
|
||||
// Take top K
|
||||
return indexed.slice(0, k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract top-K predictions from a Float32Array of probabilities.
|
||||
*
|
||||
* @param probabilities - Float32Array of probabilities
|
||||
* @param k - Number of top predictions (default 5)
|
||||
* @returns Array of { classIndex, probability } sorted descending
|
||||
*/
|
||||
export function getTopKFloat32(
|
||||
probabilities: Float32Array,
|
||||
k = 5,
|
||||
): RawPrediction[] {
|
||||
const indexed: Array<{ classIndex: number; probability: number }> = [];
|
||||
for (let i = 0; i < probabilities.length; i++) {
|
||||
indexed.push({ classIndex: i, probability: probabilities[i] });
|
||||
}
|
||||
|
||||
indexed.sort((a, b) => b.probability - a.probability);
|
||||
|
||||
return indexed.slice(0, k);
|
||||
}
|
||||
|
||||
// ─── Filtering ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter predictions by minimum confidence threshold.
|
||||
*
|
||||
* @param predictions - Raw predictions from getTopK()
|
||||
* @param minConfidence - Minimum probability threshold (default 0.15)
|
||||
* @returns Filtered predictions array
|
||||
*/
|
||||
export function filterByConfidence(
|
||||
predictions: RawPrediction[],
|
||||
minConfidence = DEFAULT_MIN_CONFIDENCE,
|
||||
): RawPrediction[] {
|
||||
return predictions.filter((p) => p.probability >= minConfidence);
|
||||
}
|
||||
244
src/lib/ml/inference.test.ts
Normal file
244
src/lib/ml/inference.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Unit tests for lib/ml/inference.ts
|
||||
*
|
||||
* Tests:
|
||||
* - validateInput rejects non-Float32Array
|
||||
* - validateInput rejects wrong-length arrays
|
||||
* - validateInput rejects NaN/Infinity values
|
||||
* - validateInput accepts correct tensor
|
||||
* - createZeroTensor produces correct shape
|
||||
* - createRandomTensor produces correct shape with finite values
|
||||
* - runInference returns InferenceResult with predictions array
|
||||
* - runInference returns exactly top-K predictions
|
||||
* - runInference predictions are sorted descending
|
||||
* - runInference includes inferenceTimeMs
|
||||
* - runInference completes under 3 seconds
|
||||
* - runBatchInference processes multiple images
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
runInference,
|
||||
validateInput,
|
||||
createZeroTensor,
|
||||
createRandomTensor,
|
||||
runBatchInference,
|
||||
INPUT_SIZE,
|
||||
INPUT_SHAPE,
|
||||
DEFAULT_TOP_K,
|
||||
} from "@/lib/ml/inference";
|
||||
import { resetModelCache } from "@/lib/ml/model-loader";
|
||||
|
||||
describe("validateInput", () => {
|
||||
it("rejects non-Float32Array", () => {
|
||||
expect(() => validateInput([1, 2, 3] as any)).toThrow("Expected Float32Array input");
|
||||
});
|
||||
|
||||
it("rejects wrong-length arrays", () => {
|
||||
const tensor = new Float32Array(100);
|
||||
expect(() => validateInput(tensor)).toThrow(`Expected tensor of length ${INPUT_SIZE}`);
|
||||
});
|
||||
|
||||
it("rejects NaN values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
tensor[50] = NaN;
|
||||
expect(() => validateInput(tensor)).toThrow("non-finite value");
|
||||
});
|
||||
|
||||
it("rejects Infinity values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
tensor[50] = Infinity;
|
||||
expect(() => validateInput(tensor)).toThrow("non-finite value");
|
||||
});
|
||||
|
||||
it("rejects -Infinity values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
tensor[50] = -Infinity;
|
||||
expect(() => validateInput(tensor)).toThrow("non-finite value");
|
||||
});
|
||||
|
||||
it("accepts correct tensor", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts tensor with negative values", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
for (let i = 0; i < INPUT_SIZE; i++) {
|
||||
tensor[i] = -2;
|
||||
}
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts tensor with values near zero", () => {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
for (let i = 0; i < INPUT_SIZE; i++) {
|
||||
tensor[i] = 0.0001;
|
||||
}
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createZeroTensor", () => {
|
||||
it("produces Float32Array", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(tensor).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("has correct length", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(tensor.length).toBe(INPUT_SIZE);
|
||||
});
|
||||
|
||||
it("has correct shape dimensions", () => {
|
||||
const expectedLength = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3];
|
||||
expect(INPUT_SIZE).toBe(expectedLength);
|
||||
});
|
||||
|
||||
it("all values are zero", () => {
|
||||
const tensor = createZeroTensor();
|
||||
expect(tensor.every((v) => v === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRandomTensor", () => {
|
||||
it("produces Float32Array", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(tensor).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it("has correct length", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(tensor.length).toBe(INPUT_SIZE);
|
||||
});
|
||||
|
||||
it("all values are finite", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(tensor.every((v) => Number.isFinite(v))).toBe(true);
|
||||
});
|
||||
|
||||
it("produces varied values", () => {
|
||||
const tensor = createRandomTensor();
|
||||
const uniqueValues = new Set(tensor.map((v) => v.toFixed(4)));
|
||||
expect(uniqueValues.size).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("passes validateInput", () => {
|
||||
const tensor = createRandomTensor();
|
||||
expect(() => validateInput(tensor)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("INPUT_SHAPE and INPUT_SIZE", () => {
|
||||
it("INPUT_SHAPE is [1, 3, 160, 160]", () => {
|
||||
expect(INPUT_SHAPE).toEqual([1, 3, 160, 160]);
|
||||
});
|
||||
|
||||
it("INPUT_SIZE equals 3 * 160 * 160", () => {
|
||||
expect(INPUT_SIZE).toBe(3 * 160 * 160);
|
||||
});
|
||||
|
||||
it("DEFAULT_TOP_K is 5", () => {
|
||||
expect(DEFAULT_TOP_K).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runInference", () => {
|
||||
beforeEach(() => {
|
||||
resetModelCache();
|
||||
});
|
||||
|
||||
it("returns InferenceResult with predictions array", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
expect(result.predictions).toBeDefined();
|
||||
expect(Array.isArray(result.predictions)).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it("returns exactly top-K predictions by default", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
expect(result.predictions.length).toBe(DEFAULT_TOP_K);
|
||||
}, 10000);
|
||||
|
||||
it("returns custom top-K predictions", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor, 3);
|
||||
expect(result.predictions.length).toBe(3);
|
||||
}, 10000);
|
||||
|
||||
it("predictions are sorted by probability descending", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
for (let i = 0; i < result.predictions.length - 1; i++) {
|
||||
expect(result.predictions[i].probability).toBeGreaterThanOrEqual(
|
||||
result.predictions[i + 1].probability,
|
||||
);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("includes inferenceTimeMs", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
expect(result.inferenceTimeMs).toBeDefined();
|
||||
expect(typeof result.inferenceTimeMs).toBe("number");
|
||||
expect(result.inferenceTimeMs).toBeGreaterThan(0);
|
||||
}, 10000);
|
||||
|
||||
it("completes under 3 seconds", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const start = performance.now();
|
||||
const result = await runInference(tensor);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
expect(result.inferenceTimeMs).toBeLessThan(3000);
|
||||
}, 10000);
|
||||
|
||||
it("each prediction has classIndex and probability", async () => {
|
||||
const tensor = createRandomTensor();
|
||||
const result = await runInference(tensor);
|
||||
for (const pred of result.predictions) {
|
||||
expect(pred.classIndex).toBeDefined();
|
||||
expect(typeof pred.classIndex).toBe("number");
|
||||
expect(pred.probability).toBeDefined();
|
||||
expect(typeof pred.probability).toBe("number");
|
||||
expect(pred.probability).toBeGreaterThanOrEqual(0);
|
||||
expect(pred.probability).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("throws on invalid input", async () => {
|
||||
const badTensor = new Float32Array(100);
|
||||
await expect(runInference(badTensor)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBatchInference", () => {
|
||||
beforeEach(() => {
|
||||
resetModelCache();
|
||||
});
|
||||
|
||||
it("processes multiple images", async () => {
|
||||
const tensors = [createRandomTensor(), createRandomTensor(), createRandomTensor()];
|
||||
const results = await runBatchInference(tensors);
|
||||
expect(results).toHaveLength(3);
|
||||
for (const result of results) {
|
||||
expect(result.predictions.length).toBe(DEFAULT_TOP_K);
|
||||
expect(result.inferenceTimeMs).toBeGreaterThan(0);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("each result is independent", async () => {
|
||||
const tensors = [createRandomTensor(), createRandomTensor()];
|
||||
const results = await runBatchInference(tensors);
|
||||
// Results should differ (different random inputs → different predictions)
|
||||
expect(results[0].predictions[0].classIndex).toBeDefined();
|
||||
expect(results[1].predictions[0].classIndex).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it("accepts custom top-K", async () => {
|
||||
const tensors = [createRandomTensor()];
|
||||
const results = await runBatchInference(tensors, 3);
|
||||
expect(results[0].predictions.length).toBe(3);
|
||||
}, 15000);
|
||||
});
|
||||
137
src/lib/ml/inference.ts
Normal file
137
src/lib/ml/inference.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* ML inference pipeline for plant disease classification.
|
||||
*
|
||||
* Accepts a preprocessed image tensor, runs it through the model,
|
||||
* applies softmax, extracts top-K predictions, and returns results
|
||||
* with timing metadata.
|
||||
*/
|
||||
|
||||
import type { InferenceResult, RawPrediction } from "@/lib/types";
|
||||
import { getModel } from "./model-loader";
|
||||
import { softmaxFloat32, getTopKFloat32 } from "./confidence";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Number of top predictions to return */
|
||||
export const DEFAULT_TOP_K = 5;
|
||||
|
||||
/** Input tensor shape: [batch=1, channels=3, height=160, width=160] */
|
||||
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 160, 160];
|
||||
|
||||
/** Expected input tensor length */
|
||||
export const INPUT_SIZE = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3]; // 3 * 160 * 160 = 76800
|
||||
|
||||
// ─── Main Inference ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the full inference pipeline on a preprocessed image tensor.
|
||||
*
|
||||
* @param imageTensor - Normalized Float32Array of shape [1, 3, 160, 160] (NCHW)
|
||||
* @param topK - Number of top predictions to return (default 5)
|
||||
* @returns InferenceResult with top-K predictions and timing
|
||||
*/
|
||||
export async function runInference(
|
||||
imageTensor: Float32Array,
|
||||
topK = DEFAULT_TOP_K,
|
||||
): Promise<InferenceResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Validate input
|
||||
validateInput(imageTensor);
|
||||
|
||||
// Get model (lazy loads on first call)
|
||||
const model = await getModel();
|
||||
|
||||
// Run model forward pass
|
||||
const { logits, inferenceTimeMs } = await model.predict(imageTensor);
|
||||
|
||||
// Apply softmax to convert logits to probabilities
|
||||
const probabilities = softmaxFloat32(logits);
|
||||
|
||||
// Extract top-K predictions
|
||||
const predictions = getTopKFloat32(probabilities, topK);
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
|
||||
return {
|
||||
predictions,
|
||||
inferenceTimeMs: Math.round(totalTime),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Input Validation ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate that the input tensor has the expected shape and type.
|
||||
*
|
||||
* @param tensor - Input tensor to validate
|
||||
* @throws Error if tensor is invalid
|
||||
*/
|
||||
export function validateInput(tensor: Float32Array): void {
|
||||
if (!(tensor instanceof Float32Array)) {
|
||||
throw new Error(`Expected Float32Array input, got ${typeof tensor}`);
|
||||
}
|
||||
|
||||
if (tensor.length !== INPUT_SIZE) {
|
||||
throw new Error(
|
||||
`Expected tensor of length ${INPUT_SIZE} (shape ${INPUT_SHAPE.join("×")}), ` +
|
||||
`got ${tensor.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for NaN/Infinity values
|
||||
for (let i = 0; i < tensor.length; i++) {
|
||||
if (!Number.isFinite(tensor[i])) {
|
||||
throw new Error(`Tensor contains non-finite value at index ${i}: ${tensor[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Batch Inference ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run inference on multiple images.
|
||||
*
|
||||
* Currently runs sequentially. For true batching, the model itself would need
|
||||
* to support batch input.
|
||||
*
|
||||
* @param tensors - Array of preprocessed image tensors
|
||||
* @param topK - Number of top predictions per image
|
||||
* @returns Array of inference results
|
||||
*/
|
||||
export async function runBatchInference(
|
||||
tensors: Float32Array[],
|
||||
topK = DEFAULT_TOP_K,
|
||||
): Promise<InferenceResult[]> {
|
||||
const results: InferenceResult[] = [];
|
||||
|
||||
for (const tensor of tensors) {
|
||||
results.push(await runInference(tensor, topK));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a zero-filled input tensor for testing.
|
||||
*
|
||||
* @returns Float32Array of shape [1, 3, 224, 224]
|
||||
*/
|
||||
export function createZeroTensor(): Float32Array {
|
||||
return new Float32Array(INPUT_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random input tensor for testing.
|
||||
*
|
||||
* @returns Float32Array of shape [1, 3, 224, 224] with random values
|
||||
*/
|
||||
export function createRandomTensor(): Float32Array {
|
||||
const tensor = new Float32Array(INPUT_SIZE);
|
||||
for (let i = 0; i < tensor.length; i++) {
|
||||
tensor[i] = (Math.random() * 2 - 1) * 2; // Range roughly -2 to 2
|
||||
}
|
||||
return tensor;
|
||||
}
|
||||
199
src/lib/ml/labels.test.ts
Normal file
199
src/lib/ml/labels.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Unit tests for lib/ml/labels.ts
|
||||
*
|
||||
* The model has 38 PlantVillage classes. Some map to the app's
|
||||
* knowledge base disease IDs, others map to "unknown".
|
||||
*
|
||||
* Known mappings:
|
||||
* - indices 3, 4, 6, 10, 14, 17, 19, 22, 23, 24, 27, 37 → "healthy"
|
||||
* - index 20 (Potato___Early_blight) → "early-blight"
|
||||
* - index 21 (Potato___Late_blight) → "late-blight"
|
||||
* - index 25 (Squash___Powdery_mildew) → "squash-powdery-mildew"
|
||||
* - index 26 (Strawberry___Leaf_scorch) → "strawberry-leaf-scorch"
|
||||
* - index 28 (Tomato___Bacterial_spot) → "bacterial-leaf-spot-tomato"
|
||||
* - index 29 (Tomato___Early_blight) → "early-blight" (duplicate)
|
||||
* - index 30 (Tomato___Late_blight) → "late-blight" (duplicate)
|
||||
* - index 32 (Tomato___Septoria_leaf_spot) → "septoria-leaf-spot"
|
||||
* - index 37 (Tomato___healthy) → "healthy"
|
||||
* - all others → "unknown"
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
INDEX_TO_DISEASE_ID,
|
||||
DISEASE_ID_TO_INDEX,
|
||||
getDiseaseIdForIndex,
|
||||
getIndexForDiseaseId,
|
||||
isRealDisease,
|
||||
getAllDiseaseIds,
|
||||
NUM_CLASSES,
|
||||
getPlantVillageClassName,
|
||||
} from "@/lib/ml/labels";
|
||||
|
||||
describe("Constants", () => {
|
||||
it("NUM_CLASSES is 38 (PlantVillage)", () => {
|
||||
expect(NUM_CLASSES).toBe(38);
|
||||
});
|
||||
|
||||
it("all 38 indices are mapped", () => {
|
||||
const keys = Object.keys(INDEX_TO_DISEASE_ID).map(Number);
|
||||
expect(keys.length).toBe(38);
|
||||
for (let i = 0; i < 38; i++) {
|
||||
expect(keys).toContain(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("INDEX_TO_DISEASE_ID — healthy indices", () => {
|
||||
const healthyIndices = [3, 4, 6, 10, 14, 17, 19, 22, 23, 24, 27, 37];
|
||||
|
||||
for (const idx of healthyIndices) {
|
||||
it(`index ${idx} maps to "healthy"`, () => {
|
||||
expect(INDEX_TO_DISEASE_ID[idx]).toBe("healthy");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("INDEX_TO_DISEASE_ID — known disease mappings", () => {
|
||||
const cases: Array<{ index: number; expected: string; name: string }> = [
|
||||
{ index: 20, expected: "early-blight", name: "Potato___Early_blight" },
|
||||
{ index: 21, expected: "late-blight", name: "Potato___Late_blight" },
|
||||
{ index: 25, expected: "squash-powdery-mildew", name: "Squash___Powdery_mildew" },
|
||||
{ index: 26, expected: "strawberry-leaf-scorch", name: "Strawberry___Leaf_scorch" },
|
||||
{ index: 28, expected: "bacterial-leaf-spot-tomato", name: "Tomato___Bacterial_spot" },
|
||||
{ index: 29, expected: "early-blight", name: "Tomato___Early_blight" },
|
||||
{ index: 30, expected: "late-blight", name: "Tomato___Late_blight" },
|
||||
{ index: 32, expected: "septoria-leaf-spot", name: "Tomato___Septoria_leaf_spot" },
|
||||
];
|
||||
|
||||
for (const { index, expected, name } of cases) {
|
||||
it(`index ${index} (${name}) maps to "${expected}"`, () => {
|
||||
expect(INDEX_TO_DISEASE_ID[index]).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("INDEX_TO_DISEASE_ID — unknown (unmapped) indices", () => {
|
||||
const unknownIndices = [0, 1, 2, 5, 7, 8, 9, 11, 12, 13, 15, 16, 18, 31, 33, 34, 35, 36];
|
||||
|
||||
for (const idx of unknownIndices) {
|
||||
it(`index ${idx} maps to "unknown"`, () => {
|
||||
expect(INDEX_TO_DISEASE_ID[idx]).toBe("unknown");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("DISEASE_ID_TO_INDEX", () => {
|
||||
it("maps 'early-blight' to first occurrence (index 20)", () => {
|
||||
expect(DISEASE_ID_TO_INDEX["early-blight"]).toBe(20);
|
||||
});
|
||||
|
||||
it("maps 'late-blight' to first occurrence (index 21)", () => {
|
||||
expect(DISEASE_ID_TO_INDEX["late-blight"]).toBe(21);
|
||||
});
|
||||
|
||||
it("maps 'septoria-leaf-spot' to index 32", () => {
|
||||
expect(DISEASE_ID_TO_INDEX["septoria-leaf-spot"]).toBe(32);
|
||||
});
|
||||
|
||||
it("maps 'healthy' to index 3 (first healthy index)", () => {
|
||||
expect(DISEASE_ID_TO_INDEX["healthy"]).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bidirectional mapping", () => {
|
||||
it("every index round-trips correctly", () => {
|
||||
for (let i = 0; i < NUM_CLASSES; i++) {
|
||||
const id = INDEX_TO_DISEASE_ID[i];
|
||||
const idx = DISEASE_ID_TO_INDEX[id];
|
||||
expect(INDEX_TO_DISEASE_ID[idx]).toBe(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiseaseIdForIndex", () => {
|
||||
it("returns 'unknown' for out-of-range positive index", () => {
|
||||
expect(getDiseaseIdForIndex(100)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for negative index", () => {
|
||||
expect(getDiseaseIdForIndex(-1)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns correct ID for valid index", () => {
|
||||
expect(getDiseaseIdForIndex(20)).toBe("early-blight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndexForDiseaseId", () => {
|
||||
it("returns -1 for unknown disease ID", () => {
|
||||
expect(getIndexForDiseaseId("nonexistent-disease")).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -1 for empty string", () => {
|
||||
expect(getIndexForDiseaseId("")).toBe(-1);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(getIndexForDiseaseId("EARLY-BLIGHT")).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRealDisease", () => {
|
||||
it("returns false for 'healthy'", () => {
|
||||
expect(isRealDisease("healthy")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 'unknown'", () => {
|
||||
expect(isRealDisease("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for known disease IDs", () => {
|
||||
expect(isRealDisease("early-blight")).toBe(true);
|
||||
expect(isRealDisease("septoria-leaf-spot")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for arbitrary non-special strings", () => {
|
||||
expect(isRealDisease("some-disease")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlantVillageClassName", () => {
|
||||
it("returns correct class name for tomato healthy", () => {
|
||||
expect(getPlantVillageClassName(37)).toBe("Tomato___healthy");
|
||||
});
|
||||
|
||||
it("returns correct class name for potato early blight", () => {
|
||||
expect(getPlantVillageClassName(20)).toBe("Potato___Early_blight");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for out-of-range index", () => {
|
||||
expect(getPlantVillageClassName(100)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllDiseaseIds", () => {
|
||||
it("returns only mapped disease IDs", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
expect(ids).toContain("early-blight");
|
||||
expect(ids).toContain("late-blight");
|
||||
expect(ids).toContain("squash-powdery-mildew");
|
||||
expect(ids).toContain("strawberry-leaf-scorch");
|
||||
expect(ids).toContain("bacterial-leaf-spot-tomato");
|
||||
expect(ids).toContain("septoria-leaf-spot");
|
||||
});
|
||||
|
||||
it("excludes 'healthy'", () => {
|
||||
expect(getAllDiseaseIds()).not.toContain("healthy");
|
||||
});
|
||||
|
||||
it("excludes 'unknown'", () => {
|
||||
expect(getAllDiseaseIds()).not.toContain("unknown");
|
||||
});
|
||||
|
||||
it("has no duplicates", () => {
|
||||
const ids = getAllDiseaseIds();
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
237
src/lib/ml/labels.ts
Normal file
237
src/lib/ml/labels.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Class label mapping for the plant disease classifier model.
|
||||
*
|
||||
* This model is a MobileNetV2 trained on the PlantVillage dataset
|
||||
* with 38 classes (14 crops × diseases/healthy).
|
||||
*
|
||||
* Model output shape: [1, NUM_CLASSES] where NUM_CLASSES = 38
|
||||
*
|
||||
* Index layout (from labels_pv_original.json):
|
||||
* 0 → Apple___Apple_scab
|
||||
* 1 → Apple___Black_rot
|
||||
* 2 → Apple___Cedar_apple_rust
|
||||
* 3 → Apple___healthy
|
||||
* 4 → Blueberry___healthy
|
||||
* 5 → Cherry_(including_sour)___Powdery_mildew
|
||||
* 6 → Cherry_(including_sour)___healthy
|
||||
* 7 → Corn_(maize)___Cercospora_leaf_spot Gray_leaf_spot
|
||||
* 8 → Corn_(maize)___Common_rust_
|
||||
* 9 → Corn_(maize)___Northern_Leaf_Blight
|
||||
* 10 → Corn_(maize)___healthy
|
||||
* 11 → Grape___Black_rot
|
||||
* 12 → Grape___Esca_(Black_Measles)
|
||||
* 13 → Grape___Leaf_blight_(Isariopsis_Leaf_Spot)
|
||||
* 14 → Grape___healthy
|
||||
* 15 → Orange___Haunglongbing_(Citrus_greening)
|
||||
* 16 → Peach___Bacterial_spot
|
||||
* 17 → Peach___healthy
|
||||
* 18 → Pepper,_bell___Bacterial_spot
|
||||
* 19 → Pepper,_bell___healthy
|
||||
* 20 → Potato___Early_blight
|
||||
* 21 → Potato___Late_blight
|
||||
* 22 → Potato___healthy
|
||||
* 23 → Raspberry___healthy
|
||||
* 24 → Soybean___healthy
|
||||
* 25 → Squash___Powdery_mildew
|
||||
* 26 → Strawberry___Leaf_scorch
|
||||
* 27 → Strawberry___healthy
|
||||
* 28 → Tomato___Bacterial_spot
|
||||
* 29 → Tomato___Early_blight
|
||||
* 30 → Tomato___Late_blight
|
||||
* 31 → Tomato___Leaf_Mold
|
||||
* 32 → Tomato___Septoria_leaf_spot
|
||||
* 33 → Tomato___Spider_mites Two-spotted_spider_mite
|
||||
* 34 → Tomato___Target_Spot
|
||||
* 35 → Tomato___Tomato_Yellow_Leaf_Curl_Virus
|
||||
* 36 → Tomato___Tomato_mosaic_virus
|
||||
* 37 → Tomato___healthy
|
||||
*
|
||||
* Some PlantVillage classes overlap with this app's knowledge base.
|
||||
* Exact class name → disease ID mappings:
|
||||
* Potato___Early_blight → "early-blight"
|
||||
* Potato___Late_blight → "late-blight"
|
||||
* Squash___Powdery_mildew → "squash-powdery-mildew"
|
||||
* Strawberry___Leaf_scorch → "strawberry-leaf-scorch"
|
||||
* Tomato___Bacterial_spot → "bacterial-leaf-spot-tomato"
|
||||
* Tomato___Early_blight → "early-blight"
|
||||
* Tomato___Late_blight → "late-blight"
|
||||
* Tomato___Septoria_leaf_spot → "septoria-leaf-spot"
|
||||
* All other classes map to "unknown" and are filtered out during enrichment.
|
||||
*
|
||||
* After fine-tuning to the app's 93 disease classes, this file will be
|
||||
* rewritten to match the new model's output layer.
|
||||
*/
|
||||
|
||||
// ─── PlantVillage class names (in model output order) ────────────────────
|
||||
|
||||
const PLANTVILLAGE_CLASSES: string[] = [
|
||||
"Apple___Apple_scab",
|
||||
"Apple___Black_rot",
|
||||
"Apple___Cedar_apple_rust",
|
||||
"Apple___healthy",
|
||||
"Blueberry___healthy",
|
||||
"Cherry_(including_sour)___Powdery_mildew",
|
||||
"Cherry_(including_sour)___healthy",
|
||||
"Corn_(maize)___Cercospora_leaf_spot Gray_leaf_spot",
|
||||
"Corn_(maize)___Common_rust_",
|
||||
"Corn_(maize)___Northern_Leaf_Blight",
|
||||
"Corn_(maize)___healthy",
|
||||
"Grape___Black_rot",
|
||||
"Grape___Esca_(Black_Measles)",
|
||||
"Grape___Leaf_blight_(Isariopsis_Leaf_Spot)",
|
||||
"Grape___healthy",
|
||||
"Orange___Haunglongbing_(Citrus_greening)",
|
||||
"Peach___Bacterial_spot",
|
||||
"Peach___healthy",
|
||||
"Pepper,_bell___Bacterial_spot",
|
||||
"Pepper,_bell___healthy",
|
||||
"Potato___Early_blight",
|
||||
"Potato___Late_blight",
|
||||
"Potato___healthy",
|
||||
"Raspberry___healthy",
|
||||
"Soybean___healthy",
|
||||
"Squash___Powdery_mildew",
|
||||
"Strawberry___Leaf_scorch",
|
||||
"Strawberry___healthy",
|
||||
"Tomato___Bacterial_spot",
|
||||
"Tomato___Early_blight",
|
||||
"Tomato___Late_blight",
|
||||
"Tomato___Leaf_Mold",
|
||||
"Tomato___Septoria_leaf_spot",
|
||||
"Tomato___Spider_mites Two-spotted_spider_mite",
|
||||
"Tomato___Target_Spot",
|
||||
"Tomato___Tomato_Yellow_Leaf_Curl_Virus",
|
||||
"Tomato___Tomato_mosaic_virus",
|
||||
"Tomato___healthy",
|
||||
] as const;
|
||||
|
||||
// ─── PlantVillage → App disease ID mapping ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps PlantVillage class names (in the form "Plant___Disease") to
|
||||
* this app's disease IDs. Unmapped classes resolve to "unknown".
|
||||
*/
|
||||
function plantVillageNameToDiseaseId(pvName: string): string {
|
||||
const parts = pvName.split("___");
|
||||
if (parts.length !== 2) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const disease = parts[1];
|
||||
|
||||
// Detect "healthy" variants
|
||||
if (disease === "healthy") {
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
// Map exact PlantVillage class names to our disease IDs.
|
||||
// Only map classes where we're confident the correspondence holds.
|
||||
const exactMap: Record<string, string> = {
|
||||
Squash___Powdery_mildew: "squash-powdery-mildew",
|
||||
Strawberry___Leaf_scorch: "strawberry-leaf-scorch",
|
||||
Potato___Early_blight: "early-blight",
|
||||
Potato___Late_blight: "late-blight",
|
||||
Tomato___Bacterial_spot: "bacterial-leaf-spot-tomato",
|
||||
Tomato___Early_blight: "early-blight",
|
||||
Tomato___Late_blight: "late-blight",
|
||||
Tomato___Septoria_leaf_spot: "septoria-leaf-spot",
|
||||
};
|
||||
|
||||
return exactMap[pvName] ?? "unknown";
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Total number of model output classes */
|
||||
export const NUM_CLASSES = PLANTVILLAGE_CLASSES.length; // 38
|
||||
|
||||
/** Index for the "healthy" class — multiple PV indices map to this */
|
||||
export const HEALTHY_INDEX = 0; // First PV healthy class, others also map to this string
|
||||
|
||||
/** First disease index (unused in PV mapping, kept for compatibility) */
|
||||
export const FIRST_DISEASE_INDEX = 0;
|
||||
|
||||
/** Index for the "unknown" catch-all — PV classes we can't map */
|
||||
export const UNKNOWN_INDEX = NUM_CLASSES - 1; // 37 (Tomato___healthy maps to "healthy", not unknown)
|
||||
|
||||
// ─── Index → Disease ID mapping ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map from model output index to app disease ID string.
|
||||
* Built dynamically from PlantVillage class names.
|
||||
*/
|
||||
export const INDEX_TO_DISEASE_ID: Record<number, string> = Object.freeze(
|
||||
(() => {
|
||||
const map: Record<number, string> = {};
|
||||
for (let i = 0; i < NUM_CLASSES; i++) {
|
||||
map[i] = plantVillageNameToDiseaseId(PLANTVILLAGE_CLASSES[i]);
|
||||
}
|
||||
return map;
|
||||
})(),
|
||||
);
|
||||
|
||||
// ─── Disease ID → Index mapping ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map from disease ID string to model output index.
|
||||
* For duplicates (e.g., both potato and tomato "Early_blight" → "early-blight"),
|
||||
* returns the first matching index.
|
||||
*/
|
||||
export const DISEASE_ID_TO_INDEX: Record<string, number> = Object.freeze(
|
||||
(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (let i = 0; i < NUM_CLASSES; i++) {
|
||||
const id = INDEX_TO_DISEASE_ID[i];
|
||||
// First occurrence wins (potato before tomato for early/late blight)
|
||||
if (map[id] === undefined) {
|
||||
map[id] = i;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})(),
|
||||
);
|
||||
|
||||
// ─── Lookup helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the disease ID for a given model output index.
|
||||
* Returns "unknown" for out-of-range indices.
|
||||
*/
|
||||
export function getDiseaseIdForIndex(index: number): string {
|
||||
return INDEX_TO_DISEASE_ID[index] ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model output index for a given disease ID.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
export function getIndexForDiseaseId(diseaseId: string): number {
|
||||
return DISEASE_ID_TO_INDEX[diseaseId.toLowerCase()] ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a disease ID is a real disease (not "healthy" or "unknown").
|
||||
*/
|
||||
export function isRealDisease(diseaseId: string): boolean {
|
||||
return diseaseId !== "healthy" && diseaseId !== "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PlantVillage display name for a given model output index.
|
||||
*/
|
||||
export function getPlantVillageClassName(index: number): string {
|
||||
return PLANTVILLAGE_CLASSES[index] ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known disease IDs (excluding "healthy" and "unknown").
|
||||
*/
|
||||
export function getAllDiseaseIds(): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const id of Object.values(INDEX_TO_DISEASE_ID)) {
|
||||
if (id !== "healthy" && id !== "unknown") {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
395
src/lib/ml/model-loader.ts
Normal file
395
src/lib/ml/model-loader.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Singleton model loader for the plant disease classifier.
|
||||
*
|
||||
* Lazy-loads the TF.js or ONNX model on first call and caches it in memory
|
||||
* via globalThis for subsequent requests. Supports graceful fallback to
|
||||
* mock mode when no model file is present.
|
||||
*
|
||||
* Model files expected at: public/models/plant-disease-classifier/model.json
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Model runtime backend */
|
||||
export type ModelBackend = "tfjs" | "onnx" | "mock";
|
||||
|
||||
/** Model loading status */
|
||||
export interface ModelStatus {
|
||||
/** Whether a real model is loaded */
|
||||
loaded: boolean;
|
||||
/** Backend being used */
|
||||
backend: ModelBackend;
|
||||
/** Model identifier string */
|
||||
modelId: string;
|
||||
/** Number of output classes */
|
||||
numClasses: number;
|
||||
/** Error message if loading failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from running the model on input data */
|
||||
export interface ModelOutput {
|
||||
/** Raw logits or probabilities from the model */
|
||||
logits: Float32Array;
|
||||
/** Inference time in milliseconds */
|
||||
inferenceTimeMs: number;
|
||||
}
|
||||
|
||||
/** Model interface abstracted over TF.js / ONNX / mock */
|
||||
export interface PlantDiseaseModel {
|
||||
/** Run inference on a preprocessed image tensor */
|
||||
predict(tensor: Float32Array): Promise<ModelOutput>;
|
||||
/** Get model metadata */
|
||||
getStatus(): ModelStatus;
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Path to model files relative to project root */
|
||||
const MODEL_DIR = path.join(process.cwd(), "public", "models", "plant-disease-classifier");
|
||||
const MODEL_JSON_PATH = path.join(MODEL_DIR, "model.json");
|
||||
|
||||
/** Model identifier */
|
||||
export const MODEL_ID = "plant-classifier-v1";
|
||||
|
||||
/** Maximum model load time (ms) */
|
||||
const MODEL_LOAD_TIMEOUT = 30_000;
|
||||
|
||||
// ─── Global cache ────────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
var __plantDiseaseModel__: PlantDiseaseModel | undefined;
|
||||
var __plantDiseaseModelLoading__: Promise<PlantDiseaseModel> | undefined;
|
||||
}
|
||||
|
||||
// ─── Model Loader ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the cached model instance, loading it lazily on first call.
|
||||
* Uses globalThis to persist across serverless invocations (within same container).
|
||||
*
|
||||
* @returns Promise resolving to the model (real or mock)
|
||||
*/
|
||||
export async function getModel(): Promise<PlantDiseaseModel> {
|
||||
// Return cached model if available
|
||||
if (globalThis.__plantDiseaseModel__) {
|
||||
return globalThis.__plantDiseaseModel__;
|
||||
}
|
||||
|
||||
// If already loading, wait for the existing promise
|
||||
if (globalThis.__plantDiseaseModelLoading__) {
|
||||
return globalThis.__plantDiseaseModelLoading__;
|
||||
}
|
||||
|
||||
// Start loading
|
||||
const loadingPromise = loadModel();
|
||||
globalThis.__plantDiseaseModelLoading__ = loadingPromise;
|
||||
|
||||
try {
|
||||
const model = await Promise.race([
|
||||
loadingPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Model load timed out after ${MODEL_LOAD_TIMEOUT}ms`)),
|
||||
MODEL_LOAD_TIMEOUT,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
globalThis.__plantDiseaseModel__ = model;
|
||||
return model;
|
||||
} finally {
|
||||
globalThis.__plantDiseaseModelLoading__ = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the model, attempting TF.js first, then ONNX, then falling back to mock.
|
||||
*/
|
||||
async function loadModel(): Promise<PlantDiseaseModel> {
|
||||
// Check if model files exist
|
||||
const modelExists = await checkModelFiles();
|
||||
|
||||
if (!modelExists) {
|
||||
console.warn(
|
||||
`[model-loader] Model files not found at ${MODEL_DIR}. Using mock model. ` +
|
||||
`Place TF.js model (model.json + weight shards) in public/models/plant-disease-classifier/`,
|
||||
);
|
||||
return createMockModel();
|
||||
}
|
||||
|
||||
// Try TF.js first
|
||||
try {
|
||||
const tfModel = await tryLoadTFJS();
|
||||
if (tfModel) {
|
||||
console.info(`[model-loader] Loaded TF.js model: ${MODEL_ID}`);
|
||||
return tfModel;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[model-loader] TF.js load failed (${err instanceof Error ? err.message : "unknown"}). Trying ONNX...`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try ONNX Runtime
|
||||
try {
|
||||
const onnxModel = await tryLoadONNX();
|
||||
if (onnxModel) {
|
||||
console.info(`[model-loader] Loaded ONNX model: ${MODEL_ID}`);
|
||||
return onnxModel;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[model-loader] ONNX load failed (${err instanceof Error ? err.message : "unknown"}). Falling back to mock.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to mock
|
||||
console.warn(`[model-loader] All backends failed. Using mock model.`);
|
||||
return createMockModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model files exist on disk.
|
||||
*/
|
||||
async function checkModelFiles(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(MODEL_JSON_PATH);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TensorFlow.js Backend ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to load the model using TensorFlow.js.
|
||||
* Attempts @tensorflow/tfjs-node first (server), falls back to @tensorflow/tfjs.
|
||||
*/
|
||||
async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let tf: any;
|
||||
|
||||
// Monkey-patch: add util.isNullOrUndefined for Node.js 26 compatibility.
|
||||
// @tensorflow/tfjs-node references this function which was removed in Node 15+.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeUtil = require("util");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (typeof (nodeUtil as any).isNullOrUndefined !== "function") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(nodeUtil as any).isNullOrUndefined = function (x: unknown): boolean {
|
||||
return x === null || x === undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// Try tfjs-node first (server-side, uses native bindings).
|
||||
// Use dynamic strings so bundlers (Turbopack/webpack) don't trace these
|
||||
// as required dependencies — they are truly optional.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const tfjsNode = await import("@tensorflow/tfjs-node" + "");
|
||||
tf = tfjsNode;
|
||||
} catch {
|
||||
// Fall back to browser tfjs
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
tf = await import("@tensorflow/tfjs" + "");
|
||||
} catch {
|
||||
return null; // Neither tfjs package available
|
||||
}
|
||||
}
|
||||
|
||||
// Load the model from file path
|
||||
const model = await tf.loadGraphModel(`file://${MODEL_JSON_PATH}`);
|
||||
|
||||
return {
|
||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Reshape to [1, 3, 160, 160] NCHW → [1, 160, 160, 3] NHWC for TF.js
|
||||
// Reshape NCHW flat array [3*160*160] → [3, 160, 160] → NHWC [1, 160, 160, 3]
|
||||
const inputTensor = tf
|
||||
.tensor3d(Array.from(tensor), [3, 160, 160])
|
||||
.transpose([1, 2, 0])
|
||||
.expandDims(0);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const outputTensor = (await model.predict(inputTensor)) as any;
|
||||
const logits = new Float32Array(await outputTensor.data());
|
||||
|
||||
inputTensor.dispose();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
outputTensor.dispose();
|
||||
|
||||
return {
|
||||
logits,
|
||||
inferenceTimeMs: performance.now() - startTime,
|
||||
};
|
||||
},
|
||||
|
||||
getStatus(): ModelStatus {
|
||||
return {
|
||||
loaded: true,
|
||||
backend: "tfjs",
|
||||
modelId: MODEL_ID,
|
||||
numClasses: 38, // Original PlantVillage model
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── ONNX Runtime Backend ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to load the model using ONNX Runtime.
|
||||
*/
|
||||
async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ort: any;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
ort = await import("onnxruntime-node" + "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for .onnx file in model directory
|
||||
const onnxPath = path.join(MODEL_DIR, "model.onnx");
|
||||
const onnxExists = fsSync.existsSync(onnxPath);
|
||||
|
||||
if (!onnxExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await ort.InferenceSession.create(onnxPath);
|
||||
|
||||
return {
|
||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// ONNX expects NCHW format: [1, 3, 160, 160]
|
||||
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 160, 160]);
|
||||
const feeds = { [session.inputNames[0]]: inputTensor };
|
||||
const results = await session.run(feeds);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const outputValues = Object.values(results) as any[];
|
||||
const logits = new Float32Array(outputValues[0].data);
|
||||
|
||||
inputTensor.dispose();
|
||||
|
||||
return {
|
||||
logits,
|
||||
inferenceTimeMs: performance.now() - startTime,
|
||||
};
|
||||
},
|
||||
|
||||
getStatus(): ModelStatus {
|
||||
return {
|
||||
loaded: true,
|
||||
backend: "onnx",
|
||||
modelId: MODEL_ID,
|
||||
numClasses: 38,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mock Model ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a deterministic mock model for development/demo mode.
|
||||
*
|
||||
* Generates reproducible predictions based on input tensor hash.
|
||||
* This allows the UI to work without a real model file.
|
||||
*/
|
||||
function createMockModel(): PlantDiseaseModel {
|
||||
return {
|
||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||
// Simulate inference time (50-200ms)
|
||||
const simulatedTime = 50 + Math.random() * 150;
|
||||
await sleep(simulatedTime);
|
||||
|
||||
// Generate deterministic logits from input hash
|
||||
const logits = generateMockLogits(tensor);
|
||||
|
||||
return {
|
||||
logits,
|
||||
inferenceTimeMs: simulatedTime,
|
||||
};
|
||||
},
|
||||
|
||||
getStatus(): ModelStatus {
|
||||
return {
|
||||
loaded: false,
|
||||
backend: "mock",
|
||||
modelId: MODEL_ID,
|
||||
numClasses: 38,
|
||||
error: "Model files not found. Running in demo mode with mock predictions.",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic mock logits from input tensor.
|
||||
* Uses a simple hash of the first few tensor values to create
|
||||
* reproducible but varied predictions.
|
||||
*/
|
||||
function generateMockLogits(tensor: Float32Array): Float32Array {
|
||||
const numClasses = 38;
|
||||
const logits = new Float32Array(numClasses);
|
||||
|
||||
// Simple hash of input for deterministic output
|
||||
let hash = 0;
|
||||
const sampleSize = Math.min(100, tensor.length);
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
hash = ((hash << 5) - hash + Math.floor(tensor[i] * 1000)) | 0;
|
||||
}
|
||||
|
||||
// Generate logits using hash as seed
|
||||
// Class 0 (healthy) gets a moderate score
|
||||
logits[0] = (Math.abs(hash % 10) / 10) * 2;
|
||||
|
||||
// Give some disease classes higher scores
|
||||
// This creates a realistic-looking distribution
|
||||
for (let i = 1; i < numClasses - 1; i++) {
|
||||
const seed = ((hash * (i + 1) * 7) % 1000) / 1000;
|
||||
logits[i] = seed * 4 - 1; // Range roughly -1 to 3
|
||||
}
|
||||
|
||||
// Make the top prediction more confident
|
||||
const topIndex = Math.abs(hash % (numClasses - 2)) + 1;
|
||||
logits[topIndex] = 3.5;
|
||||
|
||||
// Second highest
|
||||
const secondIndex = ((topIndex + Math.abs(hash % 10) + 1) % (numClasses - 1)) + 1;
|
||||
logits[secondIndex] = 2.5;
|
||||
|
||||
logits[numClasses - 1] = -2; // "unknown" gets low score
|
||||
|
||||
return logits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given number of milliseconds.
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── Reset (for testing) ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset the model cache. Useful for testing.
|
||||
*/
|
||||
export function resetModelCache(): void {
|
||||
globalThis.__plantDiseaseModel__ = undefined;
|
||||
globalThis.__plantDiseaseModelLoading__ = undefined;
|
||||
}
|
||||
55
src/lib/server/image-processing-server.test.ts
Normal file
55
src/lib/server/image-processing-server.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mimeTypeToExtension } from "./image-processing-server";
|
||||
|
||||
// Mock sharp dynamically
|
||||
const mockSharp = vi.fn(() => ({
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("resized-image-data")),
|
||||
}));
|
||||
|
||||
vi.doMock("sharp", () => ({
|
||||
default: mockSharp,
|
||||
}));
|
||||
|
||||
describe("mimeTypeToExtension", () => {
|
||||
it("maps image/png to png", () => {
|
||||
expect(mimeTypeToExtension("image/png")).toBe("png");
|
||||
});
|
||||
|
||||
it("maps image/jpeg to jpg", () => {
|
||||
expect(mimeTypeToExtension("image/jpeg")).toBe("jpg");
|
||||
});
|
||||
|
||||
it("maps image/jpg to jpg", () => {
|
||||
expect(mimeTypeToExtension("image/jpg")).toBe("jpg");
|
||||
});
|
||||
|
||||
it("maps image/webp to webp", () => {
|
||||
expect(mimeTypeToExtension("image/webp")).toBe("webp");
|
||||
});
|
||||
|
||||
it("returns jpg for unknown mime types", () => {
|
||||
expect(mimeTypeToExtension("image/bmp")).toBe("jpg");
|
||||
expect(mimeTypeToExtension("unknown/type")).toBe("jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeImageServer", () => {
|
||||
it("resizes image to specified dimensions", async () => {
|
||||
const { resizeImageServer } = await import("./image-processing-server");
|
||||
const buffer = Buffer.from("test-image-data");
|
||||
|
||||
const result = await resizeImageServer(buffer, 224, 224);
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(mockSharp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns buffer for valid input", async () => {
|
||||
const { resizeImageServer } = await import("./image-processing-server");
|
||||
const buffer = Buffer.from("test-image-data");
|
||||
|
||||
const result = await resizeImageServer(buffer, 224, 224);
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
});
|
||||
});
|
||||
51
src/lib/server/image-processing-server.ts
Normal file
51
src/lib/server/image-processing-server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Server-only image processing helpers.
|
||||
*
|
||||
* These functions use Node.js native modules (sharp) and must NOT be
|
||||
* imported by client components. They are used exclusively by API
|
||||
* route handlers (server-side).
|
||||
*/
|
||||
|
||||
// ─── Resize ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Server-side image resize using sharp (if available) or a fallback.
|
||||
* This is used by the upload API route.
|
||||
*
|
||||
* @param buffer - Raw image buffer
|
||||
* @param size - Target dimension
|
||||
* @returns Promise<Buffer> resized image as JPEG
|
||||
*/
|
||||
export async function resizeImageServer(
|
||||
buffer: Buffer,
|
||||
size: number,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const sharpModule = await import("sharp");
|
||||
return sharpModule.default(buffer)
|
||||
.resize(size, size)
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
// Fallback: return original buffer if sharp is not available
|
||||
// In production, sharp should be installed
|
||||
throw new Error(
|
||||
"sharp is required for server-side image resizing. Install with: npm install sharp",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MIME Type Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a file extension from a MIME type.
|
||||
*/
|
||||
export function mimeTypeToExtension(mimeType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
return map[mimeType] || "jpg";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user