Files
plant-disease-id/apps/web/src/app/browse/[plantId]/page.tsx
Michael Freno 820a872f07 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)
2026-06-05 19:21:16 -04:00

298 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}