525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
"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 “{searchQuery}”.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{lightboxOpen && images.length > 0 && (
|
||
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|