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:
249
apps/web/src/app/about/page.tsx
Normal file
249
apps/web/src/app/about/page.tsx
Normal 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'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>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 & 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>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/app/api/diseases/[id]/route.ts
Normal file
44
apps/web/src/app/api/diseases/[id]/route.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
79
apps/web/src/app/api/diseases/route.ts
Normal file
79
apps/web/src/app/api/diseases/route.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
13
apps/web/src/app/api/health/route.ts
Normal file
13
apps/web/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
apps/web/src/app/api/identify/identify.test.ts
Normal file
241
apps/web/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";
|
||||
|
||||
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);
|
||||
});
|
||||
263
apps/web/src/app/api/identify/route.ts
Normal file
263
apps/web/src/app/api/identify/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/web/src/app/api/plants/[id]/route.ts
Normal file
37
apps/web/src/app/api/plants/[id]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
65
apps/web/src/app/api/plants/route.ts
Normal file
65
apps/web/src/app/api/plants/route.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
188
apps/web/src/app/api/upload/route.ts
Normal file
188
apps/web/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
apps/web/src/app/api/upload/upload.test.ts
Normal file
141
apps/web/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);
|
||||
});
|
||||
169
apps/web/src/app/browse/BrowseContent.tsx
Normal file
169
apps/web/src/app/browse/BrowseContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
apps/web/src/app/browse/[plantId]/page.tsx
Normal file
297
apps/web/src/app/browse/[plantId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/app/browse/page.tsx
Normal file
34
apps/web/src/app/browse/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
120
apps/web/src/app/globals.css
Normal file
120
apps/web/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
apps/web/src/app/layout.tsx
Normal file
52
apps/web/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>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/app/not-found.tsx
Normal file
38
apps/web/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>
|
||||
);
|
||||
}
|
||||
182
apps/web/src/app/page.tsx
Normal file
182
apps/web/src/app/page.tsx
Normal 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 & 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
apps/web/src/app/results/[imageId]/page.tsx
Normal file
114
apps/web/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
apps/web/src/app/results/page.tsx
Normal file
9
apps/web/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("/");
|
||||
}
|
||||
74
apps/web/src/app/upload/page.tsx
Normal file
74
apps/web/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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user