beepboop
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
|
||||
import ImageLightbox from "@/components/ImageLightbox";
|
||||
|
||||
// ─── Severity badge ───
|
||||
@@ -79,6 +79,7 @@ function DiseaseCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PrevalenceBadge prevalence={disease.prevalence} />
|
||||
<TypeBadge type={disease.causalAgentType} />
|
||||
<SeverityBadge severity={disease.severity} />
|
||||
</div>
|
||||
@@ -207,16 +208,205 @@ function DiseaseCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Prevalence badge ───
|
||||
|
||||
function PrevalenceBadge({ prevalence }: { prevalence: Prevalence }) {
|
||||
const icons: Record<Prevalence, string> = {
|
||||
common: "📊",
|
||||
uncommon: "📋",
|
||||
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",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[prevalence]}`}
|
||||
>
|
||||
{icons[prevalence]} {prevalence.charAt(0).toUpperCase() + prevalence.slice(1)}
|
||||
</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: 3,
|
||||
uncommon: 2,
|
||||
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");
|
||||
|
||||
// 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` }));
|
||||
// ── 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) => {
|
||||
@@ -229,15 +419,40 @@ export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
|
||||
|
||||
const handleClose = useCallback(() => setLightboxOpen(false), []);
|
||||
|
||||
const handleSortOrderToggle = useCallback(() => {
|
||||
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
|
||||
}, []);
|
||||
|
||||
if (diseases.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{diseases.map((disease) => (
|
||||
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
|
||||
))}
|
||||
</div>
|
||||
<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} />
|
||||
|
||||
Reference in New Issue
Block a user