beepboop
This commit is contained in:
@@ -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
|
||||
|
||||
38
apps/web/src/app/api/plants/[id]/view/route.ts
Normal file
38
apps/web/src/app/api/plants/[id]/view/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
26
apps/web/src/components/PlantViewTracker.tsx
Normal file
26
apps/web/src/components/PlantViewTracker.tsx
Normal 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
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user