Files
plant-disease-id/apps/web/src/app/browse/[plantId]/DiseaseCards.tsx
2026-06-07 12:06:41 -04:00

525 lines
18 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.
"use client";
import { useState, useCallback, useMemo } from "react";
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
import ImageLightbox from "@/components/ImageLightbox";
import FlagButton from "@/components/FlagButton";
// ─── Severity badge ───
function SeverityBadge({ severity }: { severity: Severity }) {
const colors: Record<Severity, string> = {
low: "bg-leaf-green-100 text-leaf-green-800 dark:bg-leaf-green-900/40 dark:text-leaf-green-300",
moderate:
"bg-warning-amber-100 text-warning-amber-800 dark:bg-warning-amber-900/40 dark:text-warning-amber-300",
high: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const labels: Record<Severity, string> = {
low: "Low",
moderate: "Moderate",
high: "High",
critical: "Critical",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}
>
{severity === "critical" ? "🚨 " : ""}
{labels[severity]} Severity
</span>
);
}
// ─── Disease type badge ───
function TypeBadge({ type }: { type: CausalAgentType }) {
const colors: Record<CausalAgentType, string> = {
fungal: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
bacterial: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
viral: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300",
environmental: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type]}`}
>
{type === "environmental" ? "Environmental" : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
}
// ─── Disease card ───
function DiseaseCard({
disease,
onImageClick,
}: {
disease: Disease;
onImageClick: (disease: Disease) => void;
}) {
return (
<div
id={`disease-${disease.id}`}
className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
{/* Card header */}
<div className="p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{disease.name}
</h3>
{disease.scientificName && (
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic mt-0.5">
{disease.scientificName}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<PrevalenceBadge prevalence={disease.prevalence} />
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
</div>
{/* Disease image or placeholder */}
<div className="mb-2 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700 relative">
{disease.imageUrl ? (
<button
type="button"
onClick={() => onImageClick(disease)}
className="block w-full cursor-pointer group"
aria-label={`View larger image of ${disease.name} symptoms`}
>
<img
src={disease.imageUrl}
alt={`${disease.name} symptoms`}
className="w-full h-48 sm:h-64 object-cover transition-all duration-200 group-hover:brightness-75 group-hover:scale-[1.02]"
loading="lazy"
/>
</button>
) : (
<div className="flex items-center justify-center h-36 sm:h-48 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-800 dark:to-zinc-900">
<div className="text-center">
<span className="text-5xl block mb-2" aria-hidden="true">
{disease.causalAgentType === "fungal"
? "🍄"
: disease.causalAgentType === "bacterial"
? "🦠"
: disease.causalAgentType === "viral"
? "🧬"
: disease.causalAgentType === "environmental"
? "🌡️"
: "🔬"}
</span>
<p className="text-xs text-zinc-400 dark:text-zinc-500">
{disease.causalAgentType === "fungal"
? "Fungal pathogen"
: disease.causalAgentType === "bacterial"
? "Bacterial infection"
: disease.causalAgentType === "viral"
? "Viral infection"
: "Environmental disorder"}
</p>
</div>
</div>
)}
</div>
{/* Flag button for disease image */}
<div className="flex justify-end mb-2">
<FlagButton
contentType="disease_image"
contentId={disease.id}
fieldName="image"
label="disease image"
small
/>
</div>
<div className="flex items-start justify-between gap-4 mb-4">
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{disease.description}
</p>
<FlagButton
contentType="disease_description"
contentId={disease.id}
fieldName="description"
label="description"
small
className="shrink-0 mt-0.5"
/>
</div>
{/* Details grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Symptoms */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 flex items-center gap-1">
<span aria-hidden="true"></span> Symptoms
</h4>
<FlagButton
contentType="disease_symptoms"
contentId={disease.id}
fieldName="symptoms"
label="symptoms"
small
/>
</div>
<ul className="space-y-1.5">
{disease.symptoms.map((symptom, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-red-400 dark:bg-red-500" />
{symptom}
</li>
))}
</ul>
</div>
{/* Causes */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 flex items-center gap-1">
<span aria-hidden="true">🔍</span> Causes
</h4>
<FlagButton
contentType="disease_causes"
contentId={disease.id}
fieldName="causes"
label="causes"
small
/>
</div>
<ul className="space-y-1.5">
{disease.causes.map((cause, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-orange-400 dark:bg-orange-500" />
{cause}
</li>
))}
</ul>
</div>
{/* Treatment Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
<span aria-hidden="true">💊</span> Treatment Steps
</h4>
<FlagButton
contentType="disease_treatment"
contentId={disease.id}
fieldName="treatment"
label="treatment"
small
/>
</div>
<ol className="space-y-1.5 list-decimal list-inside">
{disease.treatment.map((step, i) => (
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
{step}
</li>
))}
</ol>
</div>
{/* Prevention Tips */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
<span aria-hidden="true">🛡</span> Prevention Tips
</h4>
<FlagButton
contentType="disease_prevention"
contentId={disease.id}
fieldName="prevention"
label="prevention tips"
small
/>
</div>
<ul className="space-y-1.5">
{disease.prevention.map((tip, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300"
>
<span className="mt-1 shrink-0 h-1.5 w-1.5 rounded-full bg-leaf-green-400 dark:bg-leaf-green-500" />
{tip}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
// ─── Prevalence badge ───
function PrevalenceBadge({ prevalence }: { prevalence: Prevalence }) {
const icons: Record<Prevalence, string> = {
common: "📊",
uncommon: "📋",
rare: "📌",
very_rare: "🔍",
};
const colors: Record<Prevalence, string> = {
common: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
uncommon: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300",
rare: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
very_rare: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
};
const label = prevalence.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[prevalence]}`}
>
{icons[prevalence]} {label}
</span>
);
}
// ─── Sort / Search controls ───
const SEVERITY_RANK: Record<Severity, number> = {
critical: 4,
high: 3,
moderate: 2,
low: 1,
};
const PREVALENCE_RANK: Record<Prevalence, number> = {
common: 4,
uncommon: 3,
rare: 2,
very_rare: 1,
};
type SortField = "prevalence" | "danger";
function SearchSortBar({
searchQuery,
onSearchChange,
sortField,
onSortFieldChange,
sortOrder,
onSortOrderToggle,
resultCount,
}: {
searchQuery: string;
onSearchChange: (q: string) => void;
sortField: SortField;
onSortFieldChange: (f: SortField) => void;
sortOrder: "asc" | "desc";
onSortOrderToggle: () => void;
resultCount: number;
}) {
return (
<div className="mb-6 space-y-4">
{/* Search */}
<div className="relative">
<span
className="absolute inset-y-0 left-0 flex items-center pl-3 text-zinc-400 dark:text-zinc-500 pointer-events-none"
aria-hidden="true"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"
/>
</svg>
</span>
<input
type="search"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search diseases by name…"
className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 py-2 pl-10 pr-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-colors"
aria-label="Search diseases"
/>
</div>
{/* Sort controls */}
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-zinc-500 dark:text-zinc-400 font-medium">Sort by:</span>
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600 overflow-hidden">
<button
type="button"
onClick={() => onSortFieldChange("prevalence")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
sortField === "prevalence"
? "bg-leaf-green-600 text-white"
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
Prevalence
</button>
<button
type="button"
onClick={() => onSortFieldChange("danger")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
sortField === "danger"
? "bg-leaf-green-600 text-white"
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
Danger
</button>
</div>
{/* Direction toggle */}
<button
type="button"
onClick={onSortOrderToggle}
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
aria-label={
sortOrder === "desc"
? "Sorted descending, click for ascending"
: "Sorted ascending, click for descending"
}
>
<svg
className={`h-3.5 w-3.5 transition-transform ${sortOrder === "asc" ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
{sortField === "danger"
? sortOrder === "desc"
? "Most dangerous first"
: "Least dangerous first"
: sortOrder === "desc"
? "Most prevalent first"
: "Least prevalent first"}
</button>
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-auto">
{resultCount} {resultCount === 1 ? "result" : "results"}
</span>
</div>
</div>
);
}
// ─── Client component wrapper ───
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>("danger");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// ── Filtered + sorted diseases ──
const processed = useMemo(() => {
// Filter
let result = diseases;
const trimmed = searchQuery.trim().toLowerCase();
if (trimmed) {
result = result.filter(
(d) =>
d.name.toLowerCase().includes(trimmed) ||
d.scientificName.toLowerCase().includes(trimmed),
);
}
// Sort
const sorted = [...result].sort((a, b) => {
let cmp: number;
if (sortField === "danger") {
cmp = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
} else {
cmp = PREVALENCE_RANK[a.prevalence] - PREVALENCE_RANK[b.prevalence];
}
return sortOrder === "desc" ? -cmp : cmp;
});
return sorted;
}, [diseases, searchQuery, sortField, sortOrder]);
// Build list of images from processed diseases that have imageUrls
const images = useMemo(
() =>
processed
.filter((d) => d.imageUrl)
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` })),
[processed],
);
const handleImageClick = useCallback(
(disease: Disease) => {
const index = images.findIndex((img) => img.src === disease.imageUrl);
setLightboxIndex(index >= 0 ? index : 0);
setLightboxOpen(true);
},
[images],
);
const handleClose = useCallback(() => setLightboxOpen(false), []);
const handleSortOrderToggle = useCallback(() => {
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
}, []);
if (diseases.length === 0) return null;
return (
<>
<SearchSortBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortField={sortField}
onSortFieldChange={setSortField}
sortOrder={sortOrder}
onSortOrderToggle={handleSortOrderToggle}
resultCount={processed.length}
/>
{processed.length > 0 ? (
<div className="space-y-6">
{processed.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">
🔍
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
No diseases match &ldquo;{searchQuery}&rdquo;.
</p>
</div>
)}
{lightboxOpen && images.length > 0 && (
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
)}
</>
);
}