Initial commit: Plant Disease Identification app

- Next.js 16 App Router project with Tailwind CSS
- Plant disease knowledge base (93 diseases, 25 plants)
- Image upload with client+server preprocessing
- ML inference pipeline with mock/demo fallback
- Responsive results page with disease cards and treatment
- Full test suite (285 passing tests)
This commit is contained in:
2026-06-05 19:21:16 -04:00
commit 820a872f07
100 changed files with 23271 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
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 50K+ labeled plant disease images covering 25+ 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&apos;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 &ldquo;your plant has a
fungus&rdquo; but &ldquo;your tomato has Late Blight caused by
Phytophthora infestans, and here&apos;s exactly how to treat it.&rdquo;
</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>50,000+ labeled plant disease
images</strong> spanning 25+ 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 &amp; 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 &amp; 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>
);
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getDiseaseWithPlant, getLookalikeDiseases } from "@/lib/api/diseases";
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 = 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 = getLookalikeDiseases(id);
return NextResponse.json(
{
disease: result.disease,
plant: result.plant,
lookalikes,
},
{ headers: { "Cache-Control": "public, max-age=3600" } }
);
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { listDiseases } from "@/lib/api/diseases";
/**
* 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 = 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" } }
);
}

View 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(),
});
}

View 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";
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 = 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 = 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);
});

View File

@@ -0,0 +1,263 @@
/**
* 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, INPUT_SIZE } from "@/lib/ml/inference";
import { softmaxFloat32, getTopKFloat32, calibrateConfidence, filterByConfidence, DEFAULT_MIN_CONFIDENCE } from "@/lib/ml/confidence";
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
import { getModel, MODEL_ID } from "@/lib/ml/model-loader";
import { getDiseaseById, getLookalikeDiseases } from "@/lib/api/diseases";
import type { IdentifyRequest, IdentifyResponse, PredictionResult, Disease } 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 = 224;
// ─── Server-side image preprocessing ─────────────────────────────────────────
/**
* Load an uploaded image and preprocess it into a Float32Array tensor
* with shape [3, 224, 224] (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
*
* @param topPredictions - Top-K raw predictions from inference
* @returns Enriched prediction results
*/
function enrichPredictions(
topPredictions: Array<{ classIndex: number; probability: number }>,
): 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 = getDiseaseById(diseaseId);
if (!disease) {
// Disease ID from model doesn't exist in knowledge base — skip
continue;
}
// Calibrate confidence
const confidence = calibrateConfidence(pred.probability);
// Get lookalike diseases
const lookalikes = disease.lookalikeDiseaseIds;
results.push({
diseaseId,
disease,
confidence,
lookalikes,
});
}
// 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 = 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 },
);
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { getPlantWithDiseases } from "@/lib/api/diseases";
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 = 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" },
});
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { listPlants } from "@/lib/api/diseases";
/**
* 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 = listPlants({
search: search || undefined,
category: category || undefined,
});
return NextResponse.json(
{ plants: results, total: results.length },
{ headers: { "Cache-Control": "public, max-age=3600" } }
);
}

View 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 },
);
}
}

View 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);
});

View File

@@ -0,0 +1,169 @@
"use client";
import React, { useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import PlantCard from "@/components/PlantCard";
import EmptyState from "@/components/EmptyState";
import { plants, type Plant } from "@/data/plants";
import { PLANT_CATEGORIES } from "@/lib/constants";
type Category = Plant["category"] | "all";
/**
* Client component that handles the interactive browse/search/filter logic.
* Wrapped in a Suspense boundary in the parent page.
*/
export default function BrowseContent() {
const searchParams = useSearchParams();
const initialSearch = searchParams.get("search") || "";
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [activeCategory, setActiveCategory] = useState<Category>("all");
const filteredPlants = useMemo(() => {
let result = plants;
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) ||
p.diseases.some((d) => d.name.toLowerCase().includes(q))
);
}
return result;
}, [activeCategory, searchQuery]);
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 {plants.length} plants and their common
diseases.
</p>
</div>
{/* Search bar */}
<div className="relative mb-6">
<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 disease..."
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>
{/* 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>
);
}

View File

@@ -0,0 +1,297 @@
import React from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPlantById, type Disease } from "@/data/plants";
import type { Metadata } from "next";
interface Props {
params: Promise<{ plantId: string }>;
}
export async function generateStaticParams() {
const { plants } = await import("@/data/plants");
return plants.map((plant) => ({
plantId: plant.id,
}));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { plantId } = await params;
const plant = getPlantById(plantId);
if (!plant) {
return { title: "Plant Not Found" };
}
return {
title: `${plant.commonName} — Diseases & Care`,
description: `Learn about ${plant.commonName} (${plant.scientificName}) diseases, symptoms, causes, and treatments. ${plant.diseases.length} diseases documented.`,
};
}
/* ─── Severity badge ─── */
function SeverityBadge({ severity }: { severity: Disease["severity"] }) {
const colors: Record<Disease["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<Disease["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: Disease["type"] }) {
const colors: Record<Disease["type"], string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
pest: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
physiological: "bg-zinc-100 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
/* ─── Disease card (expandable) ─── */
function DiseaseCard({ disease }: { disease: Disease }) {
return (
<div
id={`disease-${disease.id}`}
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
{/* Card header */}
<div className="p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{disease.name}
</h3>
{disease.scientificName && (
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
{disease.scientificName}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<TypeBadge type={disease.type} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
{disease.description}
</p>
{/* Details grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Symptoms */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
<span aria-hidden="true"></span> Symptoms
</h4>
<ul className="space-y-1.5">
{disease.symptoms.map((symptom, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
{symptom}
</li>
))}
</ul>
</div>
{/* Causes */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">🔍</span> Causes
</h4>
<ul className="space-y-1.5">
{disease.causes.map((cause, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
{cause}
</li>
))}
</ul>
</div>
{/* Treatment Steps */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatmentSteps.map((step, i) => (
<li
key={i}
className="text-sm text-zinc-600 dark:text-zinc-300"
>
{step}
</li>
))}
</ol>
</div>
{/* Prevention Tips */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<ul className="space-y-1.5">
{disease.preventionTips.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
{tip}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
/* ─── Plant Detail Page ─── */
export default async function PlantDetailPage({ params }: Props) {
const { plantId } = await params;
const plant = getPlantById(plantId);
if (!plant) {
notFound();
}
return (
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<li>
<Link href="/" className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors">
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>
{/* Plant hero */}
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
{/* Emoji illustration */}
<div className="flex items-center justify-center h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
<span className="text-6xl sm:text-7xl" role="img" aria-hidden="true">
{plant.imageEmoji}
</span>
</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">
{plant.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 dark:text-zinc-100">
🧐 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="/browse"
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">
{plant.diseases.length === 0
? "No diseases currently documented for this plant."
: `${plant.diseases.length} ${plant.diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{plant.diseases.length > 0 ? (
<div className="space-y-6">
{plant.diseases.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">🌿</span>
<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>
);
}

View File

@@ -0,0 +1,34 @@
import React, { Suspense } from "react";
import BrowseContent from "./BrowseContent";
import { PlantCardSkeleton } from "@/components/LoadingSkeleton";
/**
* Browse page requires a Suspense boundary because it uses useSearchParams().
* The actual interactive content is in BrowseContent (client component).
*/
export default function BrowsePage() {
return (
<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 />
</Suspense>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}
}

View 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>
);
}

View 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&apos;t seem to exist. Perhaps it wilted away, or the
URL got pruned. Let&apos;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>
);
}

182
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,182 @@
import React from "react";
import Link from "next/link";
import PlantCard from "@/components/PlantCard";
import { getFeaturedPlants } from "@/data/plants";
import { TRUST_SIGNALS, HOW_IT_WORKS, APP_NAME, APP_TAGLINE } from "@/lib/constants";
export default function HomePage() {
const featuredPlants = getFeaturedPlants();
return (
<div className="flex flex-col">
{/* ─── Hero Section ─── */}
<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">
{featuredPlants.map((plant) => (
<PlantCard key={plant.id} plant={plant} />
))}
</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 &amp; 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>
);
}

View 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}
/>
);
}

View 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("/");
}

View 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>
);
}