This commit is contained in:
2026-06-06 10:15:53 -04:00
parent 71d7a9d6f0
commit 78220d3568
11 changed files with 1315 additions and 335 deletions

View File

@@ -0,0 +1,247 @@
"use client";
import { useState, useCallback } from "react";
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
import ImageLightbox from "@/components/ImageLightbox";
// ─── Severity badge ───
function SeverityBadge({ severity }: { severity: Severity }) {
const colors: Record<Severity, string> = {
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
moderate:
"bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const labels: Record<Severity, string> = {
low: "Low",
moderate: "Moderate",
high: "High",
critical: "Critical",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
>
{severity === "critical" ? "🚨 " : ""}
{labels[severity]} Severity
</span>
);
}
// ─── Disease type badge ───
function TypeBadge({ type }: { type: CausalAgentType }) {
const colors: Record<CausalAgentType, string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
// ─── Disease card ───
function DiseaseCard({
disease,
onImageClick,
}: {
disease: Disease;
onImageClick: (disease: Disease) => void;
}) {
return (
<div
id={`disease-${disease.id}`}
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
{/* Card header */}
<div className="p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{disease.name}
</h3>
{disease.scientificName && (
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
{disease.scientificName}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
{/* Disease image or placeholder */}
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
{disease.imageUrl ? (
<button
type="button"
onClick={() => onImageClick(disease)}
className="block w-full cursor-pointer group"
aria-label={`View larger image of ${disease.name} symptoms`}
>
<img
src={disease.imageUrl}
alt={`${disease.name} symptoms`}
className="w-full h-48 sm:h-64 object-cover transition-all duration-200 group-hover:brightness-75 group-hover:scale-[1.02]"
loading="lazy"
/>
</button>
) : (
<div className="flex items-center justify-center h-36 sm:h-48 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-800 dark:to-zinc-900">
<div className="text-center">
<span className="text-5xl block mb-2" aria-hidden="true">
{disease.causalAgentType === "fungal"
? "🍄"
: disease.causalAgentType === "bacterial"
? "🦠"
: disease.causalAgentType === "viral"
? "🧬"
: disease.causalAgentType === "environmental"
? "🌡️"
: "🔬"}
</span>
<p className="text-xs text-zinc-400 dark:text-zinc-500">
{disease.causalAgentType === "fungal"
? "Fungal pathogen"
: disease.causalAgentType === "bacterial"
? "Bacterial infection"
: disease.causalAgentType === "viral"
? "Viral infection"
: "Environmental disorder"}
</p>
</div>
</div>
)}
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
{disease.description}
</p>
{/* Details grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Symptoms */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
<span aria-hidden="true"></span> Symptoms
</h4>
<ul className="space-y-1.5">
{disease.symptoms.map((symptom, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
{symptom}
</li>
))}
</ul>
</div>
{/* Causes */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">🔍</span> Causes
</h4>
<ul className="space-y-1.5">
{disease.causes.map((cause, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
{cause}
</li>
))}
</ul>
</div>
{/* Treatment Steps */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatment.map((step, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
{step}
</li>
))}
</ol>
</div>
{/* Prevention Tips */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<ul className="space-y-1.5">
{disease.prevention.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
{tip}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
// ─── Client component wrapper ───
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
// Build list of images from diseases that have imageUrls
const images = diseases
.filter((d) => d.imageUrl)
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` }));
const handleImageClick = useCallback(
(disease: Disease) => {
const index = images.findIndex((img) => img.src === disease.imageUrl);
setLightboxIndex(index >= 0 ? index : 0);
setLightboxOpen(true);
},
[images],
);
const handleClose = useCallback(() => setLightboxOpen(false), []);
if (diseases.length === 0) return null;
return (
<>
<div className="space-y-6">
{diseases.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
))}
</div>
{lightboxOpen && images.length > 0 && (
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
)}
</>
);
}

View File

@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
import { getEmojiForCategory, getPlantDescription } from "@/lib/display-helpers";
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
import DiseaseCards from "./DiseaseCards";
interface Props {
params: Promise<{ plantId: string }>;
@@ -33,171 +33,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
// ─── Severity badge ───
function SeverityBadge({ severity }: { severity: Severity }) {
const colors: Record<Severity, string> = {
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
moderate:
"bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const labels: Record<Severity, string> = {
low: "Low",
moderate: "Moderate",
high: "High",
critical: "Critical",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
>
{severity === "critical" ? "🚨 " : ""}
{labels[severity]} Severity
</span>
);
}
// ─── Disease type badge ───
function TypeBadge({ type }: { type: CausalAgentType }) {
const colors: Record<CausalAgentType, string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
// ─── Disease card ───
function DiseaseCard({ disease }: { disease: Disease }) {
return (
<div
id={`disease-${disease.id}`}
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
{/* Card header */}
<div className="p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{disease.name}
</h3>
{disease.scientificName && (
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
{disease.scientificName}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
{/* Disease image */}
{disease.imageUrl && (
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
<img
src={disease.imageUrl}
alt={`${disease.name} symptoms on ${disease.plantId}`}
className="w-full h-48 sm:h-64 object-cover"
loading="lazy"
/>
</div>
)}
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
{disease.description}
</p>
{/* Details grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Symptoms */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
<span aria-hidden="true"></span> Symptoms
</h4>
<ul className="space-y-1.5">
{disease.symptoms.map((symptom, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
{symptom}
</li>
))}
</ul>
</div>
{/* Causes */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">🔍</span> Causes
</h4>
<ul className="space-y-1.5">
{disease.causes.map((cause, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
{cause}
</li>
))}
</ul>
</div>
{/* Treatment Steps */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatment.map((step, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
{step}
</li>
))}
</ol>
</div>
{/* Prevention Tips */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<ul className="space-y-1.5">
{disease.prevention.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
{tip}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
// ─── Plant Detail Page ───
export default async function PlantDetailPage({ params }: Props) {
@@ -307,11 +142,7 @@ export default async function PlantDetailPage({ params }: Props) {
</p>
{diseases.length > 0 ? (
<div className="space-y-6">
{diseases.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} />
))}
</div>
<DiseaseCards diseases={diseases} />
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">