This commit is contained in:
2026-06-06 15:09:46 -04:00
parent 78220d3568
commit 06295c83ca
56 changed files with 12018 additions and 440 deletions

View File

@@ -31,13 +31,13 @@ const IMAGENET_MEAN = [0.485, 0.456, 0.406] as const;
const IMAGENET_STD = [0.229, 0.224, 0.225] as const;
/** Model input size */
const MODEL_SIZE = 224;
const MODEL_SIZE = 160;
// ─── Server-side image preprocessing ─────────────────────────────────────────
/**
* Load an uploaded image and preprocess it into a Float32Array tensor
* with shape [3, 224, 224] (NCHW without batch dim) using ImageNet normalization.
* with shape [3, 160, 160] (NCHW without batch dim) using ImageNet normalization.
*
* @param imageId - The image ID from the upload endpoint
* @returns Float32Array tensor ready for inference

View File

@@ -0,0 +1,38 @@
/**
* POST /api/plants/[id]/view
*
* Increments the view count for a plant in the plant_views table.
* Called client-side from the plant detail page via a tiny tracker component.
*/
import { NextResponse } from "next/server";
import { eq, sql } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plantViews } from "@/lib/db/schema";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Missing plant id" }, { status: 400 });
}
try {
const db = getDb();
// Upsert: increment view_count if row exists, otherwise insert with count 1
await db
.insert(plantViews)
.values({ plantId: id, viewCount: 1 })
.onConflictDoUpdate({
target: plantViews.plantId,
set: { viewCount: sql`${plantViews.viewCount} + 1` },
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[View] Failed to record view for", id, err);
// Swallow errors — tracking failure shouldn't break the page
return NextResponse.json({ ok: true });
}
}

View File

@@ -37,6 +37,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/tomato.jpg",
diseaseCount: 15,
},
{
@@ -45,6 +46,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Ocimum basilicum",
family: "Lamiaceae",
category: "herb",
imageUrl: "https://example.com/basil.jpg",
diseaseCount: 3,
},
{
@@ -53,6 +55,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Rosa spp.",
family: "Rosaceae",
category: "flower",
imageUrl: "https://example.com/rose.jpg",
diseaseCount: 7,
},
{
@@ -61,6 +64,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Monstera deliciosa",
family: "Araceae",
category: "houseplant",
imageUrl: "https://example.com/monstera.jpg",
diseaseCount: 5,
},
{
@@ -69,6 +73,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Dracaena trifasciata",
family: "Asparagaceae",
category: "houseplant",
imageUrl: "https://example.com/snake-plant.jpg",
diseaseCount: 2,
},
{
@@ -77,6 +82,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Capsicum annuum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/pepper.jpg",
diseaseCount: 9,
},
];

View File

@@ -7,6 +7,14 @@ import EmptyState from "@/components/EmptyState";
import { PLANT_CATEGORIES } from "@/lib/constants";
import type { PlantCardData } from "@/components/PlantCard";
type SortKey = "name" | "recent" | "popular";
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: "name", label: "Name (A-Z)" },
{ value: "recent", label: "Recently Updated" },
{ value: "popular", label: "Most Popular" },
];
interface BrowseContentProps {
allPlants: PlantCardData[];
}
@@ -24,6 +32,7 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [activeCategory, setActiveCategory] = useState<Category>("all");
const [sortKey, setSortKey] = useState<SortKey>("name");
const filteredPlants = useMemo(() => {
let result = allPlants;
@@ -42,8 +51,22 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
);
}
return result;
}, [activeCategory, searchQuery, allPlants]);
// Sort
const sorted = [...result];
if (sortKey === "recent") {
sorted.sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bTime - aTime; // newest first
});
} else if (sortKey === "popular") {
sorted.sort((a, b) => (b.viewCount ?? 0) - (a.viewCount ?? 0));
} else {
sorted.sort((a, b) => a.commonName.localeCompare(b.commonName));
}
return sorted;
}, [activeCategory, searchQuery, allPlants, sortKey]);
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
@@ -55,44 +78,14 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
</p>
</div>
{/* Search bar */}
<div className="relative mb-6">
<label htmlFor="browse-search" className="sr-only">
Search plants and diseases
</label>
<div className="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or family..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-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-all shadow-sm"
/>
</div>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
{/* Controls row: search + sort */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Search bar */}
<div className="relative flex-1">
<label htmlFor="browse-search" className="sr-only">
Search plants and diseases
</label>
<div className="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
@@ -103,13 +96,80 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
)}
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or family..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-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-all shadow-sm"
/>
</div>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
{/* Sort dropdown */}
<div className="relative shrink-0">
<label htmlFor="sort-select" className="sr-only">
Sort by
</label>
<select
id="sort-select"
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="w-full sm:w-auto appearance-none rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 px-4 py-3 pr-10 text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm cursor-pointer"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
{/* Category filter chips */}

View File

@@ -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 &ldquo;{searchQuery}&rdquo;.
</p>
</div>
)}
{lightboxOpen && images.length > 0 && (
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />

View File

@@ -1,9 +1,11 @@
import Image from "next/image";
import Link from "next/link";
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 { getPlantDescription } from "@/lib/display-helpers";
import DiseaseCards from "./DiseaseCards";
import PlantViewTracker from "@/components/PlantViewTracker";
interface Props {
params: Promise<{ plantId: string }>;
@@ -44,7 +46,6 @@ export default async function PlantDetailPage({ params }: Props) {
}
const { plant, diseases } = result;
const emoji = getEmojiForCategory(plant.category);
const description = getPlantDescription(
plant.commonName,
plant.scientificName,
@@ -53,107 +54,135 @@ export default async function PlantDetailPage({ params }: Props) {
);
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>
<>
<PlantViewTracker plantId={plantId} />
<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">
{emoji}
</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">
{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>
{/* Plant hero */}
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
{/* Plant image */}
<div className="relative h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
{plant.imageUrl ? (
<Image
src={plant.imageUrl}
alt={plant.commonName}
fill
className="object-cover"
sizes="(min-width: 640px) 16rem, 8rem"
unoptimized
/>
) : (
<div className="flex items-center justify-center w-full h-full">
<svg
className="w-12 h-12 text-leaf-green-300 dark:text-leaf-green-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
</svg>
</div>
)}
</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.
<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">
{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>
<Link
href="/upload"
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>
{/* 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="/upload"
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">
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${diseases.length} ${diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{diseases.length > 0 ? (
<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">
🌿
</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>
{/* 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">
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${diseases.length} ${diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{diseases.length > 0 ? (
<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">
🌿
</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>
</>
);
}

View File

@@ -10,6 +10,7 @@ describe("PlantCard", () => {
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/tomato.jpg",
diseaseCount: 2,
};
@@ -18,10 +19,18 @@ describe("PlantCard", () => {
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("renders plant emoji (generated from category)", () => {
it("renders plant image", () => {
render(<PlantCard plant={mockPlant} />);
// Vegetable category → 🥬 emoji
expect(screen.getByText("🥬")).toBeInTheDocument();
const img = screen.getByRole("img") as HTMLImageElement;
expect(img).toHaveAttribute("src", expect.stringContaining("tomato.jpg"));
expect(img).toHaveAttribute("alt", "Tomato");
});
it("renders fallback SVG when no image URL", () => {
const noImagePlant = { ...mockPlant, imageUrl: "" };
render(<PlantCard plant={noImagePlant} />);
// Should render SVG fallback instead of image
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("renders plant family", () => {

View File

@@ -1,6 +1,5 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { getEmojiForCategory } from "@/lib/display-helpers";
export interface PlantCardData {
id: string;
@@ -8,7 +7,10 @@ export interface PlantCardData {
scientificName: string;
family: string;
category: string;
imageUrl: string;
diseaseCount: number;
updatedAt?: string;
viewCount?: number;
}
interface PlantCardProps {
@@ -17,26 +19,45 @@ interface PlantCardProps {
}
/**
* Plant card showing emoji, name, family, and optional disease count.
* Plant card showing image, name, family, and optional disease count.
* Used on the homepage featured section and browse grid.
*/
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
const emoji = getEmojiForCategory(plant.category);
return (
<Link
href={`/browse/${plant.id}`}
className="group block rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-all duration-200 hover:-translate-y-1 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
{/* Emoji illustration area */}
<div className="flex items-center justify-center h-40 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 transition-transform duration-300 group-hover:scale-110"
role="img"
aria-hidden="true"
>
{emoji}
</span>
{/* Plant image */}
<div className="relative h-40 bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900 overflow-hidden">
{plant.imageUrl ? (
<Image
src={plant.imageUrl}
alt={plant.commonName}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
unoptimized
/>
) : (
<div className="flex items-center justify-center w-full h-full">
<svg
className="w-16 h-16 text-leaf-green-300 dark:text-leaf-green-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
</svg>
</div>
)}
</div>
<div className="p-4">

View File

@@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
/**
* Tracks a plant page view by POSTing to the view-count API.
* Renders nothing — purely a side-effect component.
*/
export default function PlantViewTracker({ plantId }: { plantId: string }) {
useEffect(() => {
const controller = new AbortController();
fetch(`/api/plants/${encodeURIComponent(plantId)}/view`, {
method: "POST",
signal: controller.signal,
// Keepalive so the request completes even if the user navigates away quickly
keepalive: true,
}).catch(() => {
// Silently ignore tracking failures
});
return () => controller.abort();
}, [plantId]);
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
import { sql, eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plants, diseases } from "@/lib/db/schema";
import { plants, diseases, plantViews } from "@/lib/db/schema";
import type { PlantCardData } from "@/components/PlantCard";
export type { PlantCardData };
@@ -24,10 +24,14 @@ export async function getBrowsePlants(): Promise<PlantCardData[]> {
scientificName: plants.scientificName,
family: plants.family,
category: plants.category,
imageUrl: plants.imageUrl,
updatedAt: plants.updatedAt,
viewCount: sql<number>`COALESCE(${plantViews.viewCount}, 0)`,
diseaseCount: sql<number>`COUNT(${diseases.id})`,
})
.from(plants)
.leftJoin(diseases, eq(diseases.plantId, plants.id))
.leftJoin(plantViews, eq(plantViews.plantId, plants.id))
.groupBy(plants.id)
.orderBy(plants.commonName);
@@ -37,6 +41,9 @@ export async function getBrowsePlants(): Promise<PlantCardData[]> {
scientificName: r.scientificName,
family: r.family,
category: r.category,
imageUrl: r.imageUrl,
updatedAt: r.updatedAt,
viewCount: r.viewCount,
diseaseCount: r.diseaseCount,
}));
}
@@ -53,6 +60,7 @@ export async function getBrowsePlant(id: string): Promise<PlantCardData | null>
scientificName: plants.scientificName,
family: plants.family,
category: plants.category,
imageUrl: plants.imageUrl,
diseaseCount: sql<number>`COUNT(${diseases.id})`,
})
.from(plants)

View File

@@ -18,6 +18,7 @@ import type {
Plant,
PlantListParams,
PlantWithDiseases,
Prevalence,
Severity,
PlantCategory,
} from "@/lib/types";
@@ -50,6 +51,7 @@ function toDisease(row: typeof diseases.$inferSelect): Disease {
prevention: row.prevention as string[],
lookalikeDiseaseIds: (row.lookalikeIds as string[]) ?? [],
severity: row.severity as Severity,
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
imageUrl: (row.imageUrl as string) || undefined,
};
}
@@ -278,6 +280,7 @@ export async function validateKnowledgeBase(): Promise<string[]> {
"environmental",
];
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare"];
const db = getDb();
@@ -328,6 +331,11 @@ export async function validateKnowledgeBase(): Promise<string[]> {
errors.push(`Disease "${d.id}" has invalid severity: ${full.severity}`);
}
// Valid prevalence
if (full.prevalence && !validPrevalences.includes(full.prevalence as Prevalence)) {
errors.push(`Disease "${d.id}" has invalid prevalence: ${full.prevalence}`);
}
// Minimum counts
const symptoms = full.symptoms as string[];
const causes = full.causes as string[];

View File

@@ -10,7 +10,7 @@
*/
import { createClient, type InValue } from "@libsql/client";
import type { Plant, Disease, CausalAgentType, Severity } from "./types";
import type { Plant, Disease, CausalAgentType, Prevalence, Severity } from "./types";
// ─── Client ──────────────────────────────────────────────────────────────────
@@ -173,6 +173,7 @@ export function rowToDisease(row: Record<string, unknown>): Disease {
prevention: JSON.parse(row.prevention as string) as string[],
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
severity: row.severity as Severity,
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
};
}

View File

@@ -54,6 +54,11 @@ export const diseases = sqliteTable(
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
prevention: text("prevention", { mode: "json" }).notNull().default([]).$type<string[]>(),
lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type<string[]>(),
prevalence: text("prevalence", {
enum: ["common", "uncommon", "rare"],
})
.notNull()
.default("uncommon"),
severity: text("severity", {
enum: ["low", "moderate", "high", "critical"],
}).notNull(),
@@ -70,6 +75,7 @@ export const diseases = sqliteTable(
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
severityIdx: index("idx_diseases_severity").on(table.severity),
prevalenceIdx: index("idx_diseases_prevalence").on(table.prevalence),
}),
);
@@ -92,6 +98,21 @@ export const scrapeSources = sqliteTable("scrape_sources", {
.default(sql`(datetime('now'))`),
});
// ─── Plant Views Table ───────────────────────────────────────────────────────
export const plantViews = sqliteTable(
"plant_views",
{
plantId: text("plant_id")
.primaryKey()
.references(() => plants.id),
viewCount: integer("view_count").notNull().default(0),
},
(table) => ({
viewCountIdx: index("idx_plant_views_count").on(table.viewCount),
}),
);
// ─── Relation Inference ──────────────────────────────────────────────────────
export const plantsRelations = {};

View File

@@ -1,21 +1,21 @@
/**
* Client-side image preprocessing pipeline.
*
* Resizes images to model-expected dimensions (224×224 by default),
* Resizes images to model-expected dimensions (160×160 by default),
* converts RGBA → RGB, normalizes pixel values, and produces flat
* Float32Array tensors ready for ML inference or base64 transmission.
*
* Tensor shape: [1, 3, 224, 224] — NCHW layout matching MobileNet / ResNet.
* Tensor shape: [1, 3, 160, 160] — NCHW layout matching MobileNetV2.
*
* Configurable via env:
* IMAGE_MODEL_SIZE — target dimension (default 224)
* IMAGE_MODEL_SIZE — target dimension (default 160)
* IMAGE_MEAN_R/G/B — per-channel mean for normalization (default 0.485, 0.456, 0.406 — ImageNet)
* IMAGE_STD_R/G/B — per-channel std for normalization (default 0.229, 0.224, 0.225 — ImageNet)
*/
// ─── Configuration ───────────────────────────────────────────────────────────
const DEFAULT_MODEL_SIZE = 224;
const DEFAULT_MODEL_SIZE = 160;
const DEFAULT_MEAN = [0.485, 0.456, 0.406] as const; // ImageNet RGB means
const DEFAULT_STD = [0.229, 0.224, 0.225] as const; // ImageNet RGB stds

View File

@@ -130,12 +130,12 @@ describe("createRandomTensor", () => {
});
describe("INPUT_SHAPE and INPUT_SIZE", () => {
it("INPUT_SHAPE is [1, 3, 224, 224]", () => {
expect(INPUT_SHAPE).toEqual([1, 3, 224, 224]);
it("INPUT_SHAPE is [1, 3, 160, 160]", () => {
expect(INPUT_SHAPE).toEqual([1, 3, 160, 160]);
});
it("INPUT_SIZE equals 3 * 224 * 224", () => {
expect(INPUT_SIZE).toBe(3 * 224 * 224);
it("INPUT_SIZE equals 3 * 160 * 160", () => {
expect(INPUT_SIZE).toBe(3 * 160 * 160);
});
it("DEFAULT_TOP_K is 5", () => {

View File

@@ -15,18 +15,18 @@ import { softmaxFloat32, getTopKFloat32 } from "./confidence";
/** Number of top predictions to return */
export const DEFAULT_TOP_K = 5;
/** Input tensor shape: [batch=1, channels=3, height=224, width=224] */
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 224, 224];
/** Input tensor shape: [batch=1, channels=3, height=160, width=160] */
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 160, 160];
/** Expected input tensor length */
export const INPUT_SIZE = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3]; // 3 * 224 * 224 = 150528
export const INPUT_SIZE = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3]; // 3 * 160 * 160 = 76800
// ─── Main Inference ──────────────────────────────────────────────────────────
/**
* Run the full inference pipeline on a preprocessed image tensor.
*
* @param imageTensor - Normalized Float32Array of shape [1, 3, 224, 224] (NCHW)
* @param imageTensor - Normalized Float32Array of shape [1, 3, 160, 160] (NCHW)
* @param topK - Number of top predictions to return (default 5)
* @returns InferenceResult with top-K predictions and timing
*/

View File

@@ -196,8 +196,8 @@ async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
async predict(tensor: Float32Array): Promise<ModelOutput> {
const startTime = performance.now();
// Reshape to [1, 3, 224, 224] NCHW → [1, 224, 224, 3] NHWC for TF.js
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 224, 224])
// Reshape to [1, 3, 160, 160] NCHW → [1, 160, 160, 3] NHWC for TF.js
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 160, 160])
.transpose([1, 2, 0])
.expandDims(0);
@@ -220,7 +220,7 @@ async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
loaded: true,
backend: "tfjs",
modelId: MODEL_ID,
numClasses: 95, // Will be updated after model loads
numClasses: 38, // Original PlantVillage model
};
},
};
@@ -256,8 +256,8 @@ async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
async predict(tensor: Float32Array): Promise<ModelOutput> {
const startTime = performance.now();
// ONNX expects NCHW format: [1, 3, 224, 224]
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 224, 224]);
// ONNX expects NCHW format: [1, 3, 160, 160]
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 160, 160]);
const feeds = { [session.inputNames[0]]: inputTensor };
const results = await session.run(feeds);
@@ -278,7 +278,7 @@ async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
loaded: true,
backend: "onnx",
modelId: MODEL_ID,
numClasses: 95,
numClasses: 38,
};
},
};
@@ -313,7 +313,7 @@ function createMockModel(): PlantDiseaseModel {
loaded: false,
backend: "mock",
modelId: MODEL_ID,
numClasses: 95,
numClasses: 38,
error: "Model files not found. Running in demo mode with mock predictions.",
};
},
@@ -326,7 +326,7 @@ function createMockModel(): PlantDiseaseModel {
* reproducible but varied predictions.
*/
function generateMockLogits(tensor: Float32Array): Float32Array {
const numClasses = 95;
const numClasses = 38;
const logits = new Float32Array(numClasses);
// Simple hash of input for deterministic output

View File

@@ -9,6 +9,9 @@ export type CausalAgentType = "fungal" | "bacterial" | "viral" | "environmental"
/** Severity levels for plant diseases */
export type Severity = "low" | "moderate" | "high" | "critical";
/** How common/prevalent a disease is in the field */
export type Prevalence = "common" | "uncommon" | "rare";
/** Plant category for grouping and filtering */
export type PlantCategory =
| "vegetable"
@@ -69,6 +72,8 @@ export interface Disease {
lookalikeDiseaseIds: string[];
/** Overall severity of the disease */
severity: Severity;
/** How common/prevalent this disease is */
prevalence: Prevalence;
/** URL to a representative image showing disease symptoms */
imageUrl?: string;
}